Page MenuHomeWMGMC Issues

No OneTemporary

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)

Event Timeline