Page Menu
Home
WMGMC Issues
搜索
Configure Global Search
登录
Files
F16042
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
订阅
标记用于日后
授予令牌
Size
110 KB
Referenced Files
None
订阅者
None
View Options
diff --git a/src/applications/calendar/controller/PhabricatorCalendarEventViewController.php b/src/applications/calendar/controller/PhabricatorCalendarEventViewController.php
index 37a6a62456..40a347afdf 100644
--- a/src/applications/calendar/controller/PhabricatorCalendarEventViewController.php
+++ b/src/applications/calendar/controller/PhabricatorCalendarEventViewController.php
@@ -1,644 +1,647 @@
<?php
final class PhabricatorCalendarEventViewController
extends PhabricatorCalendarController {
public function shouldAllowPublic() {
return true;
}
public function handleRequest(AphrontRequest $request) {
$viewer = $request->getViewer();
$event = $this->loadEvent();
if (!$event) {
return new Aphront404Response();
}
// If we looked up or generated a stub event, redirect to that event's
// canonical URI.
$id = $request->getURIData('id');
if ($event->getID() != $id) {
$uri = $event->getURI();
return id(new AphrontRedirectResponse())->setURI($uri);
}
$monogram = $event->getMonogram();
$page_title = $monogram.' '.$event->getName();
$crumbs = $this->buildApplicationCrumbs();
$start = $event->newStartDateTime()
->newPHPDateTime();
$crumbs->addTextCrumb(
$start->format('F Y'),
'/calendar/query/month/'.$start->format('Y/m/'));
$crumbs->addTextCrumb(
$start->format('D jS'),
'/calendar/query/month/'.$start->format('Y/m/d/'));
$crumbs->addTextCrumb($monogram);
$crumbs->setBorder(true);
$timeline = $this->buildTransactionTimeline(
$event,
new PhabricatorCalendarEventTransactionQuery());
$header = $this->buildHeaderView($event);
$subheader = $this->buildSubheaderView($event);
$curtain = $this->buildCurtain($event);
$details = $this->buildPropertySection($event);
$recurring = $this->buildRecurringSection($event);
$description = $this->buildDescriptionView($event);
$comment_view = id(new PhabricatorCalendarEventEditEngine())
->setViewer($viewer)
->buildEditEngineCommentView($event);
$timeline->setQuoteRef($monogram);
$comment_view->setTransactionTimeline($timeline);
$details_header = id(new PHUIHeaderView())
->setHeader(pht('Details'));
$recurring_header = $this->buildRecurringHeader($event);
// NOTE: This is a bit hacky: for imported events, we're just hiding the
// comment form without actually preventing comments. Users could still
// submit a request to add comments to these events. This isn't really a
// major problem since they can't do anything truly bad and there isn't an
// easy way to selectively disable this or some other similar behaviors
// today, but it would probably be nice to fully disable these
// "pseudo-edits" (like commenting and probably subscribing and awarding
// tokens) at some point.
if ($event->isImportedEvent()) {
$comment_view = null;
$timeline->setShouldTerminate(true);
}
$view = id(new PHUITwoColumnView())
->setHeader($header)
->setSubheader($subheader)
->setMainColumn(
array(
$timeline,
$comment_view,
))
->setCurtain($curtain)
->addPropertySection(pht('Description'), $description)
->addPropertySection($recurring_header, $recurring)
->addPropertySection($details_header, $details);
return $this->newPage()
->setTitle($page_title)
->setCrumbs($crumbs)
->setPageObjectPHIDs(array($event->getPHID()))
->appendChild($view);
}
private function buildHeaderView(
PhabricatorCalendarEvent $event) {
$viewer = $this->getViewer();
$id = $event->getID();
if ($event->getIsCancelled()) {
$icon = 'fa-ban';
$color = 'red';
$status = pht('Cancelled');
} else {
$icon = 'fa-check';
$color = 'bluegrey';
$status = pht('Active');
}
$header = id(new PHUIHeaderView())
->setViewer($viewer)
->setHeader($event->getName())
->setStatus($icon, $color, $status)
->setPolicyObject($event)
->setHeaderIcon($event->getIcon());
if ($event->isImportedEvent()) {
$header->addTag(
id(new PHUITagView())
->setType(PHUITagView::TYPE_SHADE)
->setName(pht('Imported'))
->setIcon('fa-download')
->setHref($event->getImportSource()->getURI())
->setColor(PHUITagView::COLOR_ORANGE));
}
foreach ($this->buildRSVPActions($event) as $action) {
$header->addActionLink($action);
}
$options = PhabricatorCalendarEventInvitee::getAvailabilityMap();
$is_attending = $event->getIsUserAttending($viewer->getPHID());
if ($is_attending) {
$invitee = $event->getInviteeForPHID($viewer->getPHID());
$selected = $invitee->getDisplayAvailability($event);
if (!$selected) {
$selected = PhabricatorCalendarEventInvitee::AVAILABILITY_AVAILABLE;
}
$selected_option = idx($options, $selected);
$availability_select = id(new PHUIButtonView())
->setTag('a')
->setIcon('fa-circle '.$selected_option['color'])
->setText(pht('Availability: %s', $selected_option['name']));
$dropdown = id(new PhabricatorActionListView())
->setViewer($viewer);
foreach ($options as $key => $option) {
$uri = "event/availability/{$id}/{$key}/";
$uri = $this->getApplicationURI($uri);
$dropdown->addAction(
id(new PhabricatorActionView())
->setName($option['name'])
->setIcon('fa-circle '.$option['color'])
->setHref($uri)
->setWorkflow(true));
}
$availability_select->setDropdownMenu($dropdown);
+ $availability_select->setDisabled($event->isImportedEvent());
$header->addActionLink($availability_select);
}
return $header;
}
private function buildCurtain(PhabricatorCalendarEvent $event) {
$viewer = $this->getRequest()->getUser();
$id = $event->getID();
$is_attending = $event->getIsUserAttending($viewer->getPHID());
$can_edit = PhabricatorPolicyFilter::hasCapability(
$viewer,
$event,
PhabricatorPolicyCapability::CAN_EDIT);
$edit_uri = "event/edit/{$id}/";
$edit_uri = $this->getApplicationURI($edit_uri);
$is_recurring = $event->getIsRecurring();
$edit_label = pht('Edit Event');
$curtain = $this->newCurtainView($event);
if ($edit_label && $edit_uri) {
$curtain->addAction(
id(new PhabricatorActionView())
->setName($edit_label)
->setIcon('fa-pencil')
->setHref($edit_uri)
->setDisabled(!$can_edit)
->setWorkflow(!$can_edit || $is_recurring));
}
$recurring_uri = "{$edit_uri}page/recurring/";
$can_recurring = $can_edit && !$event->isChildEvent();
if ($event->getIsRecurring()) {
$recurring_label = pht('Edit Recurrence');
} else {
$recurring_label = pht('Make Recurring');
}
$curtain->addAction(
id(new PhabricatorActionView())
->setName($recurring_label)
->setIcon('fa-repeat')
->setHref($recurring_uri)
->setDisabled(!$can_recurring)
->setWorkflow(true));
$can_attend = !$event->isImportedEvent();
if ($is_attending) {
$curtain->addAction(
id(new PhabricatorActionView())
->setName(pht('Decline Event'))
->setIcon('fa-user-times')
->setHref($this->getApplicationURI("event/join/{$id}/"))
->setDisabled(!$can_attend)
->setWorkflow(true));
} else {
$curtain->addAction(
id(new PhabricatorActionView())
->setName(pht('Join Event'))
->setIcon('fa-user-plus')
->setHref($this->getApplicationURI("event/join/{$id}/"))
->setDisabled(!$can_attend)
->setWorkflow(true));
}
$cancel_uri = $this->getApplicationURI("event/cancel/{$id}/");
$cancel_disabled = !$can_edit;
$cancel_label = pht('Cancel Event');
$reinstate_label = pht('Reinstate Event');
if ($event->getIsCancelled()) {
$curtain->addAction(
id(new PhabricatorActionView())
->setName($reinstate_label)
->setIcon('fa-plus')
->setHref($cancel_uri)
->setDisabled($cancel_disabled)
->setWorkflow(true));
} else {
$curtain->addAction(
id(new PhabricatorActionView())
->setName($cancel_label)
->setIcon('fa-times')
->setHref($cancel_uri)
->setDisabled($cancel_disabled)
->setWorkflow(true));
}
$ics_name = $event->getICSFilename();
$export_uri = $this->getApplicationURI("event/export/{$id}/{$ics_name}");
$curtain->addAction(
id(new PhabricatorActionView())
->setName(pht('Export as .ics'))
->setIcon('fa-download')
->setHref($export_uri));
return $curtain;
}
private function buildPropertySection(
PhabricatorCalendarEvent $event) {
$viewer = $this->getViewer();
$properties = id(new PHUIPropertyListView())
->setViewer($viewer);
$invitees = $event->getInvitees();
foreach ($invitees as $key => $invitee) {
if ($invitee->isUninvited()) {
unset($invitees[$key]);
}
}
if ($invitees) {
$invitee_list = new PHUIStatusListView();
$icon_invited = PHUIStatusItemView::ICON_OPEN;
$icon_attending = PHUIStatusItemView::ICON_ACCEPT;
$icon_declined = PHUIStatusItemView::ICON_REJECT;
$status_invited = PhabricatorCalendarEventInvitee::STATUS_INVITED;
$status_attending = PhabricatorCalendarEventInvitee::STATUS_ATTENDING;
$status_declined = PhabricatorCalendarEventInvitee::STATUS_DECLINED;
$icon_map = array(
$status_invited => $icon_invited,
$status_attending => $icon_attending,
$status_declined => $icon_declined,
);
$icon_color_map = array(
$status_invited => null,
$status_attending => 'green',
$status_declined => 'red',
);
$viewer_phid = $viewer->getPHID();
$is_rsvp_invited = $event->isRSVPInvited($viewer_phid);
$type_user = PhabricatorPeopleUserPHIDType::TYPECONST;
$head = array();
$tail = array();
foreach ($invitees as $invitee) {
$item = new PHUIStatusItemView();
$invitee_phid = $invitee->getInviteePHID();
$status = $invitee->getStatus();
$target = $viewer->renderHandle($invitee_phid);
$is_user = (phid_get_type($invitee_phid) == $type_user);
if (!$is_user) {
$icon = 'fa-users';
$icon_color = 'blue';
} else {
$icon = $icon_map[$status];
$icon_color = $icon_color_map[$status];
}
// Highlight invited groups which you're a member of if you have
// not RSVP'd to an event yet.
if ($is_rsvp_invited) {
if ($invitee_phid != $viewer_phid) {
if ($event->hasRSVPAuthority($viewer_phid, $invitee_phid)) {
$item->setHighlighted(true);
}
}
}
$item->setIcon($icon, $icon_color)
->setTarget($target);
if ($is_user) {
$tail[] = $item;
} else {
$head[] = $item;
}
}
foreach (array_merge($head, $tail) as $item) {
$invitee_list->addItem($item);
}
} else {
$invitee_list = phutil_tag(
'em',
array(),
pht('None'));
}
if ($event->isImportedEvent()) {
$properties->addProperty(
pht('Imported By'),
pht(
'%s from %s',
$viewer->renderHandle($event->getImportAuthorPHID()),
$viewer->renderHandle($event->getImportSourcePHID())));
}
$properties->addProperty(
pht('Invitees'),
$invitee_list);
$properties->invokeWillRenderEvent();
return $properties;
}
private function buildRecurringHeader(PhabricatorCalendarEvent $event) {
$viewer = $this->getViewer();
if (!$event->getIsRecurring()) {
return null;
}
$header = id(new PHUIHeaderView())
->setHeader(pht('Recurring Event'));
$sequence = $event->getSequenceIndex();
if ($event->isParentEvent()) {
$parent = $event;
} else {
$parent = $event->getParentEvent();
}
if ($parent->isValidSequenceIndex($viewer, $sequence + 1)) {
$next_uri = $parent->getURI().'/'.($sequence + 1);
$has_next = true;
} else {
$next_uri = null;
$has_next = false;
}
if ($sequence) {
if ($sequence > 1) {
$previous_uri = $parent->getURI().'/'.($sequence - 1);
} else {
$previous_uri = $parent->getURI();
}
$has_previous = true;
} else {
$has_previous = false;
$previous_uri = null;
}
$prev_button = id(new PHUIButtonView())
->setTag('a')
->setIcon('fa-chevron-left')
->setHref($previous_uri)
->setDisabled(!$has_previous)
->setText(pht('Previous'));
$next_button = id(new PHUIButtonView())
->setTag('a')
->setIcon('fa-chevron-right')
->setHref($next_uri)
->setDisabled(!$has_next)
->setText(pht('Next'));
$header
->addActionLink($next_button)
->addActionLink($prev_button);
return $header;
}
private function buildRecurringSection(PhabricatorCalendarEvent $event) {
$viewer = $this->getViewer();
if (!$event->getIsRecurring()) {
return null;
}
$properties = id(new PHUIPropertyListView())
->setViewer($viewer);
$is_parent = $event->isParentEvent();
if ($is_parent) {
$parent_link = null;
} else {
$parent = $event->getParentEvent();
$parent_link = $viewer
->renderHandle($parent->getPHID())
->render();
}
$rrule = $event->newRecurrenceRule();
if ($rrule) {
$frequency = $rrule->getFrequency();
} else {
$frequency = null;
}
switch ($frequency) {
case PhutilCalendarRecurrenceRule::FREQUENCY_DAILY:
if ($is_parent) {
$message = pht('This event repeats every day.');
} else {
$message = pht(
'This event is an instance of %s, and repeats every day.',
$parent_link);
}
break;
case PhutilCalendarRecurrenceRule::FREQUENCY_WEEKLY:
if ($is_parent) {
$message = pht('This event repeats every week.');
} else {
$message = pht(
'This event is an instance of %s, and repeats every week.',
$parent_link);
}
break;
case PhutilCalendarRecurrenceRule::FREQUENCY_MONTHLY:
if ($is_parent) {
$message = pht('This event repeats every month.');
} else {
$message = pht(
'This event is an instance of %s, and repeats every month.',
$parent_link);
}
break;
case PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY:
if ($is_parent) {
$message = pht('This event repeats every year.');
} else {
$message = pht(
'This event is an instance of %s, and repeats every year.',
$parent_link);
}
break;
}
$properties->addProperty(pht('Event Series'), $message);
return $properties;
}
private function buildDescriptionView(
PhabricatorCalendarEvent $event) {
$viewer = $this->getViewer();
$properties = id(new PHUIPropertyListView())
->setViewer($viewer);
if (strlen($event->getDescription())) {
$description = new PHUIRemarkupView($viewer, $event->getDescription());
$properties->addTextContent($description);
return $properties;
}
return null;
}
private function loadEvent() {
$request = $this->getRequest();
$viewer = $this->getViewer();
$id = $request->getURIData('id');
$sequence = $request->getURIData('sequence');
// We're going to figure out which event you're trying to look at. Most of
// the time this is simple, but you may be looking at an instance of a
// recurring event which we haven't generated an object for.
// If you are, we're going to generate a "stub" event so we have a real
// ID and PHID to work with, since the rest of the infrastructure relies
// on these identifiers existing.
// Load the event identified by ID first.
$event = id(new PhabricatorCalendarEventQuery())
->setViewer($viewer)
->withIDs(array($id))
->needRSVPs(array($viewer->getPHID()))
->executeOne();
if (!$event) {
return null;
}
// If we aren't looking at an instance of this event, this is a completely
// normal request and we can just return this event.
if (!$sequence) {
return $event;
}
// When you view "E123/999", E123 is normally the parent event. However,
// you might visit a different instance first instead and then fiddle
// with the URI. If the event we're looking at is a child, we are going
// to act on the parent instead.
if ($event->isChildEvent()) {
$event = $event->getParentEvent();
}
// Try to load the instance. If it already exists, we're all done and
// can just return it.
$instance = id(new PhabricatorCalendarEventQuery())
->setViewer($viewer)
->withInstanceSequencePairs(
array(
array($event->getPHID(), $sequence),
))
->executeOne();
if ($instance) {
return $instance;
}
if (!$viewer->isLoggedIn()) {
throw new Exception(
pht(
'This event instance has not been created yet. Log in to create '.
'it.'));
}
if (!$event->isValidSequenceIndex($viewer, $sequence)) {
return null;
}
return $event->newStub($viewer, $sequence);
}
private function buildSubheaderView(PhabricatorCalendarEvent $event) {
$viewer = $this->getViewer();
$host_phid = $event->getHostPHID();
$handles = $viewer->loadHandles(array($host_phid));
$handle = $handles[$host_phid];
$host = $viewer->renderHandle($host_phid);
$host = phutil_tag('strong', array(), $host);
$image_uri = $handles[$host_phid]->getImageURI();
$image_href = $handles[$host_phid]->getURI();
$date = $event->renderEventDate($viewer, true);
$content = pht('Hosted by %s on %s.', $host, $date);
return id(new PHUIHeadThingView())
->setImage($image_uri)
->setImageHref($image_href)
->setContent($content);
}
private function buildRSVPActions(PhabricatorCalendarEvent $event) {
$viewer = $this->getViewer();
$id = $event->getID();
$is_pending = $event->isRSVPInvited($viewer->getPHID());
if (!$is_pending) {
return array();
}
$decline_button = id(new PHUIButtonView())
->setTag('a')
->setIcon('fa-times grey')
->setHref($this->getApplicationURI("/event/decline/{$id}/"))
->setWorkflow(true)
+ ->setDisabled($event->isImportedEvent())
->setText(pht('Decline'));
$accept_button = id(new PHUIButtonView())
->setTag('a')
->setIcon('fa-check green')
->setHref($this->getApplicationURI("/event/accept/{$id}/"))
->setWorkflow(true)
+ ->setDisabled($event->isImportedEvent())
->setText(pht('Accept'));
return array($decline_button, $accept_button);
}
}
diff --git a/src/applications/calendar/import/PhabricatorCalendarImportEngine.php b/src/applications/calendar/import/PhabricatorCalendarImportEngine.php
index f5964b6efd..02b72e7498 100644
--- a/src/applications/calendar/import/PhabricatorCalendarImportEngine.php
+++ b/src/applications/calendar/import/PhabricatorCalendarImportEngine.php
@@ -1,604 +1,648 @@
<?php
abstract class PhabricatorCalendarImportEngine
extends Phobject {
const QUEUE_BYTE_LIMIT = 524288;
final public function getImportEngineType() {
return $this->getPhobjectClassConstant('ENGINETYPE', 64);
}
abstract public function getImportEngineName();
abstract public function getImportEngineTypeName();
abstract public function getImportEngineHint();
public function appendImportProperties(
PhabricatorUser $viewer,
PhabricatorCalendarImport $import,
PHUIPropertyListView $properties) {
return;
}
abstract public function newEditEngineFields(
PhabricatorEditEngine $engine,
PhabricatorCalendarImport $import);
abstract public function getDisplayName(PhabricatorCalendarImport $import);
abstract public function importEventsFromSource(
PhabricatorUser $viewer,
PhabricatorCalendarImport $import,
$should_queue);
abstract public function canDisable(
PhabricatorUser $viewer,
PhabricatorCalendarImport $import);
public function explainCanDisable(
PhabricatorUser $viewer,
PhabricatorCalendarImport $import) {
throw new PhutilMethodNotImplementedException();
}
abstract public function supportsTriggers(
PhabricatorCalendarImport $import);
final public static function getAllImportEngines() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getImportEngineType')
->setSortMethod('getImportEngineName')
->execute();
}
final protected function importEventDocument(
PhabricatorUser $viewer,
PhabricatorCalendarImport $import,
?PhutilCalendarRootNode $root = null) {
$event_type = PhutilCalendarEventNode::NODETYPE;
$nodes = array();
if ($root) {
foreach ($root->getChildren() as $document) {
foreach ($document->getChildren() as $node) {
$node_type = $node->getNodeType();
if ($node_type != $event_type) {
$import->newLogMessage(
PhabricatorCalendarImportIgnoredNodeLogType::LOGTYPE,
array(
'node.type' => $node_type,
));
continue;
}
$nodes[] = $node;
}
}
}
// Reject events which have dates outside of the range of a signed
// 32-bit integer. We'll need to accommodate a wider range of events
// eventually, but have about 20 years until it's an issue and we'll
// all be dead by then.
foreach ($nodes as $key => $node) {
$dates = array();
$dates[] = $node->getStartDateTime();
$dates[] = $node->getEndDateTime();
$dates[] = $node->getCreatedDateTime();
$dates[] = $node->getModifiedDateTime();
$rrule = $node->getRecurrenceRule();
if ($rrule) {
$dates[] = $rrule->getUntil();
}
$bad_date = false;
foreach ($dates as $date) {
if ($date === null) {
continue;
}
$year = $date->getYear();
if ($year < 1970 || $year > 2037) {
$bad_date = true;
break;
}
}
if ($bad_date) {
$import->newLogMessage(
PhabricatorCalendarImportEpochLogType::LOGTYPE,
array());
unset($nodes[$key]);
}
}
// Reject events which occur too frequently. Users do not normally define
// these events and the UI and application make many assumptions which are
// incompatible with events recurring once per second.
foreach ($nodes as $key => $node) {
$rrule = $node->getRecurrenceRule();
if (!$rrule) {
// This is not a recurring event, so we don't need to check the
// frequency.
continue;
}
$scale = $rrule->getFrequencyScale();
if ($scale >= PhutilCalendarRecurrenceRule::SCALE_DAILY) {
// This is a daily, weekly, monthly, or yearly event. These are
// supported.
} else {
// This is an hourly, minutely, or secondly event.
$import->newLogMessage(
PhabricatorCalendarImportFrequencyLogType::LOGTYPE,
array(
'frequency' => $rrule->getFrequency(),
));
unset($nodes[$key]);
}
}
$node_map = array();
foreach ($nodes as $node) {
$full_uid = $this->getFullNodeUID($node);
if (isset($node_map[$full_uid])) {
$import->newLogMessage(
PhabricatorCalendarImportDuplicateLogType::LOGTYPE,
array(
'uid.full' => $full_uid,
));
continue;
}
$node_map[$full_uid] = $node;
}
// If we already know about some of these events and they were created
// here, we're not going to import it again. This can happen if a user
// exports an event and then tries to import it again. This is probably
// not what they meant to do and this pathway generally leads to madness.
$likely_phids = array();
foreach ($node_map as $full_uid => $node) {
$uid = $node->getUID();
$matches = null;
if (preg_match('/^(PHID-.*)@(.*)\z/', $uid, $matches)) {
$likely_phids[$full_uid] = $matches[1];
}
}
if ($likely_phids) {
// NOTE: We're using the omnipotent viewer here because we don't want
// to collide with events that already exist, even if you can't see
// them.
$events = id(new PhabricatorCalendarEventQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs($likely_phids)
->execute();
$events = mpull($events, null, 'getPHID');
foreach ($node_map as $full_uid => $node) {
$phid = idx($likely_phids, $full_uid);
if (!$phid) {
continue;
}
$event = idx($events, $phid);
if (!$event) {
continue;
}
$import->newLogMessage(
PhabricatorCalendarImportOriginalLogType::LOGTYPE,
array(
'phid' => $event->getPHID(),
));
unset($node_map[$full_uid]);
}
}
if ($node_map) {
$events = id(new PhabricatorCalendarEventQuery())
->setViewer($viewer)
->withImportAuthorPHIDs(array($import->getAuthorPHID()))
->withImportUIDs(array_keys($node_map))
->execute();
$events = mpull($events, null, 'getImportUID');
} else {
$events = null;
}
+ // Verified emails of the Event Uploader, to be eventually matched.
+ // Phorge loves privacy, so emails are generally private.
+ // This just covers a corner case: yourself importing yourself.
+ // NOTE: We are using the omnipotent user since we already have
+ // withUserPHIDs() limiting to a specific person (you).
+ $author_verified_emails = id(new PhabricatorPeopleUserEmailQuery())
+ ->setViewer(PhabricatorUser::getOmnipotentUser())
+ ->withUserPHIDs(array($import->getAuthorPHID()))
+ ->withIsVerified(true)
+ ->execute();
+ $author_verified_emails = mpull($author_verified_emails, 'getAddress');
+ $author_verified_emails = array_fuse($author_verified_emails);
+
$xactions = array();
$update_map = array();
$invitee_map = array();
- $attendee_map = array();
+ $attendee_name_map = array(); // map[eventUID][email from] = Attendee
+ $attendee_user_map = array(); // map[eventUID][userPHID ] = Attendee
foreach ($node_map as $full_uid => $node) {
$event = idx($events, $full_uid);
if (!$event) {
$event = PhabricatorCalendarEvent::initializeNewCalendarEvent($viewer);
}
$event
->setImportAuthorPHID($import->getAuthorPHID())
->setImportSourcePHID($import->getPHID())
->setImportUID($full_uid)
->attachImportSource($import);
$this->updateEventFromNode($viewer, $event, $node);
$xactions[$full_uid] = $this->newUpdateTransactions($event, $node);
$update_map[$full_uid] = $event;
- $attendee_map[$full_uid] = array();
+ $attendee_name_map[$full_uid] = array();
+ $attendee_user_map[$full_uid] = array();
$attendees = $node->getAttendees();
$private_index = 1;
foreach ($attendees as $attendee) {
// Generate a "name" for this attendee which is not an email address.
// We avoid disclosing email addresses to be consistent with the rest
// of the product.
$name = $attendee->getName();
if (phutil_nonempty_string($name) && preg_match('/@/', $name)) {
- $name = new PhutilEmailAddress($name);
- $name = $name->getDisplayName();
+ $attendee_mail = new PhutilEmailAddress($name);
+ $name = $attendee_mail->getDisplayName();
+ $address = $attendee_mail->getAddress();
+
+ // Skip creation of dummy "Private User" if it's me, the uploader.
+ if ($address && isset($author_verified_emails[$address])) {
+ $attendee_user_map[$full_uid][$import->getAuthorPHID()] =
+ $attendee;
+ continue;
+ }
}
// If we don't have a name or the name still looks like it's an
// email address, give them a dummy placeholder name.
if (!phutil_nonempty_string($name) || preg_match('/@/', $name)) {
$name = pht('Private User %d', $private_index);
$private_index++;
}
- $attendee_map[$full_uid][$name] = $attendee;
+ $attendee_name_map[$full_uid][$name] = $attendee;
}
}
$attendee_names = array();
- foreach ($attendee_map as $full_uid => $event_attendees) {
+ foreach ($attendee_name_map as $full_uid => $event_attendees) {
foreach ($event_attendees as $name => $attendee) {
$attendee_names[$name] = $attendee;
}
}
if ($attendee_names) {
$external_invitees = id(new PhabricatorCalendarExternalInviteeQuery())
->setViewer($viewer)
->withNames(array_keys($attendee_names))
->execute();
$external_invitees = mpull($external_invitees, null, 'getName');
foreach ($attendee_names as $name => $attendee) {
if (isset($external_invitees[$name])) {
continue;
}
$external_invitee = id(new PhabricatorCalendarExternalInvitee())
->setName($name)
->setURI($attendee->getURI())
->setSourcePHID($import->getPHID());
try {
$external_invitee->save();
} catch (AphrontDuplicateKeyQueryException $ex) {
$external_invitee =
id(new PhabricatorCalendarExternalInviteeQuery())
->setViewer($viewer)
->withNames(array($name))
->executeOne();
}
$external_invitees[$name] = $external_invitee;
}
}
// Reorder events so we create parents first. This allows us to populate
// "instanceOfEventPHID" correctly.
$insert_order = array();
foreach ($update_map as $full_uid => $event) {
$parent_uid = $this->getParentNodeUID($node_map[$full_uid]);
if ($parent_uid === null) {
$insert_order[$full_uid] = $full_uid;
continue;
}
if (empty($update_map[$parent_uid])) {
// The parent was not present in this import, which means it either
// does not exist or we're going to delete it anyway. We just drop
// this node.
$import->newLogMessage(
PhabricatorCalendarImportOrphanLogType::LOGTYPE,
array(
'uid.full' => $full_uid,
'uid.parent' => $parent_uid,
));
continue;
}
// Otherwise, we're going to insert the parent first, then insert
// the child.
$insert_order[$parent_uid] = $parent_uid;
$insert_order[$full_uid] = $full_uid;
}
// TODO: Define per-engine content sources so this can say "via Upload" or
// whatever.
$content_source = PhabricatorContentSource::newForSource(
PhabricatorWebContentSource::SOURCECONST);
// NOTE: We're using the omnipotent user here because imported events are
// otherwise immutable.
$edit_actor = PhabricatorUser::getOmnipotentUser();
$update_map = array_select_keys($update_map, $insert_order);
foreach ($update_map as $full_uid => $event) {
- $parent_uid = $this->getParentNodeUID($node_map[$full_uid]);
+ $node = $node_map[$full_uid];
+ $parent_uid = $this->getParentNodeUID($node);
if ($parent_uid) {
$parent_phid = $update_map[$parent_uid]->getPHID();
} else {
$parent_phid = null;
}
$event->setInstanceOfEventPHID($parent_phid);
$event_xactions = $xactions[$full_uid];
$editor = id(new PhabricatorCalendarEventEditor())
->setActor($edit_actor)
->setActingAsPHID($import->getPHID())
->setContentSource($content_source)
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true);
$is_new = !$event->getID();
$editor->applyTransactions($event, $event_xactions);
// We're just forcing attendees to the correct values here because
// transactions intentionally don't let you RSVP for other users. This
// might need to be turned into a special type of transaction eventually.
- $attendees = $attendee_map[$full_uid];
+ $attendees_name = $attendee_name_map[$full_uid];
+ $attendees_user = $attendee_user_map[$full_uid];
$old_map = $event->getInvitees();
$old_map = mpull($old_map, null, 'getInviteePHID');
+ $phid_invitees = array();
+ foreach ($attendees_name as $name => $attendee) {
+ $attendee_phid = $external_invitees[$name]->getPHID();
+ $phid_invitees[$attendee_phid] = $attendee;
+ }
+ foreach ($attendees_user as $phid_user_attendee => $attendee) {
+ $phid_invitees[$phid_user_attendee] = $attendee;
+ }
+
$new_map = array();
- foreach ($attendees as $name => $attendee) {
- $phid = $external_invitees[$name]->getPHID();
+ foreach ($phid_invitees as $phid_invitee => $attendee) {
- $invitee = idx($old_map, $phid);
+ $invitee = idx($old_map, $phid_invitee);
if (!$invitee) {
$invitee = id(new PhabricatorCalendarEventInvitee())
->setEventPHID($event->getPHID())
- ->setInviteePHID($phid)
+ ->setInviteePHID($phid_invitee)
->setInviterPHID($import->getPHID());
}
switch ($attendee->getStatus()) {
case PhutilCalendarUserNode::STATUS_ACCEPTED:
$status = PhabricatorCalendarEventInvitee::STATUS_ATTENDING;
break;
case PhutilCalendarUserNode::STATUS_DECLINED:
$status = PhabricatorCalendarEventInvitee::STATUS_DECLINED;
break;
case PhutilCalendarUserNode::STATUS_INVITED:
default:
- $status = PhabricatorCalendarEventInvitee::STATUS_INVITED;
+ // Is me importing myself? I'm coming!
+ if ($phid_invitee === $import->getAuthorPHID()) {
+ $status = PhabricatorCalendarEventInvitee::STATUS_ATTENDING;
+ } else {
+ $status = PhabricatorCalendarEventInvitee::STATUS_INVITED;
+ }
break;
}
$invitee->setStatus($status);
+ // Import "busy/available", very useful for myself to tell this
+ // to coworkers. This is probably somehow very un-useful for most
+ // "Private user(s)", but let's add it for them too since it
+ // doesn't hurt them.
+ $invitee->importAvailabilityFromTimeTransparency(
+ $node->getTimeTransparency());
$invitee->save();
- $new_map[$phid] = $invitee;
+ $new_map[$phid_invitee] = $invitee;
}
-
+ // Remove old Invitees if they are not invited anymore.
foreach ($old_map as $phid => $invitee) {
if (empty($new_map[$phid])) {
$invitee->delete();
}
}
$event->attachInvitees($new_map);
$import->newLogMessage(
PhabricatorCalendarImportUpdateLogType::LOGTYPE,
array(
'new' => $is_new,
'phid' => $event->getPHID(),
));
}
if (!$update_map) {
$import->newLogMessage(
PhabricatorCalendarImportEmptyLogType::LOGTYPE,
array());
}
// Delete any events which are no longer present in the source.
$updated_events = mpull($update_map, null, 'getPHID');
$source_events = id(new PhabricatorCalendarEventQuery())
->setViewer($viewer)
->withImportSourcePHIDs(array($import->getPHID()))
->execute();
$engine = new PhabricatorDestructionEngine();
foreach ($source_events as $source_event) {
if (isset($updated_events[$source_event->getPHID()])) {
// We imported and updated this event, so keep it around.
continue;
}
$import->newLogMessage(
PhabricatorCalendarImportDeleteLogType::LOGTYPE,
array(
'name' => $source_event->getName(),
));
$engine->destroyObject($source_event);
}
}
private function getFullNodeUID(PhutilCalendarEventNode $node) {
$uid = $node->getUID();
$instance_epoch = $this->getNodeInstanceEpoch($node);
$full_uid = $uid.'/'.$instance_epoch;
return $full_uid;
}
private function getParentNodeUID(PhutilCalendarEventNode $node) {
$recurrence_id = $node->getRecurrenceID();
if (!phutil_nonempty_string($recurrence_id)) {
return null;
}
return $node->getUID().'/';
}
private function getNodeInstanceEpoch(PhutilCalendarEventNode $node) {
$instance_iso = $node->getRecurrenceID();
if (phutil_nonempty_string($instance_iso)) {
$instance_datetime = PhutilCalendarAbsoluteDateTime::newFromISO8601(
$instance_iso);
$instance_epoch = $instance_datetime->getEpoch();
} else {
$instance_epoch = null;
}
return $instance_epoch;
}
private function newUpdateTransactions(
PhabricatorCalendarEvent $event,
PhutilCalendarEventNode $node) {
$xactions = array();
$uid = $node->getUID();
if (!$event->getID()) {
$xactions[] = id(new PhabricatorCalendarEventTransaction())
->setTransactionType(PhabricatorTransactions::TYPE_CREATE)
->setNewValue(true);
}
$name = $node->getName();
if (!strlen($name)) {
if (strlen($uid)) {
$name = pht('Unnamed Event "%s"', $uid);
} else {
$name = pht('Unnamed Imported Event');
}
}
$xactions[] = id(new PhabricatorCalendarEventTransaction())
->setTransactionType(
PhabricatorCalendarEventNameTransaction::TRANSACTIONTYPE)
->setNewValue($name);
$description = $node->getDescription();
$xactions[] = id(new PhabricatorCalendarEventTransaction())
->setTransactionType(
PhabricatorCalendarEventDescriptionTransaction::TRANSACTIONTYPE)
->setNewValue((string)$description);
$is_recurring = (bool)$node->getRecurrenceRule();
$xactions[] = id(new PhabricatorCalendarEventTransaction())
->setTransactionType(
PhabricatorCalendarEventRecurringTransaction::TRANSACTIONTYPE)
->setNewValue($is_recurring);
return $xactions;
}
private function updateEventFromNode(
PhabricatorUser $actor,
PhabricatorCalendarEvent $event,
PhutilCalendarEventNode $node) {
$instance_epoch = $this->getNodeInstanceEpoch($node);
$event->setUTCInstanceEpoch($instance_epoch);
$timezone = $actor->getTimezoneIdentifier();
// TODO: These should be transactional, but the transaction only accepts
// epoch timestamps right now.
$start_datetime = $node->getStartDateTime()
->setViewerTimezone($timezone);
$end_datetime = $node->getEndDateTime()
->setViewerTimezone($timezone);
$event
->setStartDateTime($start_datetime)
->setEndDateTime($end_datetime);
$event->setIsAllDay((int)$start_datetime->getIsAllDay());
// TODO: This should be transactional, but the transaction only accepts
// simple frequency rules right now.
$rrule = $node->getRecurrenceRule();
if ($rrule) {
$event->setRecurrenceRule($rrule);
$until_datetime = $rrule->getUntil();
if ($until_datetime) {
$until_datetime->setViewerTimezone($timezone);
$event->setUntilDateTime($until_datetime);
}
$count = $rrule->getCount();
$event->setParameter('recurrenceCount', $count);
}
return $event;
}
public function canDeleteAnyEvents(
PhabricatorUser $viewer,
PhabricatorCalendarImport $import) {
$table = new PhabricatorCalendarEvent();
$conn = $table->establishConnection('r');
// Using a CalendarEventQuery here was failing oddly in a way that was
// difficult to reproduce locally (see T11808). Just check the table
// directly; this is significantly more efficient anyway.
$any_event = queryfx_all(
$conn,
'SELECT phid FROM %T WHERE importSourcePHID = %s LIMIT 1',
$table->getTableName(),
$import->getPHID());
return (bool)$any_event;
}
final protected function shouldQueueDataImport($data) {
return (strlen($data) > self::QUEUE_BYTE_LIMIT);
}
final protected function queueDataImport(
PhabricatorCalendarImport $import,
$data) {
$import->newLogMessage(
PhabricatorCalendarImportQueueLogType::LOGTYPE,
array(
'data.size' => strlen($data),
'data.limit' => self::QUEUE_BYTE_LIMIT,
));
// When we queue on this pathway, we're queueing in response to an explicit
// user action (like uploading a big `.ics` file), so we queue at normal
// priority instead of bulk/import priority.
PhabricatorWorker::scheduleTask(
'PhabricatorCalendarImportReloadWorker',
array(
'importPHID' => $import->getPHID(),
'via' => PhabricatorCalendarImportReloadWorker::VIA_BACKGROUND,
),
array(
'objectPHID' => $import->getPHID(),
));
}
}
diff --git a/src/applications/calendar/import/__tests__/CalendarImportTestCase.php b/src/applications/calendar/import/__tests__/CalendarImportTestCase.php
index 133e4c557d..e620384b0e 100644
--- a/src/applications/calendar/import/__tests__/CalendarImportTestCase.php
+++ b/src/applications/calendar/import/__tests__/CalendarImportTestCase.php
@@ -1,231 +1,231 @@
<?php
final class CalendarImportTestCase extends PhabricatorTestCase {
protected function getPhabricatorTestCaseConfiguration() {
return array(
self::PHABRICATOR_TESTCONFIG_BUILD_STORAGE_FIXTURES => true,
);
}
// Indexes of the "expectedInviteesTests" test.
const INVITED_USER = 0;
const INVITED_EXPECTED = 1;
const INVITED_RESULT = 2;
public function testIcsFileImportWithGuestThatIsHost() {
$alice_unverified =
$this->generateTestUserWithVerifiedMail(
'alice@example.com',
0);
$lincoln_verified =
$this->generateTestUserWithVerifiedMail(
'a.lincoln@example.com',
1);
$alien_unverified =
$this->generateTestUserWithVerifiedMail(
'alien.unferified@example.com',
0);
$alien_verified =
$this->generateTestUserWithVerifiedMail(
'alien.verified@example.com',
1);
// Tests are event-based. Each event has their expected invitees.
$tests = array(
// Test zero. Alice imports an event with A.Lincoln.
array(
'test' => pht('alice invites a.lincoln via verified email'),
'file' => 'simple-event-alincoln-guest.ics',
'fileAuthor' => $alice_unverified,
'expectedInvitees' => 3,
'expectedInviteesTests' => array(
// Documentation:
// Array 0 (INVITED_USER): User object
// Array 1 (INVITED_EXPECTED): Presence (bool)
array($lincoln_verified, false),
array($alice_unverified, false),
array($alien_unverified, false),
array($alien_verified, false),
),
),
// Test one. A.Lincoln imports an event with A.Lincoln.
array(
'test' => pht('a.lincoln self-invite via verified email'),
'file' => 'simple-event-alincoln-guest.ics',
'fileAuthor' => $lincoln_verified,
'expectedInvitees' => 3,
'expectedInviteesTests' => array(
-// array($lincoln_verified, true), // Self-invitation. T15564
+ array($lincoln_verified, true), // Self-invitation. T15564
array($alice_unverified, false),
array($alien_unverified, false),
array($alien_verified, false),
),
),
);
foreach ($tests as $test) {
$this->runIcsFileImportTestWithExpectedResults(
$test['test'],
$test['file'],
$test['fileAuthor'],
$test['expectedInvitees'],
$test['expectedInviteesTests']);
}
}
private function runIcsFileImportTestWithExpectedResults(
$test, $file, $importer_author, $expecteds, $invitees_tests) {
$ics_path = __DIR__.'/events/'.$file;
// Prepare a calendar import.
$import_type = new PhabricatorCalendarICSFileImportEngine();
$calendar_import = PhabricatorCalendarImport::initializeNewCalendarImport(
$importer_author,
clone $import_type);
// Create the File containing the ICS example.
$file_data = Filesystem::readFile($ics_path);
$file_test_engine = new PhabricatorTestStorageEngine();
$file_params = array(
'name' => $file,
'viewPolicy' => PhabricatorPolicies::POLICY_USER,
'authorPHID' => $importer_author->getPHID(),
'storageEngines' => array($file_test_engine),
);
$file_up = PhabricatorFile::newFromFileData($file_data, $file_params);
// Create a calendar import with our ICS file.
$import_xactions = array();
$import_xactions[] = id(new PhabricatorCalendarImportTransaction())
->setTransactionType(
PhabricatorCalendarImportICSFileTransaction::TRANSACTIONTYPE)
->setNewValue($file_up->getPHID());
// Persist the calendar import and get it.
id(new PhabricatorCalendarImportEditor())
->setActor($importer_author)
->setContentSource($this->newContentSource())
->applyTransactions($calendar_import, $import_xactions);
$import_type->importEventsFromSource(
$importer_author,
$calendar_import,
false);
// Find imported events from the perspective of the importer author itself.
// So we check if we gained some extra people email visibility by mistake.
// The backend does not support attachInvitees() and it's done by default.
$events = (new PhabricatorCalendarEventQuery())
->setViewer($importer_author)
->withImportSourcePHIDs(array($calendar_import->getPHID()))
->execute();
// At the moment test cases are hardcoded with one event.
$this->assertEqual(
1,
count($events),
pht('Unexpected events in file "%s" on test "%s"',
$file,
$test));
// Take the first event.
$event = head($events);
// How many people we invited in this event.
$this->assertEqual(
$expecteds,
count($event->getInvitees()),
pht('Unexpected invitees in file "%s" on test "%s"', $file, $test));
foreach ($invitees_tests as $invitees_test) {
$this->assertMatchingInvitees($test, $file, $event, $invitees_tests);
}
$event->delete();
}
/**
* Check if the invitees matches.
*/
private function assertMatchingInvitees($test, $file, $event, $expecteds) {
// Index what we expect, by user PHID.
$expected_users_by_phid = [];
foreach ($expecteds as $expected_invited_data) {
$user_phid = $expected_invited_data[self::INVITED_USER]
->getPHID();
$expected_users_by_phid[$user_phid]
= $expected_invited_data;
}
// Get current invitees (mixed between "PHID-CXNV" and "PHID-USER").
$actuals_phids_mixed = mpull($event->getInvitees(), 'getInviteePHID');
// Get just actual users.
$actual_users = (new PhabricatorUser())->loadAllWhere(
'phid IN (%Ls)',
$actuals_phids_mixed);
$actual_users = mpull($actual_users, null, 'getPHID');
// Map actual users with the expected ones.
foreach ($actual_users as $actual_user) {
$user_phid = $actual_user->getPHID();
$found = isset($expected_users_by_phid[$user_phid]);
if (!$found) {
$expected_users_by_phid[$user_phid] = array();
}
$expected_users_by_phid[$user_phid][self::INVITED_RESULT]
= $found;
}
// In the future it may be useful to also check external users
// by their email. In case, start from here!
// but note that the 'getURI()' returns 'mailto:' stuff.
// $actual_externals = (new PhabricatorCalendarExternalInvitee())
// ->loadAllWhere(
// 'phid IN (%Ls)',
// $actuals_phids_mixed);
// $actual_externals = mpull($actual_externals, null, 'getURI');
// Check results (matched or not).
foreach ($expected_users_by_phid as $phid => $expecteds_data) {
$expected = idx($expecteds_data, self::INVITED_EXPECTED, null);
$result = idx($expecteds_data, self::INVITED_RESULT, false);
$user = idx($expecteds_data, self::INVITED_USER, null);
if ($expected !== null) {
$this->assertEqual(
$expected,
$result,
pht('Unexpected presence of user "%s" in file "%s" on test "%s"',
$user == null ? '(unknown)' : $user->loadPrimaryEmailAddress(),
$file,
$test));
}
}
}
/**
* Generate a test user with a specific verified (or not) email.
* @param string $mail Email address
* @param int $is_verified 0: unverified, 1: verified
* @return PhabricatorUser
*/
private function generateTestUserWithVerifiedMail($mail, $is_verified) {
$user = $this->generateNewTestUser();
// Set our primary address as verified or not.
$email = id(new PhabricatorUserEmail())->loadOneWhere(
'userPHID = %s',
$user->getPHID());
$email->setAddress($mail);
$email->setIsVerified($is_verified);
$email->setIsPrimary(true);
$email->save();
return $user;
}
}
diff --git a/src/applications/calendar/parser/data/PhutilCalendarEventNode.php b/src/applications/calendar/parser/data/PhutilCalendarEventNode.php
index d3d33aa77e..95042539e3 100644
--- a/src/applications/calendar/parser/data/PhutilCalendarEventNode.php
+++ b/src/applications/calendar/parser/data/PhutilCalendarEventNode.php
@@ -1,172 +1,191 @@
<?php
final class PhutilCalendarEventNode
extends PhutilCalendarContainerNode {
const NODETYPE = 'event';
private $uid;
private $name;
private $description;
private $startDateTime;
private $endDateTime;
private $duration;
private $createdDateTime;
private $modifiedDateTime;
private $organizer;
private $attendees = array();
+ private $timeTransparency;
private $recurrenceRule;
private $recurrenceExceptions = array();
private $recurrenceDates = array();
private $recurrenceID;
public function setUID($uid) {
$this->uid = $uid;
return $this;
}
public function getUID() {
return $this->uid;
}
public function setName($name) {
$this->name = $name;
return $this;
}
public function getName() {
return $this->name;
}
public function setDescription($description) {
$this->description = $description;
return $this;
}
public function getDescription() {
return $this->description;
}
public function setStartDateTime(PhutilCalendarDateTime $start) {
$this->startDateTime = $start;
return $this;
}
public function getStartDateTime() {
return $this->startDateTime;
}
public function setEndDateTime(PhutilCalendarDateTime $end) {
$this->endDateTime = $end;
return $this;
}
public function getEndDateTime() {
$end = $this->endDateTime;
if ($end) {
return $end;
}
$start = $this->getStartDateTime();
$duration = $this->getDuration();
if ($start && $duration) {
return id(new PhutilCalendarRelativeDateTime())
->setOrigin($start)
->setDuration($duration);
}
// If no end date or duration are specified, the event is instantaneous.
return $start;
}
public function setDuration(PhutilCalendarDuration $duration) {
$this->duration = $duration;
return $this;
}
public function getDuration() {
return $this->duration;
}
public function setCreatedDateTime(PhutilCalendarDateTime $created) {
$this->createdDateTime = $created;
return $this;
}
public function getCreatedDateTime() {
return $this->createdDateTime;
}
public function setModifiedDateTime(PhutilCalendarDateTime $modified) {
$this->modifiedDateTime = $modified;
return $this;
}
public function getModifiedDateTime() {
return $this->modifiedDateTime;
}
public function setOrganizer(PhutilCalendarUserNode $organizer) {
$this->organizer = $organizer;
return $this;
}
public function getOrganizer() {
return $this->organizer;
}
public function setAttendees(array $attendees) {
assert_instances_of($attendees, 'PhutilCalendarUserNode');
$this->attendees = $attendees;
return $this;
}
public function getAttendees() {
return $this->attendees;
}
public function addAttendee(PhutilCalendarUserNode $attendee) {
$this->attendees[] = $attendee;
return $this;
}
+ /**
+ * Get the "time transparency" as described by RFC 5545 3.8.2.7.
+ * @return string|null
+ */
+ public function getTimeTransparency() {
+ return $this->timeTransparency;
+ }
+
+ /**
+ * Set the "time transparency" as described by RFC 5545 3.8.2.7.
+ * @param string|null $time_transparency
+ * @return self
+ */
+ public function setTimeTransparency($time_transparency) {
+ $this->timeTransparency = $time_transparency;
+ return $this;
+ }
+
public function setRecurrenceRule(
PhutilCalendarRecurrenceRule $recurrence_rule) {
$this->recurrenceRule = $recurrence_rule;
return $this;
}
public function getRecurrenceRule() {
return $this->recurrenceRule;
}
public function setRecurrenceExceptions(array $recurrence_exceptions) {
assert_instances_of($recurrence_exceptions, 'PhutilCalendarDateTime');
$this->recurrenceExceptions = $recurrence_exceptions;
return $this;
}
public function getRecurrenceExceptions() {
return $this->recurrenceExceptions;
}
public function setRecurrenceDates(array $recurrence_dates) {
assert_instances_of($recurrence_dates, 'PhutilCalendarDateTime');
$this->recurrenceDates = $recurrence_dates;
return $this;
}
public function getRecurrenceDates() {
return $this->recurrenceDates;
}
public function setRecurrenceID($recurrence_id) {
$this->recurrenceID = $recurrence_id;
return $this;
}
public function getRecurrenceID() {
return $this->recurrenceID;
}
}
diff --git a/src/applications/calendar/parser/ics/PhutilICSParser.php b/src/applications/calendar/parser/ics/PhutilICSParser.php
index 1cd7261e12..0193f6c5a2 100644
--- a/src/applications/calendar/parser/ics/PhutilICSParser.php
+++ b/src/applications/calendar/parser/ics/PhutilICSParser.php
@@ -1,919 +1,923 @@
<?php
final class PhutilICSParser extends Phobject {
private $stack;
private $node;
private $document;
private $lines;
private $cursor;
private $warnings;
const PARSE_MISSING_END = 'missing-end';
const PARSE_INITIAL_UNFOLD = 'initial-unfold';
const PARSE_UNEXPECTED_CHILD = 'unexpected-child';
const PARSE_EXTRA_END = 'extra-end';
const PARSE_MISMATCHED_SECTIONS = 'mismatched-sections';
const PARSE_ROOT_PROPERTY = 'root-property';
const PARSE_BAD_BASE64 = 'bad-base64';
const PARSE_BAD_BOOLEAN = 'bad-boolean';
const PARSE_UNEXPECTED_TEXT = 'unexpected-text';
const PARSE_MALFORMED_DOUBLE_QUOTE = 'malformed-double-quote';
const PARSE_MALFORMED_PARAMETER_NAME = 'malformed-parameter';
const PARSE_MALFORMED_PROPERTY = 'malformed-property';
const PARSE_MISSING_VALUE = 'missing-value';
const PARSE_UNESCAPED_BACKSLASH = 'unescaped-backslash';
const PARSE_MULTIPLE_PARAMETERS = 'multiple-parameters';
const PARSE_EMPTY_DATETIME = 'empty-datetime';
const PARSE_MANY_DATETIME = 'many-datetime';
const PARSE_BAD_DATETIME = 'bad-datetime';
const PARSE_EMPTY_DURATION = 'empty-duration';
const PARSE_MANY_DURATION = 'many-duration';
const PARSE_BAD_DURATION = 'bad-duration';
const WARN_TZID_UTC = 'warn-tzid-utc';
const WARN_TZID_GUESS = 'warn-tzid-guess';
const WARN_TZID_IGNORED = 'warn-tzid-ignored';
public function parseICSData($data) {
$this->stack = array();
$this->node = null;
$this->cursor = null;
$this->warnings = array();
$lines = $this->unfoldICSLines($data);
$this->lines = $lines;
$root = $this->newICSNode('<ROOT>');
$this->stack[] = $root;
$this->node = $root;
foreach ($lines as $key => $line) {
$this->cursor = $key;
$matches = null;
if (preg_match('(^BEGIN:(.*)\z)', $line, $matches)) {
$this->beginParsingNode($matches[1]);
} else if (preg_match('(^END:(.*)\z)', $line, $matches)) {
$this->endParsingNode($matches[1]);
} else {
if (count($this->stack) < 2) {
$this->raiseParseFailure(
self::PARSE_ROOT_PROPERTY,
pht(
'Found unexpected property at ICS document root.'));
}
$this->parseICSProperty($line);
}
}
if (count($this->stack) > 1) {
$this->raiseParseFailure(
self::PARSE_MISSING_END,
pht(
'Expected all "BEGIN:" sections in ICS document to have '.
'corresponding "END:" sections.'));
}
$this->node = null;
$this->lines = null;
$this->cursor = null;
return $root;
}
private function getNode() {
return $this->node;
}
private function unfoldICSLines($data) {
$lines = phutil_split_lines($data, $retain_endings = false);
$this->lines = $lines;
// ICS files are wrapped at 75 characters, with overlong lines continued
// on the following line with an initial space or tab. Unwrap all of the
// lines in the file.
// This unwrapping is specifically byte-oriented, not character oriented,
// and RFC5545 anticipates that simple implementations may even split UTF8
// characters in the middle.
$last = null;
foreach ($lines as $idx => $line) {
$this->cursor = $idx;
if (!preg_match('/^[ \t]/', $line)) {
$last = $idx;
continue;
}
if ($last === null) {
$this->raiseParseFailure(
self::PARSE_INITIAL_UNFOLD,
pht(
'First line of ICS file begins with a space or tab, but this '.
'marks a line which should be unfolded.'));
}
$lines[$last] = $lines[$last].substr($line, 1);
unset($lines[$idx]);
}
return $lines;
}
private function beginParsingNode($type) {
$node = $this->getNode();
$new_node = $this->newICSNode($type);
if ($node instanceof PhutilCalendarContainerNode) {
$node->appendChild($new_node);
} else {
$this->raiseParseFailure(
self::PARSE_UNEXPECTED_CHILD,
pht(
'Found unexpected node "%s" inside node "%s".',
$new_node->getAttribute('ics.type'),
$node->getAttribute('ics.type')));
}
$this->stack[] = $new_node;
$this->node = $new_node;
return $this;
}
private function newICSNode($type) {
switch ($type) {
case '<ROOT>':
$node = new PhutilCalendarRootNode();
break;
case 'VCALENDAR':
$node = new PhutilCalendarDocumentNode();
break;
case 'VEVENT':
$node = new PhutilCalendarEventNode();
break;
default:
$node = new PhutilCalendarRawNode();
break;
}
$node->setAttribute('ics.type', $type);
return $node;
}
private function endParsingNode($type) {
$node = $this->getNode();
if ($node instanceof PhutilCalendarRootNode) {
$this->raiseParseFailure(
self::PARSE_EXTRA_END,
pht(
'Found unexpected "END" without a "BEGIN".'));
}
$old_type = $node->getAttribute('ics.type');
if ($old_type != $type) {
$this->raiseParseFailure(
self::PARSE_MISMATCHED_SECTIONS,
pht(
'Found mismatched "BEGIN" ("%s") and "END" ("%s") sections.',
$old_type,
$type));
}
array_pop($this->stack);
$this->node = last($this->stack);
return $this;
}
private function parseICSProperty($line) {
$matches = null;
// Properties begin with an alphanumeric name with no escaping, followed
// by either a ";" (to begin a list of parameters) or a ":" (to begin
// the actual field body).
$ok = preg_match('(^([A-Za-z0-9-]+)([;:])(.*)\z)', $line, $matches);
if (!$ok) {
$this->raiseParseFailure(
self::PARSE_MALFORMED_PROPERTY,
pht(
'Found malformed property in ICS document.'));
}
$name = $matches[1];
$body = $matches[3];
$has_parameters = ($matches[2] == ';');
$parameters = array();
if ($has_parameters) {
// Parameters are a sensible name, a literal "=", a pile of magic,
// and then maybe a comma and another parameter.
while (true) {
// We're going to get the first couple of parts first.
$ok = preg_match('(^([^=]+)=)', $body, $matches);
if (!$ok) {
$this->raiseParseFailure(
self::PARSE_MALFORMED_PARAMETER_NAME,
pht(
'Found malformed property in ICS document: %s',
$body));
}
$param_name = $matches[1];
$body = substr($body, strlen($matches[0]));
// Now we're going to match zero or more values.
$param_values = array();
while (true) {
// The value can either be a double-quoted string or an unquoted
// string, with some characters forbidden.
if (strlen($body) && $body[0] == '"') {
$is_quoted = true;
$ok = preg_match(
'(^"([^\x00-\x08\x10-\x19"]*)")',
$body,
$matches);
if (!$ok) {
$this->raiseParseFailure(
self::PARSE_MALFORMED_DOUBLE_QUOTE,
pht(
'Found malformed double-quoted string in ICS document '.
'parameter value.'));
}
} else {
$is_quoted = false;
// It's impossible for this not to match since it can match
// nothing, and it's valid for it to match nothing.
preg_match('(^([^\x00-\x08\x10-\x19";:,]*))', $body, $matches);
}
// NOTE: RFC5545 says "Property parameter values that are not in
// quoted-strings are case-insensitive." -- that is, the quoted and
// unquoted representations are not equivalent. Thus, preserve the
// original formatting in case we ever need to respect this.
$param_values[] = array(
'value' => $this->unescapeParameterValue($matches[1]),
'quoted' => $is_quoted,
);
$body = substr($body, strlen($matches[0]));
if (!strlen($body)) {
$this->raiseParseFailure(
self::PARSE_MISSING_VALUE,
pht(
'Expected ":" after parameters in ICS document property.'));
}
// If we have a comma now, we're going to read another value. Strip
// it off and keep going.
if ($body[0] == ',') {
$body = substr($body, 1);
continue;
}
// If we have a semicolon, we're going to read another parameter.
if ($body[0] == ';') {
break;
}
// If we have a colon, this is the last value and also the last
// property. Break, then handle the colon below.
if ($body[0] == ':') {
break;
}
$short_body = id(new PhutilUTF8StringTruncator())
->setMaximumGlyphs(32)
->truncateString($body);
// We aren't expecting anything else.
$this->raiseParseFailure(
self::PARSE_UNEXPECTED_TEXT,
pht(
'Found unexpected text ("%s") after reading parameter value.',
$short_body));
}
$parameters[] = array(
'name' => $param_name,
'values' => $param_values,
);
if ($body[0] == ';') {
$body = substr($body, 1);
continue;
}
if ($body[0] == ':') {
$body = substr($body, 1);
break;
}
}
}
$value = $this->unescapeFieldValue($name, $parameters, $body);
$node = $this->getNode();
$raw = $node->getAttribute('ics.properties', array());
$raw[] = array(
'name' => $name,
'parameters' => $parameters,
'value' => $value,
);
$node->setAttribute('ics.properties', $raw);
switch ($node->getAttribute('ics.type')) {
case 'VEVENT':
$this->didParseEventProperty($node, $name, $parameters, $value);
break;
}
}
private function unescapeParameterValue($data) {
// The parameter grammar is adjusted by RFC6868 to permit escaping with
// carets. Remove that escaping.
// This escaping is a bit weird because it's trying to be backwards
// compatible and the original spec didn't think about this and didn't
// provide much room to fix things.
$out = '';
$esc = false;
foreach (phutil_utf8v($data) as $c) {
if (!$esc) {
if ($c != '^') {
$out .= $c;
} else {
$esc = true;
}
} else {
switch ($c) {
case 'n':
$out .= "\n";
break;
case '^':
$out .= '^';
break;
case "'":
// NOTE: This is "<caret> <single quote>" being decoded into a
// double quote!
$out .= '"';
break;
default:
// NOTE: The caret is NOT an escape for any other characters.
// This is a "MUST" requirement of RFC6868.
$out .= '^'.$c;
break;
}
}
}
// NOTE: Because caret on its own just means "caret" for backward
// compatibility, we don't warn if we're still in escaped mode once we
// reach the end of the string.
return $out;
}
private function unescapeFieldValue($name, array $parameters, $data) {
// NOTE: The encoding of the field value data is dependent on the field
// name (which defines a default encoding) and the parameters (which may
// include "VALUE", specifying a type of the data.
$default_types = array(
'CALSCALE' => 'TEXT',
'METHOD' => 'TEXT',
'PRODID' => 'TEXT',
'VERSION' => 'TEXT',
'ATTACH' => 'URI',
'CATEGORIES' => 'TEXT',
'CLASS' => 'TEXT',
'COMMENT' => 'TEXT',
'DESCRIPTION' => 'TEXT',
// TODO: The spec appears to contradict itself: it says that the value
// type is FLOAT, but it also says that this property value is actually
// two semicolon-separated values, which is not what FLOAT is defined as.
'GEO' => 'TEXT',
'LOCATION' => 'TEXT',
'PERCENT-COMPLETE' => 'INTEGER',
'PRIORITY' => 'INTEGER',
'RESOURCES' => 'TEXT',
'STATUS' => 'TEXT',
'SUMMARY' => 'TEXT',
'COMPLETED' => 'DATE-TIME',
'DTEND' => 'DATE-TIME',
'DUE' => 'DATE-TIME',
'DTSTART' => 'DATE-TIME',
'DURATION' => 'DURATION',
'FREEBUSY' => 'PERIOD',
'TRANSP' => 'TEXT',
'TZID' => 'TEXT',
'TZNAME' => 'TEXT',
'TZOFFSETFROM' => 'UTC-OFFSET',
'TZOFFSETTO' => 'UTC-OFFSET',
'TZURL' => 'URI',
'ATTENDEE' => 'CAL-ADDRESS',
'CONTACT' => 'TEXT',
'ORGANIZER' => 'CAL-ADDRESS',
'RECURRENCE-ID' => 'DATE-TIME',
'RELATED-TO' => 'TEXT',
'URL' => 'URI',
'UID' => 'TEXT',
'EXDATE' => 'DATE-TIME',
'RDATE' => 'DATE-TIME',
'RRULE' => 'RECUR',
'ACTION' => 'TEXT',
'REPEAT' => 'INTEGER',
'TRIGGER' => 'DURATION',
'CREATED' => 'DATE-TIME',
'DTSTAMP' => 'DATE-TIME',
'LAST-MODIFIED' => 'DATE-TIME',
'SEQUENCE' => 'INTEGER',
'REQUEST-STATUS' => 'TEXT',
);
$value_type = idx($default_types, $name, 'TEXT');
foreach ($parameters as $parameter) {
if ($parameter['name'] == 'VALUE') {
$value_type = idx(head($parameter['values']), 'value');
}
}
switch ($value_type) {
case 'BINARY':
$result = base64_decode($data, true);
if ($result === false) {
$this->raiseParseFailure(
self::PARSE_BAD_BASE64,
pht(
'Unable to decode base64 data: %s',
$data));
}
break;
case 'BOOLEAN':
$map = array(
'true' => true,
'false' => false,
);
$result = phutil_utf8_strtolower($data);
if (!isset($map[$result])) {
$this->raiseParseFailure(
self::PARSE_BAD_BOOLEAN,
pht(
'Unexpected BOOLEAN value "%s".',
$data));
}
$result = $map[$result];
break;
case 'CAL-ADDRESS':
$result = $data;
break;
case 'DATE':
// This is a comma-separated list of "YYYYMMDD" values.
$result = explode(',', $data);
break;
case 'DATE-TIME':
if (!strlen($data)) {
$result = array();
} else {
$result = explode(',', $data);
}
break;
case 'DURATION':
if (!strlen($data)) {
$result = array();
} else {
$result = explode(',', $data);
}
break;
case 'FLOAT':
$result = explode(',', $data);
foreach ($result as $k => $v) {
$result[$k] = (float)$v;
}
break;
case 'INTEGER':
$result = explode(',', $data);
foreach ($result as $k => $v) {
$result[$k] = (int)$v;
}
break;
case 'PERIOD':
$result = explode(',', $data);
break;
case 'RECUR':
$result = $data;
break;
case 'TEXT':
$result = $this->unescapeTextValue($data);
break;
case 'TIME':
$result = explode(',', $data);
break;
case 'URI':
$result = $data;
break;
case 'UTC-OFFSET':
$result = $data;
break;
default:
// RFC5545 says we MUST preserve the data for any types we don't
// recognize.
$result = $data;
break;
}
return array(
'type' => $value_type,
'value' => $result,
'raw' => $data,
);
}
private function unescapeTextValue($data) {
$result = array();
$buf = '';
$esc = false;
foreach (phutil_utf8v($data) as $c) {
if (!$esc) {
if ($c == '\\') {
$esc = true;
} else if ($c == ',') {
$result[] = $buf;
$buf = '';
} else {
$buf .= $c;
}
} else {
switch ($c) {
case 'n':
case 'N':
$buf .= "\n";
break;
default:
$buf .= $c;
break;
}
$esc = false;
}
}
if ($esc) {
$this->raiseParseFailure(
self::PARSE_UNESCAPED_BACKSLASH,
pht(
'ICS document contains TEXT value ending with unescaped '.
'backslash.'));
}
$result[] = $buf;
return $result;
}
private function raiseParseFailure($code, $message) {
if ($this->lines && isset($this->lines[$this->cursor])) {
$message = pht(
"ICS Parse Error near line %s:\n\n>>> %s\n\n%s",
$this->cursor + 1,
$this->lines[$this->cursor],
$message);
} else {
$message = pht(
'ICS Parse Error: %s',
$message);
}
throw id(new PhutilICSParserException($message))
->setParserFailureCode($code);
}
private function raiseWarning($code, $message) {
$this->warnings[] = array(
'code' => $code,
'line' => $this->cursor,
'text' => $this->lines[$this->cursor],
'message' => $message,
);
return $this;
}
public function getWarnings() {
return $this->warnings;
}
private function didParseEventProperty(
PhutilCalendarEventNode $node,
$name,
array $parameters,
array $value) {
switch ($name) {
case 'UID':
$text = $this->newTextFromProperty($parameters, $value);
$node->setUID($text);
break;
case 'CREATED':
$datetime = $this->newDateTimeFromProperty($parameters, $value);
$node->setCreatedDateTime($datetime);
break;
case 'DTSTAMP':
$datetime = $this->newDateTimeFromProperty($parameters, $value);
$node->setModifiedDateTime($datetime);
break;
case 'SUMMARY':
$text = $this->newTextFromProperty($parameters, $value);
$node->setName($text);
break;
case 'DESCRIPTION':
$text = $this->newTextFromProperty($parameters, $value);
$node->setDescription($text);
break;
case 'DTSTART':
$datetime = $this->newDateTimeFromProperty($parameters, $value);
$node->setStartDateTime($datetime);
break;
case 'DTEND':
$datetime = $this->newDateTimeFromProperty($parameters, $value);
$node->setEndDateTime($datetime);
break;
case 'DURATION':
$duration = $this->newDurationFromProperty($parameters, $value);
$node->setDuration($duration);
break;
case 'RRULE':
$rrule = $this->newRecurrenceRuleFromProperty($parameters, $value);
$node->setRecurrenceRule($rrule);
break;
case 'RECURRENCE-ID':
$text = $this->newTextFromProperty($parameters, $value);
$node->setRecurrenceID($text);
break;
case 'ATTENDEE':
$attendee = $this->newAttendeeFromProperty($parameters, $value);
$node->addAttendee($attendee);
break;
+ case 'TRANSP':
+ $transp = $this->newTextFromProperty($parameters, $value);
+ $node->setTimeTransparency($transp);
+ break;
}
}
private function newTextFromProperty(array $parameters, array $value) {
$value = $value['value'];
return implode("\n\n", $value);
}
private function newAttendeeFromProperty(array $parameters, array $value) {
$uri = $value['value'];
switch (idx($parameters, 'PARTSTAT')) {
case 'ACCEPTED':
$status = PhutilCalendarUserNode::STATUS_ACCEPTED;
break;
case 'DECLINED':
$status = PhutilCalendarUserNode::STATUS_DECLINED;
break;
case 'NEEDS-ACTION':
default:
$status = PhutilCalendarUserNode::STATUS_INVITED;
break;
}
$name = $this->getScalarParameterValue($parameters, 'CN');
return id(new PhutilCalendarUserNode())
->setURI($uri)
->setName($name)
->setStatus($status);
}
private function newDateTimeFromProperty(array $parameters, array $value) {
$value = $value['value'];
if (!$value) {
$this->raiseParseFailure(
self::PARSE_EMPTY_DATETIME,
pht(
'Expected DATE-TIME to have exactly one value, found none.'));
}
if (count($value) > 1) {
$this->raiseParseFailure(
self::PARSE_MANY_DATETIME,
pht(
'Expected DATE-TIME to have exactly one value, found more than '.
'one.'));
}
$value = head($value);
$tzid = $this->getScalarParameterValue($parameters, 'TZID');
if (preg_match('/Z\z/', $value)) {
if ($tzid) {
$this->raiseWarning(
self::WARN_TZID_UTC,
pht(
'DATE-TIME "%s" uses "Z" to specify UTC, but also has a TZID '.
'parameter with value "%s". This violates RFC5545. The TZID '.
'will be ignored, and the value will be interpreted as UTC.',
$value,
$tzid));
}
$tzid = 'UTC';
} else if ($tzid !== null) {
$tzid = $this->guessTimezone($tzid);
}
try {
$datetime = PhutilCalendarAbsoluteDateTime::newFromISO8601(
$value,
$tzid);
} catch (Exception $ex) {
$this->raiseParseFailure(
self::PARSE_BAD_DATETIME,
pht(
'Error parsing DATE-TIME: %s',
$ex->getMessage()));
}
return $datetime;
}
private function newDurationFromProperty(array $parameters, array $value) {
$value = $value['value'];
if (!$value) {
$this->raiseParseFailure(
self::PARSE_EMPTY_DURATION,
pht(
'Expected DURATION to have exactly one value, found none.'));
}
if (count($value) > 1) {
$this->raiseParseFailure(
self::PARSE_MANY_DURATION,
pht(
'Expected DURATION to have exactly one value, found more than '.
'one.'));
}
$value = head($value);
try {
$duration = PhutilCalendarDuration::newFromISO8601($value);
} catch (Exception $ex) {
$this->raiseParseFailure(
self::PARSE_BAD_DURATION,
pht(
'Invalid DURATION: %s',
$ex->getMessage()));
}
return $duration;
}
private function newRecurrenceRuleFromProperty(array $parameters, $value) {
return PhutilCalendarRecurrenceRule::newFromRRULE($value['value']);
}
private function getScalarParameterValue(
array $parameters,
$name,
$default = null) {
$match = null;
foreach ($parameters as $parameter) {
if ($parameter['name'] == $name) {
$match = $parameter;
}
}
if ($match === null) {
return $default;
}
$value = $match['values'];
if (!$value) {
// Parameter is specified, but with no value, like "KEY=". Just return
// the default, as though the parameter was not specified.
return $default;
}
if (count($value) > 1) {
$this->raiseParseFailure(
self::PARSE_MULTIPLE_PARAMETERS,
pht(
'Expected parameter "%s" to have at most one value, but found '.
'more than one.',
$name));
}
return idx(head($value), 'value');
}
private function guessTimezone($tzid) {
$map = DateTimeZone::listIdentifiers();
$map = array_fuse($map);
if (isset($map[$tzid])) {
// This is a real timezone we recognize, so just use it as provided.
return $tzid;
}
// These are alternate names for timezones.
static $aliases;
if ($aliases === null) {
$aliases = array(
'Etc/GMT' => 'UTC',
);
// Load the map of Windows timezones.
$root_path = dirname(phutil_get_library_root('phabricator'));
$windows_path = $root_path.'/resources/timezones/windows-timezones.json';
$windows_data = Filesystem::readFile($windows_path);
$windows_zones = phutil_json_decode($windows_data);
$aliases = $aliases + $windows_zones;
}
if (isset($aliases[$tzid])) {
return $aliases[$tzid];
}
// Look for something that looks like "UTC+3" or "GMT -05.00". If we find
// anything, pick a timezone with that offset.
$offset_pattern =
'/'.
'(?:UTC|GMT)'.
'\s*'.
'(?P<sign>[+-])'.
'\s*'.
'(?P<h>\d+)'.
'(?:'.
'[:.](?P<m>\d+)'.
')?'.
'/i';
$matches = null;
if (preg_match($offset_pattern, $tzid, $matches)) {
$hours = (int)$matches['h'];
$minutes = (int)idx($matches, 'm');
$offset = ($hours * 60 * 60) + ($minutes * 60);
if (idx($matches, 'sign') == '-') {
$offset = -$offset;
}
// NOTE: We could possibly do better than this, by using the event start
// time to guess a timezone. However, that won't work for recurring
// events and would require us to do this work after finishing initial
// parsing. Since these unusual offset-based timezones appear to be rare,
// the benefit may not be worth the complexity.
$now = new DateTime('@'.time());
foreach ($map as $identifier) {
$zone = new DateTimeZone($identifier);
if ($zone->getOffset($now) == $offset) {
$this->raiseWarning(
self::WARN_TZID_GUESS,
pht(
'TZID "%s" is unknown, guessing "%s" based on pattern "%s".',
$tzid,
$identifier,
$matches[0]));
return $identifier;
}
}
}
$this->raiseWarning(
self::WARN_TZID_IGNORED,
pht(
'TZID "%s" is unknown, using UTC instead.',
$tzid));
return 'UTC';
}
}
diff --git a/src/applications/calendar/parser/ics/PhutilICSWriter.php b/src/applications/calendar/parser/ics/PhutilICSWriter.php
index c35008e079..5cd979973e 100644
--- a/src/applications/calendar/parser/ics/PhutilICSWriter.php
+++ b/src/applications/calendar/parser/ics/PhutilICSWriter.php
@@ -1,391 +1,402 @@
<?php
final class PhutilICSWriter extends Phobject {
public function writeICSDocument(PhutilCalendarRootNode $node) {
$out = array();
foreach ($node->getChildren() as $child) {
$out[] = $this->writeNode($child);
}
return implode('', $out);
}
private function writeNode(PhutilCalendarNode $node) {
if (!$this->getICSNodeType($node)) {
return null;
}
$out = array();
$out[] = $this->writeBeginNode($node);
$out[] = $this->writeNodeProperties($node);
if ($node instanceof PhutilCalendarContainerNode) {
foreach ($node->getChildren() as $child) {
$out[] = $this->writeNode($child);
}
}
$out[] = $this->writeEndNode($node);
return implode('', $out);
}
private function writeBeginNode(PhutilCalendarNode $node) {
$type = $this->getICSNodeType($node);
return $this->wrapICSLine("BEGIN:{$type}");
}
private function writeEndNode(PhutilCalendarNode $node) {
$type = $this->getICSNodeType($node);
return $this->wrapICSLine("END:{$type}");
}
private function writeNodeProperties(PhutilCalendarNode $node) {
$properties = $this->getNodeProperties($node);
$out = array();
foreach ($properties as $property) {
$propname = $property['name'];
$propvalue = $property['value'];
$propline = array();
$propline[] = $propname;
foreach ($property['parameters'] as $parameter) {
$paramname = $parameter['name'];
$paramvalue = $parameter['value'];
$propline[] = ";{$paramname}={$paramvalue}";
}
$propline[] = ":{$propvalue}";
$propline = implode('', $propline);
$out[] = $this->wrapICSLine($propline);
}
return implode('', $out);
}
private function getICSNodeType(PhutilCalendarNode $node) {
switch ($node->getNodeType()) {
case PhutilCalendarDocumentNode::NODETYPE:
return 'VCALENDAR';
case PhutilCalendarEventNode::NODETYPE:
return 'VEVENT';
default:
return null;
}
}
private function wrapICSLine($line) {
$out = array();
$buf = '';
// NOTE: The line may contain sequences of combining characters which are
// more than 80 bytes in length. If it does, we'll split them in the
// middle of the sequence. This is okay and generally anticipated by
// RFC5545, which even allows implementations to split multibyte
// characters. The sequence will be stitched back together properly by
// whatever is parsing things.
foreach (phutil_utf8v($line) as $character) {
// If adding this character would bring the line over 75 bytes, start
// a new line.
if (strlen($buf) + strlen($character) > 75) {
$out[] = $buf."\r\n";
$buf = ' ';
}
$buf .= $character;
}
$out[] = $buf."\r\n";
return implode('', $out);
}
private function getNodeProperties(PhutilCalendarNode $node) {
switch ($node->getNodeType()) {
case PhutilCalendarDocumentNode::NODETYPE:
return $this->getDocumentNodeProperties($node);
case PhutilCalendarEventNode::NODETYPE:
return $this->getEventNodeProperties($node);
default:
return array();
}
}
private function getDocumentNodeProperties(
PhutilCalendarDocumentNode $event) {
$properties = array();
$properties[] = $this->newTextProperty(
'VERSION',
'2.0');
$properties[] = $this->newTextProperty(
'PRODID',
self::getICSPRODID());
return $properties;
}
public static function getICSPRODID() {
return '-//Phacility//Phabricator//EN';
}
private function getEventNodeProperties(PhutilCalendarEventNode $event) {
$properties = array();
$uid = $event->getUID();
if (!strlen($uid)) {
throw new Exception(
pht(
'Unable to write ICS document: event has no UID, but each event '.
'MUST have a UID.'));
}
$properties[] = $this->newTextProperty(
'UID',
$uid);
$created = $event->getCreatedDateTime();
if ($created) {
$properties[] = $this->newDateTimeProperty(
'CREATED',
$event->getCreatedDateTime());
}
$dtstamp = $event->getModifiedDateTime();
if (!$dtstamp) {
throw new Exception(
pht(
'Unable to write ICS document: event has no modified time, but '.
'each event MUST have a modified time.'));
}
$properties[] = $this->newDateTimeProperty(
'DTSTAMP',
$dtstamp);
$dtstart = $event->getStartDateTime();
if ($dtstart) {
$properties[] = $this->newDateTimeProperty(
'DTSTART',
$dtstart);
}
$dtend = $event->getEndDateTime();
if ($dtend) {
$properties[] = $this->newDateTimeProperty(
'DTEND',
$event->getEndDateTime());
}
$name = $event->getName();
if (phutil_nonempty_string($name)) {
$properties[] = $this->newTextProperty(
'SUMMARY',
$name);
}
$description = $event->getDescription();
if (phutil_nonempty_string($description)) {
$properties[] = $this->newTextProperty(
'DESCRIPTION',
$description);
}
$organizer = $event->getOrganizer();
if ($organizer) {
$properties[] = $this->newUserProperty(
'ORGANIZER',
$organizer);
}
$attendees = $event->getAttendees();
if ($attendees) {
foreach ($attendees as $attendee) {
$properties[] = $this->newUserProperty(
'ATTENDEE',
$attendee);
}
}
+ // In the future you may want to add export support
+ // to the "Time Trasparency" field. In case, please tell us why.
+ // No one needs it at the moment. This is not even persisted
+ // in the event object, so, this cannot be exported.
+// $transp = $event->getTimeTransparency();
+// if ($transp) {
+// $properties[] = $this->newTextProperty(
+// 'TRANSP',
+// $transp);
+// }
+
$rrule = $event->getRecurrenceRule();
if ($rrule) {
$properties[] = $this->newRRULEProperty(
'RRULE',
$rrule);
}
$recurrence_id = $event->getRecurrenceID();
if ($recurrence_id) {
$properties[] = $this->newTextProperty(
'RECURRENCE-ID',
$recurrence_id);
}
$exdates = $event->getRecurrenceExceptions();
if ($exdates) {
$properties[] = $this->newDateTimesProperty(
'EXDATE',
$exdates);
}
$rdates = $event->getRecurrenceDates();
if ($rdates) {
$properties[] = $this->newDateTimesProperty(
'RDATE',
$rdates);
}
return $properties;
}
private function newTextProperty(
$name,
$value,
array $parameters = array()) {
$map = array(
'\\' => '\\\\',
',' => '\\,',
"\n" => '\\n',
);
$value = (array)$value;
foreach ($value as $k => $v) {
$v = str_replace(array_keys($map), array_values($map), $v);
$value[$k] = $v;
}
$value = implode(',', $value);
return $this->newProperty($name, $value, $parameters);
}
private function newDateTimeProperty(
$name,
PhutilCalendarDateTime $value,
array $parameters = array()) {
return $this->newDateTimesProperty($name, array($value), $parameters);
}
private function newDateTimesProperty(
$name,
array $values,
array $parameters = array()) {
assert_instances_of($values, 'PhutilCalendarDateTime');
if (head($values)->getIsAllDay()) {
$parameters[] = array(
'name' => 'VALUE',
'values' => array(
'DATE',
),
);
}
$datetimes = array();
foreach ($values as $value) {
$datetimes[] = $value->getISO8601();
}
$datetimes = implode(';', $datetimes);
return $this->newProperty($name, $datetimes, $parameters);
}
private function newUserProperty(
$name,
PhutilCalendarUserNode $value,
array $parameters = array()) {
$parameters[] = array(
'name' => 'CN',
'values' => array(
$value->getName(),
),
);
$partstat = null;
switch ($value->getStatus()) {
case PhutilCalendarUserNode::STATUS_INVITED:
$partstat = 'NEEDS-ACTION';
break;
case PhutilCalendarUserNode::STATUS_ACCEPTED:
$partstat = 'ACCEPTED';
break;
case PhutilCalendarUserNode::STATUS_DECLINED:
$partstat = 'DECLINED';
break;
}
if ($partstat !== null) {
$parameters[] = array(
'name' => 'PARTSTAT',
'values' => array(
$partstat,
),
);
}
// TODO: We could reasonably fill in "ROLE" and "RSVP" here too, but it
// isn't clear if these are important to external programs or not.
return $this->newProperty($name, $value->getURI(), $parameters);
}
private function newRRULEProperty(
$name,
PhutilCalendarRecurrenceRule $rule,
array $parameters = array()) {
$value = $rule->toRRULE();
return $this->newProperty($name, $value, $parameters);
}
private function newProperty(
$name,
$value,
array $parameters = array()) {
$map = array(
'^' => '^^',
"\n" => '^n',
'"' => "^'",
);
$writable_params = array();
foreach ($parameters as $k => $parameter) {
$value_list = array();
foreach ($parameter['values'] as $v) {
$v = str_replace(array_keys($map), array_values($map), $v);
// If the parameter value isn't a very simple one, quote it.
// RFC5545 says that we MUST quote it if it has a colon, a semicolon,
// or a comma, and that we MUST quote it if it's a URI.
if (!preg_match('/^[A-Za-z0-9-]*\z/', $v)) {
$v = '"'.$v.'"';
}
$value_list[] = $v;
}
$writable_params[] = array(
'name' => $parameter['name'],
'value' => implode(',', $value_list),
);
}
return array(
'name' => $name,
'value' => $value,
'parameters' => $writable_params,
);
}
}
diff --git a/src/applications/calendar/parser/ics/__tests__/PhutilICSParserTestCase.php b/src/applications/calendar/parser/ics/__tests__/PhutilICSParserTestCase.php
index e99acaf8ab..b10c0eaa7e 100644
--- a/src/applications/calendar/parser/ics/__tests__/PhutilICSParserTestCase.php
+++ b/src/applications/calendar/parser/ics/__tests__/PhutilICSParserTestCase.php
@@ -1,341 +1,352 @@
<?php
final class PhutilICSParserTestCase extends PhutilTestCase {
public function testICSParser() {
$event = $this->parseICSSingleEvent('simple.ics');
$this->assertEqual(
array(
array(
'name' => 'CREATED',
'parameters' => array(),
'value' => array(
'type' => 'DATE-TIME',
'value' => array(
'20160908T172702Z',
),
'raw' => '20160908T172702Z',
),
),
array(
'name' => 'UID',
'parameters' => array(),
'value' => array(
'type' => 'TEXT',
'value' => array(
'1CEB57AF-0C9C-402D-B3BD-D75BD4843F68',
),
'raw' => '1CEB57AF-0C9C-402D-B3BD-D75BD4843F68',
),
),
array(
'name' => 'DTSTART',
'parameters' => array(
array(
'name' => 'TZID',
'values' => array(
array(
'value' => 'America/Los_Angeles',
'quoted' => false,
),
),
),
),
'value' => array(
'type' => 'DATE-TIME',
'value' => array(
'20160915T090000',
),
'raw' => '20160915T090000',
),
),
array(
'name' => 'DTEND',
'parameters' => array(
array(
'name' => 'TZID',
'values' => array(
array(
'value' => 'America/Los_Angeles',
'quoted' => false,
),
),
),
),
'value' => array(
'type' => 'DATE-TIME',
'value' => array(
'20160915T100000',
),
'raw' => '20160915T100000',
),
),
array(
'name' => 'SUMMARY',
'parameters' => array(),
'value' => array(
'type' => 'TEXT',
'value' => array(
'Simple Event',
),
'raw' => 'Simple Event',
),
),
array(
'name' => 'DESCRIPTION',
'parameters' => array(),
'value' => array(
'type' => 'TEXT',
'value' => array(
'This is a simple event.',
),
'raw' => 'This is a simple event.',
),
),
+ array(
+ 'name' => 'TRANSP',
+ 'parameters' => array(),
+ 'value' => array(
+ 'type' => 'TEXT',
+ 'value' => array(
+ 'OPAQUE',
+ ),
+ 'raw' => 'OPAQUE',
+ ),
+ ),
),
$event->getAttribute('ics.properties'));
$this->assertEqual(
'Simple Event',
$event->getName());
$this->assertEqual(
'This is a simple event.',
$event->getDescription());
$this->assertEqual(
1473955200,
$event->getStartDateTime()->getEpoch());
$this->assertEqual(
1473955200 + phutil_units('1 hour in seconds'),
$event->getEndDateTime()->getEpoch());
}
public function testICSOddTimezone() {
$event = $this->parseICSSingleEvent('zimbra-timezone.ics');
$start = $event->getStartDateTime();
$this->assertEqual(
'20170303T140000Z',
$start->getISO8601());
}
public function testICSFloatingTime() {
// This tests "floating" event times, which have no absolute time and are
// supposed to be interpreted using the viewer's timezone. It also uses
// a duration, and the duration needs to float along with the viewer
// timezone.
$event = $this->parseICSSingleEvent('floating.ics');
$start = $event->getStartDateTime();
$caught = null;
try {
$start->getEpoch();
} catch (Exception $ex) {
$caught = $ex;
}
$this->assertTrue(
($caught instanceof Exception),
pht('Expected exception for floating time with no viewer timezone.'));
$newyears_utc = strtotime('2015-01-01 00:00:00 UTC');
$this->assertEqual(1420070400, $newyears_utc);
$start->setViewerTimezone('UTC');
$this->assertEqual(
$newyears_utc,
$start->getEpoch());
$start->setViewerTimezone('America/Los_Angeles');
$this->assertEqual(
$newyears_utc + phutil_units('8 hours in seconds'),
$start->getEpoch());
$start->setViewerTimezone('America/New_York');
$this->assertEqual(
$newyears_utc + phutil_units('5 hours in seconds'),
$start->getEpoch());
$end = $event->getEndDateTime();
$end->setViewerTimezone('UTC');
$this->assertEqual(
$newyears_utc + phutil_units('24 hours in seconds'),
$end->getEpoch());
$end->setViewerTimezone('America/Los_Angeles');
$this->assertEqual(
$newyears_utc + phutil_units('32 hours in seconds'),
$end->getEpoch());
$end->setViewerTimezone('America/New_York');
$this->assertEqual(
$newyears_utc + phutil_units('29 hours in seconds'),
$end->getEpoch());
}
public function testICSVALARM() {
$event = $this->parseICSSingleEvent('valarm.ics');
// For now, we parse but ignore VALARM sections. This test just makes
// sure they survive parsing.
$start_epoch = strtotime('2016-10-19 22:00:00 UTC');
$this->assertEqual(1476914400, $start_epoch);
$this->assertEqual(
$start_epoch,
$event->getStartDateTime()->getEpoch());
}
public function testICSDuration() {
$event = $this->parseICSSingleEvent('duration.ics');
// Raw value is "20160719T095722Z".
$start_epoch = strtotime('2016-07-19 09:57:22 UTC');
$this->assertEqual(1468922242, $start_epoch);
// Raw value is "P1DT17H4M23S".
$duration =
phutil_units('1 day in seconds') +
phutil_units('17 hours in seconds') +
phutil_units('4 minutes in seconds') +
phutil_units('23 seconds in seconds');
$this->assertEqual(
$start_epoch,
$event->getStartDateTime()->getEpoch());
$this->assertEqual(
$start_epoch + $duration,
$event->getEndDateTime()->getEpoch());
}
public function testICSWeeklyEvent() {
$event = $this->parseICSSingleEvent('weekly.ics');
$start = $event->getStartDateTime();
$start->setViewerTimezone('UTC');
$rrule = $event->getRecurrenceRule()
->setStartDateTime($start);
$rset = id(new PhutilCalendarRecurrenceSet())
->addSource($rrule);
$result = $rset->getEventsBetween(null, null, 3);
$expect = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20150811'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20150818'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20150825'),
);
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Weekly recurring event.'));
}
public function testICSParserErrors() {
$map = array(
'err-missing-end.ics' => PhutilICSParser::PARSE_MISSING_END,
'err-bad-base64.ics' => PhutilICSParser::PARSE_BAD_BASE64,
'err-bad-boolean.ics' => PhutilICSParser::PARSE_BAD_BOOLEAN,
'err-extra-end.ics' => PhutilICSParser::PARSE_EXTRA_END,
'err-initial-unfold.ics' => PhutilICSParser::PARSE_INITIAL_UNFOLD,
'err-malformed-double-quote.ics' =>
PhutilICSParser::PARSE_MALFORMED_DOUBLE_QUOTE,
'err-malformed-parameter.ics' =>
PhutilICSParser::PARSE_MALFORMED_PARAMETER_NAME,
'err-malformed-property.ics' =>
PhutilICSParser::PARSE_MALFORMED_PROPERTY,
'err-missing-value.ics' => PhutilICSParser::PARSE_MISSING_VALUE,
'err-mixmatched-sections.ics' =>
PhutilICSParser::PARSE_MISMATCHED_SECTIONS,
'err-root-property.ics' => PhutilICSParser::PARSE_ROOT_PROPERTY,
'err-unescaped-backslash.ics' =>
PhutilICSParser::PARSE_UNESCAPED_BACKSLASH,
'err-unexpected-text.ics' => PhutilICSParser::PARSE_UNEXPECTED_TEXT,
'err-multiple-parameters.ics' =>
PhutilICSParser::PARSE_MULTIPLE_PARAMETERS,
'err-empty-datetime.ics' =>
PhutilICSParser::PARSE_EMPTY_DATETIME,
'err-many-datetime.ics' =>
PhutilICSParser::PARSE_MANY_DATETIME,
'err-bad-datetime.ics' =>
PhutilICSParser::PARSE_BAD_DATETIME,
'err-empty-duration.ics' =>
PhutilICSParser::PARSE_EMPTY_DURATION,
'err-many-duration.ics' =>
PhutilICSParser::PARSE_MANY_DURATION,
'err-bad-duration.ics' =>
PhutilICSParser::PARSE_BAD_DURATION,
'simple.ics' => null,
'good-boolean.ics' => null,
'multiple-vcalendars.ics' => null,
);
foreach ($map as $test_file => $expect) {
$caught = null;
try {
$this->parseICSDocument($test_file);
} catch (PhutilICSParserException $ex) {
$caught = $ex;
}
if ($expect === null) {
$this->assertTrue(
($caught === null),
pht(
'Expected no exception parsing "%s", got: %s',
$test_file,
(string)$ex));
} else {
if ($caught) {
$code = $ex->getParserFailureCode();
$explain = pht(
'Expected one exception parsing "%s", got a different '.
'one: %s',
$test_file,
(string)$ex);
} else {
$code = null;
$explain = pht(
'Expected exception parsing "%s", got none.',
$test_file);
}
$this->assertEqual($expect, $code, $explain);
}
}
}
private function parseICSSingleEvent($name) {
$root = $this->parseICSDocument($name);
$documents = $root->getDocuments();
$this->assertEqual(1, count($documents));
$document = head($documents);
$events = $document->getEvents();
$this->assertEqual(1, count($events));
return head($events);
}
private function parseICSDocument($name) {
$path = dirname(__FILE__).'/data/'.$name;
$data = Filesystem::readFile($path);
return id(new PhutilICSParser())
->parseICSData($data);
}
}
diff --git a/src/applications/calendar/parser/ics/__tests__/data/simple.ics b/src/applications/calendar/parser/ics/__tests__/data/simple.ics
index 8181a24ff0..115a486953 100644
--- a/src/applications/calendar/parser/ics/__tests__/data/simple.ics
+++ b/src/applications/calendar/parser/ics/__tests__/data/simple.ics
@@ -1,12 +1,13 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
CREATED:20160908T172702Z
UID:1CEB57AF-0C9C-402D-B3BD-D75BD4843F68
DTSTART;TZID=America/Los_Angeles:20160915T090000
DTEND;TZID=America/Los_Angeles:20160915T100000
SUMMARY:Simple Event
DESCRIPTION:This is a simple event.
+TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR
diff --git a/src/applications/calendar/storage/PhabricatorCalendarEventInvitee.php b/src/applications/calendar/storage/PhabricatorCalendarEventInvitee.php
index 23c9ad33df..000f99461b 100644
--- a/src/applications/calendar/storage/PhabricatorCalendarEventInvitee.php
+++ b/src/applications/calendar/storage/PhabricatorCalendarEventInvitee.php
@@ -1,124 +1,151 @@
<?php
final class PhabricatorCalendarEventInvitee extends PhabricatorCalendarDAO
implements PhabricatorPolicyInterface {
protected $eventPHID;
protected $inviteePHID;
protected $inviterPHID;
protected $status;
protected $availability = self::AVAILABILITY_DEFAULT;
const STATUS_INVITED = 'invited';
const STATUS_ATTENDING = 'attending';
const STATUS_DECLINED = 'declined';
const STATUS_UNINVITED = 'uninvited';
const AVAILABILITY_DEFAULT = 'default';
const AVAILABILITY_AVAILABLE = 'available';
const AVAILABILITY_BUSY = 'busy';
const AVAILABILITY_AWAY = 'away';
public static function initializeNewCalendarEventInvitee(
PhabricatorUser $actor, $event) {
return id(new PhabricatorCalendarEventInvitee())
->setInviterPHID($actor->getPHID())
->setStatus(self::STATUS_INVITED)
->setEventPHID($event->getPHID());
}
protected function getConfiguration() {
return array(
self::CONFIG_COLUMN_SCHEMA => array(
'status' => 'text64',
'availability' => 'text64',
),
self::CONFIG_KEY_SCHEMA => array(
'key_event' => array(
'columns' => array('eventPHID', 'inviteePHID'),
'unique' => true,
),
'key_invitee' => array(
'columns' => array('inviteePHID'),
),
),
) + parent::getConfiguration();
}
public function isAttending() {
return ($this->getStatus() == self::STATUS_ATTENDING);
}
public function isUninvited() {
if ($this->getStatus() == self::STATUS_UNINVITED) {
return true;
} else {
return false;
}
}
public function getDisplayAvailability(PhabricatorCalendarEvent $event) {
switch ($this->getAvailability()) {
case self::AVAILABILITY_DEFAULT:
case self::AVAILABILITY_BUSY:
return self::AVAILABILITY_BUSY;
case self::AVAILABILITY_AWAY:
return self::AVAILABILITY_AWAY;
default:
return null;
}
}
+ /**
+ * Import the invitee availability from the Time Transparency
+ * field in an ICS calendar event as per RFC 5545 section 3.8.2.7.
+ * @param wild $time_transp Time transparency like 'OPAQUE'
+ * or 'TRANSPARENT' or null.
+ * @return void
+ */
+ public function importAvailabilityFromTimeTransparency($time_transp) {
+ // How to understand RFC 5545 suburbs. Example conversation:
+ // "Hey dude
+ // I'm a bit *opaque* on this event so I'm not *transparent*"
+ // Means:
+ // "Good morning Sir,
+ // I'm a bit *busy* on this business so I'm not *available*"
+ static $transparency_2_availability = array(
+ 'OPAQUE' => self::AVAILABILITY_BUSY,
+ 'TRANSPARENT' => self::AVAILABILITY_AVAILABLE,
+ );
+
+ // Note that idx($array, $key) likes a null $key.
+ $availability = idx($transparency_2_availability, $time_transp);
+ if ($availability) {
+ $this->setAvailability($availability);
+ }
+ }
+
+
public static function getAvailabilityMap() {
return array(
self::AVAILABILITY_AVAILABLE => array(
'color' => 'green',
'name' => pht('Available'),
),
self::AVAILABILITY_BUSY => array(
'color' => 'orange',
'name' => pht('Busy'),
),
self::AVAILABILITY_AWAY => array(
'color' => 'red',
'name' => pht('Away'),
),
);
}
public static function getAvailabilitySpec($const) {
return idx(self::getAvailabilityMap(), $const, array());
}
public static function getAvailabilityName($const) {
$spec = self::getAvailabilitySpec($const);
return idx($spec, 'name', $const);
}
public static function getAvailabilityColor($const) {
$spec = self::getAvailabilitySpec($const);
return idx($spec, 'color', 'indigo');
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return PhabricatorPolicies::getMostOpenPolicy();
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return false;
}
}
diff --git a/src/applications/people/query/PhabricatorPeopleUserEmailQuery.php b/src/applications/people/query/PhabricatorPeopleUserEmailQuery.php
index 178a3b9460..ae340cbfe1 100644
--- a/src/applications/people/query/PhabricatorPeopleUserEmailQuery.php
+++ b/src/applications/people/query/PhabricatorPeopleUserEmailQuery.php
@@ -1,77 +1,111 @@
<?php
final class PhabricatorPeopleUserEmailQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $ids;
private $phids;
+ private $userPhids;
+ private $isVerified;
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
+ /**
+ * With the specified User PHIDs.
+ * @param null|array $phids User PHIDs
+ */
+ public function withUserPHIDs(array $phids) {
+ $this->userPhids = $phids;
+ return $this;
+ }
+
+ /**
+ * With a verified email or not.
+ * @param bool|null $isVerified
+ */
+ public function withIsVerified($verified) {
+ $this->isVerified = $verified;
+ return $this;
+ }
+
public function newResultObject() {
return new PhabricatorUserEmail();
}
protected function getPrimaryTableAlias() {
return 'email';
}
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
$where = parent::buildWhereClauseParts($conn);
if ($this->ids !== null) {
$where[] = qsprintf(
$conn,
'email.id IN (%Ld)',
$this->ids);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn,
'email.phid IN (%Ls)',
$this->phids);
}
+ if ($this->userPhids !== null) {
+ $where[] = qsprintf(
+ $conn,
+ 'email.userPHID IN (%Ls)',
+ $this->userPhids);
+ }
+
+ if ($this->isVerified !== null) {
+ $where[] = qsprintf(
+ $conn,
+ 'email.isVerified = %d',
+ (int)$this->isVerified);
+ }
+
return $where;
}
protected function willLoadPage(array $page) {
$user_phids = mpull($page, 'getUserPHID');
$users = id(new PhabricatorPeopleQuery())
->setViewer($this->getViewer())
->setParentQuery($this)
->withPHIDs($user_phids)
->execute();
$users = mpull($users, null, 'getPHID');
foreach ($page as $key => $address) {
$user = idx($users, $address->getUserPHID());
if (!$user) {
unset($page[$key]);
$this->didRejectResult($address);
continue;
}
$address->attachUser($user);
}
return $page;
}
public function getQueryApplicationClass() {
return PhabricatorPeopleApplication::class;
}
}
File Metadata
详情
附加的
Mime Type
text/x-diff
Expires
9月 11 Thu, 1:35 PM (1 d, 51 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
5513
默认替代文本
(110 KB)
Attached To
Mode
rP phorge
附加的
Detach File
Event Timeline
Log In to Comment