diff --git a/src/applications/calendar/import/PhabricatorCalendarImportEngine.php b/src/applications/calendar/import/PhabricatorCalendarImportEngine.php index aaedf3e2fd..0b91a6244c 100644 --- a/src/applications/calendar/import/PhabricatorCalendarImportEngine.php +++ b/src/applications/calendar/import/PhabricatorCalendarImportEngine.php @@ -1,424 +1,427 @@ 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); abstract public function canDisable( PhabricatorUser $viewer, PhabricatorCalendarImport $import); public function explainCanDisable( PhabricatorUser $viewer, PhabricatorCalendarImport $import) { throw new PhutilMethodNotImplementedException(); } 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($viewer->getPHID())) ->withImportUIDs(array_keys($node_map)) ->execute(); $events = mpull($events, null, 'getImportUID'); } else { $events = null; } $xactions = array(); $update_map = array(); foreach ($node_map as $full_uid => $node) { $event = idx($events, $full_uid); if (!$event) { $event = PhabricatorCalendarEvent::initializeNewCalendarEvent($viewer); } $event ->setImportAuthorPHID($viewer->getPHID()) ->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; } // 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]); if ($parent_uid) { $parent_phid = $update_map[$full_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); $import->newLogMessage( PhabricatorCalendarImportUpdateLogType::LOGTYPE, array( 'new' => $is_new, 'phid' => $event->getPHID(), )); } if (!$update_map) { $import->newLogMessage( PhabricatorCalendarImportEmptyLogType::LOGTYPE, array()); } // TODO: When the source is a subscription-based ICS file or some other // similar source, we should load all events from the source here and // destroy the ones we didn't update. These are events that have been // deleted. } 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 (!strlen($recurrence_id)) { return null; } return $node->getUID().'/'; } private function getNodeInstanceEpoch(PhutilCalendarEventNode $node) { $instance_iso = $node->getRecurrenceID(); if (strlen($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(); $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); // 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) { $any_event = id(new PhabricatorCalendarEventQuery()) ->setViewer($viewer) ->withImportSourcePHIDs(array($import->getPHID())) ->setLimit(1) ->execute(); return (bool)$any_event; } } diff --git a/src/applications/calendar/query/PhabricatorCalendarEventQuery.php b/src/applications/calendar/query/PhabricatorCalendarEventQuery.php index 9949ee2f5a..5c6b34c9fb 100644 --- a/src/applications/calendar/query/PhabricatorCalendarEventQuery.php +++ b/src/applications/calendar/query/PhabricatorCalendarEventQuery.php @@ -1,639 +1,644 @@ generateGhosts = $generate_ghosts; return $this; } public function withIDs(array $ids) { $this->ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withDateRange($begin, $end) { $this->rangeBegin = $begin; $this->rangeEnd = $end; return $this; } public function withInvitedPHIDs(array $phids) { $this->inviteePHIDs = $phids; return $this; } public function withHostPHIDs(array $phids) { $this->hostPHIDs = $phids; return $this; } public function withIsCancelled($is_cancelled) { $this->isCancelled = $is_cancelled; return $this; } public function withIsStub($is_stub) { $this->isStub = $is_stub; return $this; } public function withEventsWithNoParent($events_with_no_parent) { $this->eventsWithNoParent = $events_with_no_parent; return $this; } public function withInstanceSequencePairs(array $pairs) { $this->instanceSequencePairs = $pairs; return $this; } public function withParentEventPHIDs(array $parent_phids) { $this->parentEventPHIDs = $parent_phids; return $this; } public function withImportSourcePHIDs(array $import_phids) { $this->importSourcePHIDs = $import_phids; return $this; } public function withImportAuthorPHIDs(array $author_phids) { $this->importAuthorPHIDs = $author_phids; return $this; } public function withImportUIDs(array $uids) { $this->importUIDs = $uids; return $this; } protected function getDefaultOrderVector() { return array('start', 'id'); } public function getBuiltinOrders() { return array( 'start' => array( 'vector' => array('start', 'id'), 'name' => pht('Event Start'), ), ) + parent::getBuiltinOrders(); } public function getOrderableColumns() { return array( 'start' => array( 'table' => $this->getPrimaryTableAlias(), 'column' => 'dateFrom', 'reverse' => true, 'type' => 'int', 'unique' => false, ), ) + parent::getOrderableColumns(); } protected function getPagingValueMap($cursor, array $keys) { $event = $this->loadCursorObject($cursor); return array( 'start' => $event->getStartDateTimeEpoch(), 'id' => $event->getID(), ); } protected function shouldLimitResults() { // When generating ghosts, we can't rely on database ordering because // MySQL can't predict the ghost start times. We'll just load all matching // events, then generate results from there. if ($this->generateGhosts) { return false; } return true; } protected function loadPage() { $events = $this->loadStandardPage($this->newResultObject()); $viewer = $this->getViewer(); foreach ($events as $event) { $event->applyViewerTimezone($viewer); } if (!$this->generateGhosts) { return $events; } $raw_limit = $this->getRawResultLimit(); if (!$raw_limit && !$this->rangeEnd) { throw new Exception( pht( 'Event queries which generate ghost events must include either a '. 'result limit or an end date, because they may otherwise generate '. 'an infinite number of results. This query has neither.')); } foreach ($events as $key => $event) { $sequence_start = 0; $sequence_end = null; $end = null; $instance_of = $event->getInstanceOfEventPHID(); if ($instance_of == null && $this->isCancelled !== null) { if ($event->getIsCancelled() != $this->isCancelled) { unset($events[$key]); continue; } } } // Pull out all of the parents first. We may discard them as we begin // generating ghost events, but we still want to process all of them. $parents = array(); foreach ($events as $key => $event) { if ($event->isParentEvent()) { $parents[$key] = $event; } } // Now that we've picked out all the parent events, we can immediately // discard anything outside of the time window. $events = $this->getEventsInRange($events); $generate_from = $this->rangeBegin; $generate_until = $this->rangeEnd; foreach ($parents as $key => $event) { $duration = $event->getDuration(); $start_date = $this->getRecurrenceWindowStart( $event, $generate_from - $duration); $end_date = $this->getRecurrenceWindowEnd( $event, $generate_until); $limit = $this->getRecurrenceLimit($event, $raw_limit); $set = $event->newRecurrenceSet(); $recurrences = $set->getEventsBetween( null, $end_date, $limit + 1); // We're generating events from the beginning and then filtering them // here (instead of only generating events starting at the start date) // because we need to know the proper sequence indexes to generate ghost // events. This may change after RDATE support. if ($start_date) { $start_epoch = $start_date->getEpoch(); } else { $start_epoch = null; } foreach ($recurrences as $sequence_index => $sequence_datetime) { if (!$sequence_index) { // This is the parent event, which we already have. continue; } if ($start_epoch) { if ($sequence_datetime->getEpoch() < $start_epoch) { continue; } } $events[] = $event->newGhost( $viewer, $sequence_index, $sequence_datetime); } // NOTE: We're slicing results every time because this makes it cheaper // to generate future ghosts. If we already have 100 events that occur // before July 1, we know we never need to generate ghosts after that // because they couldn't possibly ever appear in the result set. if ($raw_limit) { if (count($events) > $raw_limit) { $events = msort($events, 'getStartDateTimeEpoch'); $events = array_slice($events, 0, $raw_limit, true); $generate_until = last($events)->getEndDateTimeEpoch(); } } } // Now that we're done generating ghost events, we're going to remove any // ghosts that we have concrete events for (or which we can load the // concrete events for). These concrete events are generated when users // edit a ghost, and replace the ghost events. // First, generate a map of all concrete events we // already loaded. We don't need to load these again. $have_pairs = array(); foreach ($events as $event) { if ($event->getIsGhostEvent()) { continue; } $parent_phid = $event->getInstanceOfEventPHID(); $sequence = $event->getSequenceIndex(); $have_pairs[$parent_phid][$sequence] = true; } // Now, generate a map of all events we generated // ghosts for. We need to try to load these if we don't already have them. $map = array(); $parent_pairs = array(); foreach ($events as $key => $event) { if (!$event->getIsGhostEvent()) { continue; } $parent_phid = $event->getInstanceOfEventPHID(); $sequence = $event->getSequenceIndex(); // We already loaded the concrete version of this event, so we can just // throw out the ghost and move on. if (isset($have_pairs[$parent_phid][$sequence])) { unset($events[$key]); continue; } // We didn't load the concrete version of this event, so we need to // try to load it if it exists. $parent_pairs[] = array($parent_phid, $sequence); $map[$parent_phid][$sequence] = $key; } if ($parent_pairs) { $instances = id(new self()) ->setViewer($viewer) ->setParentQuery($this) ->withInstanceSequencePairs($parent_pairs) ->execute(); foreach ($instances as $instance) { $parent_phid = $instance->getInstanceOfEventPHID(); $sequence = $instance->getSequenceIndex(); $indexes = idx($map, $parent_phid); $key = idx($indexes, $sequence); // Replace the ghost with the corresponding concrete event. $events[$key] = $instance; } } $events = msort($events, 'getStartDateTimeEpoch'); return $events; } protected function buildJoinClauseParts(AphrontDatabaseConnection $conn_r) { $parts = parent::buildJoinClauseParts($conn_r); if ($this->inviteePHIDs !== null) { $parts[] = qsprintf( $conn_r, 'JOIN %T invitee ON invitee.eventPHID = event.phid AND invitee.status != %s', id(new PhabricatorCalendarEventInvitee())->getTableName(), PhabricatorCalendarEventInvitee::STATUS_UNINVITED); } return $parts; } protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); if ($this->ids !== null) { $where[] = qsprintf( $conn, 'event.id IN (%Ld)', $this->ids); } if ($this->phids !== null) { $where[] = qsprintf( $conn, 'event.phid IN (%Ls)', $this->phids); } // NOTE: The date ranges we query for are larger than the requested ranges // because we need to catch all-day events. We'll refine this range later // after adjusting the visible range of events we load. if ($this->rangeBegin) { $where[] = qsprintf( $conn, '(event.utcUntilEpoch >= %d) OR (event.utcUntilEpoch IS NULL)', $this->rangeBegin - phutil_units('16 hours in seconds')); } if ($this->rangeEnd) { $where[] = qsprintf( $conn, 'event.utcInitialEpoch <= %d', $this->rangeEnd + phutil_units('16 hours in seconds')); } if ($this->inviteePHIDs !== null) { $where[] = qsprintf( $conn, 'invitee.inviteePHID IN (%Ls)', $this->inviteePHIDs); } if ($this->hostPHIDs !== null) { $where[] = qsprintf( $conn, 'event.hostPHID IN (%Ls)', $this->hostPHIDs); } if ($this->isCancelled !== null) { $where[] = qsprintf( $conn, 'event.isCancelled = %d', (int)$this->isCancelled); } if ($this->eventsWithNoParent == true) { $where[] = qsprintf( $conn, 'event.instanceOfEventPHID IS NULL'); } if ($this->instanceSequencePairs !== null) { $sql = array(); foreach ($this->instanceSequencePairs as $pair) { $sql[] = qsprintf( $conn, '(event.instanceOfEventPHID = %s AND event.sequenceIndex = %d)', $pair[0], $pair[1]); } $where[] = qsprintf( $conn, '%Q', implode(' OR ', $sql)); } if ($this->isStub !== null) { $where[] = qsprintf( $conn, 'event.isStub = %d', (int)$this->isStub); } if ($this->parentEventPHIDs !== null) { $where[] = qsprintf( $conn, 'event.instanceOfEventPHID IN (%Ls)', $this->parentEventPHIDs); } if ($this->importSourcePHIDs !== null) { $where[] = qsprintf( $conn, 'event.importSourcePHID IN (%Ls)', $this->importSourcePHIDs); } if ($this->importAuthorPHIDs !== null) { $where[] = qsprintf( $conn, 'event.importAuthorPHID IN (%Ls)', $this->importAuthorPHIDs); } if ($this->importUIDs !== null) { $where[] = qsprintf( $conn, 'event.importUID IN (%Ls)', $this->importUIDs); } return $where; } protected function getPrimaryTableAlias() { return 'event'; } protected function shouldGroupQueryResultRows() { if ($this->inviteePHIDs !== null) { return true; } return parent::shouldGroupQueryResultRows(); } protected function getApplicationSearchObjectPHIDColumn() { return 'event.phid'; } public function getQueryApplicationClass() { return 'PhabricatorCalendarApplication'; } protected function willFilterPage(array $events) { $instance_of_event_phids = array(); $recurring_events = array(); $viewer = $this->getViewer(); $events = $this->getEventsInRange($events); $import_phids = array(); foreach ($events as $event) { $import_phid = $event->getImportSourcePHID(); if ($import_phid !== null) { $import_phids[$import_phid] = $import_phid; } } if ($import_phids) { $imports = id(new PhabricatorCalendarImportQuery()) ->setParentQuery($this) ->setViewer($viewer) ->withPHIDs($import_phids) ->execute(); $imports = mpull($imports, null, 'getPHID'); } else { $imports = array(); } foreach ($events as $key => $event) { $import_phid = $event->getImportSourcePHID(); if ($import_phid === null) { $event->attachImportSource(null); continue; } $import = idx($imports, $import_phid); if (!$import) { unset($events[$key]); $this->didRejectResult($event); continue; } $event->attachImportSource($import); } $phids = array(); foreach ($events as $event) { $phids[] = $event->getPHID(); $instance_of = $event->getInstanceOfEventPHID(); if ($instance_of) { $instance_of_event_phids[] = $instance_of; } } if (count($instance_of_event_phids) > 0) { $recurring_events = id(new PhabricatorCalendarEventQuery()) ->setViewer($viewer) ->withPHIDs($instance_of_event_phids) ->withEventsWithNoParent(true) ->execute(); $recurring_events = mpull($recurring_events, null, 'getPHID'); } if ($events) { $invitees = id(new PhabricatorCalendarEventInviteeQuery()) ->setViewer($viewer) ->withEventPHIDs($phids) ->execute(); $invitees = mgroup($invitees, 'getEventPHID'); } else { $invitees = array(); } foreach ($events as $key => $event) { $event_invitees = idx($invitees, $event->getPHID(), array()); $event->attachInvitees($event_invitees); $instance_of = $event->getInstanceOfEventPHID(); if (!$instance_of) { continue; } $parent = idx($recurring_events, $instance_of); // should never get here if (!$parent) { unset($events[$key]); continue; } $event->attachParentEvent($parent); if ($this->isCancelled !== null) { if ($event->getIsCancelled() != $this->isCancelled) { unset($events[$key]); continue; } } } $events = msort($events, 'getStartDateTimeEpoch'); return $events; } private function getEventsInRange(array $events) { $range_start = $this->rangeBegin; $range_end = $this->rangeEnd; foreach ($events as $key => $event) { $event_start = $event->getStartDateTimeEpoch(); $event_end = $event->getEndDateTimeEpoch(); if ($range_start && $event_end < $range_start) { unset($events[$key]); } if ($range_end && $event_start > $range_end) { unset($events[$key]); } } return $events; } private function getRecurrenceWindowStart( PhabricatorCalendarEvent $event, $generate_from) { if (!$generate_from) { return null; } return PhutilCalendarAbsoluteDateTime::newFromEpoch($generate_from); } private function getRecurrenceWindowEnd( PhabricatorCalendarEvent $event, $generate_until) { $end_epochs = array(); if ($generate_until) { $end_epochs[] = $generate_until; } $until_epoch = $event->getUntilDateTimeEpoch(); if ($until_epoch) { $end_epochs[] = $until_epoch; } if (!$end_epochs) { return null; } return PhutilCalendarAbsoluteDateTime::newFromEpoch(min($end_epochs)); } private function getRecurrenceLimit( PhabricatorCalendarEvent $event, $raw_limit) { + $count = $event->getRecurrenceCount(); + if ($count && ($count <= $raw_limit)) { + return ($count - 1); + } + return $raw_limit; } } diff --git a/src/applications/calendar/storage/PhabricatorCalendarEvent.php b/src/applications/calendar/storage/PhabricatorCalendarEvent.php index 64bdf07d46..37b45a2a3f 100644 --- a/src/applications/calendar/storage/PhabricatorCalendarEvent.php +++ b/src/applications/calendar/storage/PhabricatorCalendarEvent.php @@ -1,1206 +1,1227 @@ setViewer($actor) ->withClasses(array('PhabricatorCalendarApplication')) ->executeOne(); $view_default = PhabricatorCalendarEventDefaultViewCapability::CAPABILITY; $edit_default = PhabricatorCalendarEventDefaultEditCapability::CAPABILITY; $view_policy = $app->getPolicy($view_default); $edit_policy = $app->getPolicy($edit_default); $now = PhabricatorTime::getNow(); $default_icon = 'fa-calendar'; $datetime_start = PhutilCalendarAbsoluteDateTime::newFromEpoch( $now, $actor->getTimezoneIdentifier()); $datetime_end = $datetime_start->newRelativeDateTime('PT1H'); return id(new PhabricatorCalendarEvent()) ->setDescription('') ->setHostPHID($actor->getPHID()) ->setIsCancelled(0) ->setIsAllDay(0) ->setIsStub(0) ->setIsRecurring(0) ->setIcon($default_icon) ->setViewPolicy($view_policy) ->setEditPolicy($edit_policy) ->setSpacePHID($actor->getDefaultSpacePHID()) ->attachInvitees(array()) ->setDateFrom(0) ->setDateTo(0) ->setAllDayDateFrom(0) ->setAllDayDateTo(0) ->setStartDateTime($datetime_start) ->setEndDateTime($datetime_end) ->attachImportSource(null) ->applyViewerTimezone($actor); } private function newChild( PhabricatorUser $actor, $sequence, PhutilCalendarDateTime $start = null) { if (!$this->isParentEvent()) { throw new Exception( pht( 'Unable to generate a new child event for an event which is not '. 'a recurring parent event!')); } $child = id(new self()) ->setIsCancelled(0) ->setIsStub(0) ->setInstanceOfEventPHID($this->getPHID()) ->setSequenceIndex($sequence) ->setIsRecurring(true) ->attachParentEvent($this) ->setAllDayDateFrom(0) ->setAllDayDateTo(0) ->setDateFrom(0) ->setDateTo(0); return $child->copyFromParent($actor, $start); } protected function readField($field) { static $inherit = array( 'hostPHID' => true, 'isAllDay' => true, 'icon' => true, 'spacePHID' => true, 'viewPolicy' => true, 'editPolicy' => true, 'name' => true, 'description' => true, ); // Read these fields from the parent event instead of this event. For // example, we want any changes to the parent event's name to apply to // the child. if (isset($inherit[$field])) { if ($this->getIsStub()) { // TODO: This should be unconditional, but the execution order of // CalendarEventQuery and applyViewerTimezone() are currently odd. if ($this->parentEvent !== self::ATTACHABLE) { return $this->getParentEvent()->readField($field); } } } return parent::readField($field); } public function copyFromParent( PhabricatorUser $actor, PhutilCalendarDateTime $start = null) { if (!$this->isChildEvent()) { throw new Exception( pht( 'Unable to copy from parent event: this is not a child event.')); } $parent = $this->getParentEvent(); $this ->setHostPHID($parent->getHostPHID()) ->setIsAllDay($parent->getIsAllDay()) ->setIcon($parent->getIcon()) ->setSpacePHID($parent->getSpacePHID()) ->setViewPolicy($parent->getViewPolicy()) ->setEditPolicy($parent->getEditPolicy()) ->setName($parent->getName()) ->setDescription($parent->getDescription()); if ($start) { $start_datetime = $start; } else { $sequence = $this->getSequenceIndex(); $start_datetime = $parent->newSequenceIndexDateTime($sequence); if (!$start_datetime) { throw new Exception( pht( 'Sequence "%s" is not valid for event!', $sequence)); } } $duration = $parent->newDuration(); $end_datetime = $start_datetime->newRelativeDateTime($duration); $this ->setStartDateTime($start_datetime) ->setEndDateTime($end_datetime); if ($parent->isImportedEvent()) { $full_uid = $parent->getImportUID().'/'.$start_datetime->getEpoch(); // NOTE: We don't attach the import source because this gets called // from CalendarEventQuery while building ghosts, before we've loaded // and attached sources. Possibly this sequence should be flipped. $this ->setImportAuthorPHID($parent->getImportAuthorPHID()) ->setImportSourcePHID($parent->getImportSourcePHID()) ->setImportUID($full_uid); } return $this; } public function isValidSequenceIndex(PhabricatorUser $viewer, $sequence) { return (bool)$this->newSequenceIndexDateTime($sequence); } public function newSequenceIndexDateTime($sequence) { $set = $this->newRecurrenceSet(); if (!$set) { return null; } + $limit = $sequence + 1; + $count = $this->getRecurrenceCount(); + if ($count && ($count < $limit)) { + return null; + } + $instances = $set->getEventsBetween( null, $this->newUntilDateTime(), - $sequence + 1); + $limit); return idx($instances, $sequence, null); } public function newStub(PhabricatorUser $actor, $sequence) { $stub = $this->newChild($actor, $sequence); $stub->setIsStub(1); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $stub->save(); unset($unguarded); $stub->applyViewerTimezone($actor); return $stub; } public function newGhost( PhabricatorUser $actor, $sequence, PhutilCalendarDateTime $start = null) { $ghost = $this->newChild($actor, $sequence, $start); $ghost ->setIsGhostEvent(true) ->makeEphemeral(); $ghost->applyViewerTimezone($actor); return $ghost; } public function applyViewerTimezone(PhabricatorUser $viewer) { $this->viewerTimezone = $viewer->getTimezoneIdentifier(); return $this; } public function getDuration() { return ($this->getEndDateTimeEpoch() - $this->getStartDateTimeEpoch()); } public function updateUTCEpochs() { // The "intitial" epoch is the start time of the event, in UTC. $start_date = $this->newStartDateTime() ->setViewerTimezone('UTC'); $start_epoch = $start_date->getEpoch(); $this->setUTCInitialEpoch($start_epoch); // The "until" epoch is the last UTC epoch on which any instance of this // event occurs. For infinitely recurring events, it is `null`. if (!$this->getIsRecurring()) { $end_date = $this->newEndDateTime() ->setViewerTimezone('UTC'); $until_epoch = $end_date->getEpoch(); } else { $until_epoch = null; $until_date = $this->newUntilDateTime(); if ($until_date) { $until_date->setViewerTimezone('UTC'); $duration = $this->newDuration(); $until_epoch = id(new PhutilCalendarRelativeDateTime()) ->setOrigin($until_date) ->setDuration($duration) ->getEpoch(); } } $this->setUTCUntilEpoch($until_epoch); // The "instance" epoch is a property of instances of recurring events. // It's the original UTC epoch on which the instance started. Usually that // is the same as the start date, but they may be different if the instance // has been edited. // The ICS format uses this value (original start time) to identify event // instances, and must do so because it allows additional arbitrary // instances to be added (with "RDATE"). $instance_epoch = null; $instance_date = $this->newInstanceDateTime(); if ($instance_date) { $instance_epoch = $instance_date ->setViewerTimezone('UTC') ->getEpoch(); } $this->setUTCInstanceEpoch($instance_epoch); return $this; } public function save() { if (!$this->mailKey) { $this->mailKey = Filesystem::readRandomCharacters(20); } $import_uid = $this->getImportUID(); if ($import_uid !== null) { $index = PhabricatorHash::digestForIndex($import_uid); } else { $index = null; } $this->setImportUIDIndex($index); $this->updateUTCEpochs(); return parent::save(); } /** * Get the event start epoch for evaluating invitee availability. * * When assessing availability, we pretend events start earlier than they * really do. This allows us to mark users away for the entire duration of a * series of back-to-back meetings, even if they don't strictly overlap. * * @return int Event start date for availability caches. */ public function getStartDateTimeEpochForCache() { $epoch = $this->getStartDateTimeEpoch(); $window = phutil_units('15 minutes in seconds'); return ($epoch - $window); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'text', 'description' => 'text', 'isCancelled' => 'bool', 'isAllDay' => 'bool', 'icon' => 'text32', 'mailKey' => 'bytes20', 'isRecurring' => 'bool', 'instanceOfEventPHID' => 'phid?', 'sequenceIndex' => 'uint32?', 'isStub' => 'bool', 'utcInitialEpoch' => 'epoch', 'utcUntilEpoch' => 'epoch?', 'utcInstanceEpoch' => 'epoch?', 'importAuthorPHID' => 'phid?', 'importSourcePHID' => 'phid?', 'importUIDIndex' => 'bytes12?', 'importUID' => 'text?', // TODO: DEPRECATED. 'allDayDateFrom' => 'epoch', 'allDayDateTo' => 'epoch', 'dateFrom' => 'epoch', 'dateTo' => 'epoch', 'recurrenceEndDate' => 'epoch?', ), self::CONFIG_KEY_SCHEMA => array( 'key_date' => array( 'columns' => array('dateFrom', 'dateTo'), ), 'key_instance' => array( 'columns' => array('instanceOfEventPHID', 'sequenceIndex'), 'unique' => true, ), 'key_epoch' => array( 'columns' => array('utcInitialEpoch', 'utcUntilEpoch'), ), 'key_rdate' => array( 'columns' => array('instanceOfEventPHID', 'utcInstanceEpoch'), 'unique' => true, ), ), self::CONFIG_SERIALIZATION => array( 'recurrenceFrequency' => self::SERIALIZATION_JSON, 'parameters' => self::SERIALIZATION_JSON, ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorCalendarEventPHIDType::TYPECONST); } public function getMonogram() { return 'E'.$this->getID(); } public function getInvitees() { return $this->assertAttached($this->invitees); } public function attachInvitees(array $invitees) { $this->invitees = $invitees; return $this; } public function getInviteePHIDsForEdit() { $invitees = array(); foreach ($this->getInvitees() as $invitee) { if ($invitee->isUninvited()) { continue; } $invitees[] = $invitee->getInviteePHID(); } return $invitees; } public function getUserInviteStatus($phid) { $invitees = $this->getInvitees(); $invitees = mpull($invitees, null, 'getInviteePHID'); $invited = idx($invitees, $phid); if (!$invited) { return PhabricatorCalendarEventInvitee::STATUS_UNINVITED; } $invited = $invited->getStatus(); return $invited; } public function getIsUserAttending($phid) { $attending_status = PhabricatorCalendarEventInvitee::STATUS_ATTENDING; $old_status = $this->getUserInviteStatus($phid); $is_attending = ($old_status == $attending_status); return $is_attending; } public function getIsUserInvited($phid) { $uninvited_status = PhabricatorCalendarEventInvitee::STATUS_UNINVITED; $declined_status = PhabricatorCalendarEventInvitee::STATUS_DECLINED; $status = $this->getUserInviteStatus($phid); if ($status == $uninvited_status || $status == $declined_status) { return false; } return true; } public function getIsGhostEvent() { return $this->isGhostEvent; } public function setIsGhostEvent($is_ghost_event) { $this->isGhostEvent = $is_ghost_event; return $this; } public function getURI() { if ($this->getIsGhostEvent()) { $base = $this->getParentEvent()->getURI(); $sequence = $this->getSequenceIndex(); return "{$base}/{$sequence}/"; } return '/'.$this->getMonogram(); } public function getParentEvent() { return $this->assertAttached($this->parentEvent); } public function attachParentEvent($event) { $this->parentEvent = $event; return $this; } public function isParentEvent() { return ($this->getIsRecurring() && !$this->getInstanceOfEventPHID()); } public function isChildEvent() { return ($this->instanceOfEventPHID !== null); } public function isCancelledEvent() { if ($this->getIsCancelled()) { return true; } if ($this->isChildEvent()) { if ($this->getParentEvent()->getIsCancelled()) { return true; } } return false; } public function renderEventDate( PhabricatorUser $viewer, $show_end) { $start = $this->newStartDateTime(); $end = $this->newEndDateTime(); if ($show_end) { $min_date = $start->newPHPDateTime(); $max_date = $end->newPHPDateTime(); $min_day = $min_date->format('Y m d'); $max_day = $max_date->format('Y m d'); $show_end_date = ($min_day != $max_day); } else { $show_end_date = false; } $min_epoch = $start->getEpoch(); $max_epoch = $end->getEpoch(); if ($this->getIsAllDay()) { if ($show_end_date) { return pht( '%s - %s, All Day', phabricator_date($min_epoch, $viewer), phabricator_date($max_epoch, $viewer)); } else { return pht( '%s, All Day', phabricator_date($min_epoch, $viewer)); } } else if ($show_end_date) { return pht( '%s - %s', phabricator_datetime($min_epoch, $viewer), phabricator_datetime($max_epoch, $viewer)); } else if ($show_end) { return pht( '%s - %s', phabricator_datetime($min_epoch, $viewer), phabricator_time($max_epoch, $viewer)); } else { return pht( '%s', phabricator_datetime($min_epoch, $viewer)); } } public function getDisplayIcon(PhabricatorUser $viewer) { if ($this->isCancelledEvent()) { return 'fa-times'; } if ($viewer->isLoggedIn()) { $status = $this->getUserInviteStatus($viewer->getPHID()); switch ($status) { case PhabricatorCalendarEventInvitee::STATUS_ATTENDING: return 'fa-check-circle'; case PhabricatorCalendarEventInvitee::STATUS_INVITED: return 'fa-user-plus'; case PhabricatorCalendarEventInvitee::STATUS_DECLINED: return 'fa-times'; } } if ($this->isImportedEvent()) { return 'fa-download'; } return $this->getIcon(); } public function getDisplayIconColor(PhabricatorUser $viewer) { if ($this->isCancelledEvent()) { return 'red'; } if ($this->isImportedEvent()) { return 'orange'; } if ($viewer->isLoggedIn()) { $status = $this->getUserInviteStatus($viewer->getPHID()); switch ($status) { case PhabricatorCalendarEventInvitee::STATUS_ATTENDING: return 'green'; case PhabricatorCalendarEventInvitee::STATUS_INVITED: return 'green'; case PhabricatorCalendarEventInvitee::STATUS_DECLINED: return 'grey'; } } return 'bluegrey'; } public function getDisplayIconLabel(PhabricatorUser $viewer) { if ($this->isCancelledEvent()) { return pht('Cancelled'); } if ($viewer->isLoggedIn()) { $status = $this->getUserInviteStatus($viewer->getPHID()); switch ($status) { case PhabricatorCalendarEventInvitee::STATUS_ATTENDING: return pht('Attending'); case PhabricatorCalendarEventInvitee::STATUS_INVITED: return pht('Invited'); case PhabricatorCalendarEventInvitee::STATUS_DECLINED: return pht('Declined'); } } return null; } public function getICSFilename() { return $this->getMonogram().'.ics'; } public function newIntermediateEventNode( PhabricatorUser $viewer, array $children) { $base_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/')); $domain = $base_uri->getDomain(); // NOTE: For recurring events, all of the events in the series have the // same UID (the UID of the parent). The child event instances are // differentiated by the "RECURRENCE-ID" field. if ($this->isChildEvent()) { $parent = $this->getParentEvent(); $instance_datetime = PhutilCalendarAbsoluteDateTime::newFromEpoch( $this->getUTCInstanceEpoch()); $recurrence_id = $instance_datetime->getISO8601(); $rrule = null; } else { $parent = $this; $recurrence_id = null; $rrule = $this->newRecurrenceRule(); } $uid = $parent->getPHID().'@'.$domain; $created = $this->getDateCreated(); $created = PhutilCalendarAbsoluteDateTime::newFromEpoch($created); $modified = $this->getDateModified(); $modified = PhutilCalendarAbsoluteDateTime::newFromEpoch($modified); $date_start = $this->newStartDateTime(); $date_end = $this->newEndDateTime(); if ($this->getIsAllDay()) { $date_start->setIsAllDay(true); $date_end->setIsAllDay(true); } $host_phid = $this->getHostPHID(); $invitees = $this->getInvitees(); foreach ($invitees as $key => $invitee) { if ($invitee->isUninvited()) { unset($invitees[$key]); } } $phids = array(); $phids[] = $host_phid; foreach ($invitees as $invitee) { $phids[] = $invitee->getInviteePHID(); } $handles = $viewer->loadHandles($phids); $host_handle = $handles[$host_phid]; $host_name = $host_handle->getFullName(); $host_uri = $host_handle->getURI(); $host_uri = PhabricatorEnv::getURI($host_uri); $organizer = id(new PhutilCalendarUserNode()) ->setName($host_name) ->setURI($host_uri); $attendees = array(); foreach ($invitees as $invitee) { $invitee_phid = $invitee->getInviteePHID(); $invitee_handle = $handles[$invitee_phid]; $invitee_name = $invitee_handle->getFullName(); $invitee_uri = $invitee_handle->getURI(); $invitee_uri = PhabricatorEnv::getURI($invitee_uri); switch ($invitee->getStatus()) { case PhabricatorCalendarEventInvitee::STATUS_ATTENDING: $status = PhutilCalendarUserNode::STATUS_ACCEPTED; break; case PhabricatorCalendarEventInvitee::STATUS_DECLINED: $status = PhutilCalendarUserNode::STATUS_DECLINED; break; case PhabricatorCalendarEventInvitee::STATUS_INVITED: default: $status = PhutilCalendarUserNode::STATUS_INVITED; break; } $attendees[] = id(new PhutilCalendarUserNode()) ->setName($invitee_name) ->setURI($invitee_uri) ->setStatus($status); } // TODO: Use $children to generate EXDATE/RDATE information. $node = id(new PhutilCalendarEventNode()) ->setUID($uid) ->setName($this->getName()) ->setDescription($this->getDescription()) ->setCreatedDateTime($created) ->setModifiedDateTime($modified) ->setStartDateTime($date_start) ->setEndDateTime($date_end) ->setOrganizer($organizer) ->setAttendees($attendees); if ($rrule) { $node->setRecurrenceRule($rrule); } if ($recurrence_id) { $node->setRecurrenceID($recurrence_id); } return $node; } public function newStartDateTime() { $datetime = $this->getParameter('startDateTime'); if ($datetime) { return $this->newDateTimeFromDictionary($datetime); } $epoch = $this->getDateFrom(); return $this->newDateTimeFromEpoch($epoch); } public function getStartDateTimeEpoch() { return $this->newStartDateTime()->getEpoch(); } public function newEndDateTime() { $datetime = $this->getParameter('endDateTime'); if ($datetime) { return $this->newDateTimeFromDictionary($datetime); } $epoch = $this->getDateTo(); return $this->newDateTimeFromEpoch($epoch); } public function getEndDateTimeEpoch() { return $this->newEndDateTime()->getEpoch(); } public function newUntilDateTime() { $datetime = $this->getParameter('untilDateTime'); if ($datetime) { return $this->newDateTimeFromDictionary($datetime); } $epoch = $this->getRecurrenceEndDate(); if (!$epoch) { return null; } return $this->newDateTimeFromEpoch($epoch); } public function getUntilDateTimeEpoch() { $datetime = $this->newUntilDateTime(); if (!$datetime) { return null; } return $datetime->getEpoch(); } public function newDuration() { return id(new PhutilCalendarDuration()) ->setSeconds($this->getDuration()); } public function newInstanceDateTime() { if (!$this->getIsRecurring()) { return null; } $index = $this->getSequenceIndex(); if (!$index) { return null; } return $this->newSequenceIndexDateTime($index); } private function newDateTimeFromEpoch($epoch) { $datetime = PhutilCalendarAbsoluteDateTime::newFromEpoch($epoch); if ($this->getIsAllDay()) { $datetime->setIsAllDay(true); } return $this->newDateTimeFromDateTime($datetime); } private function newDateTimeFromDictionary(array $dict) { $datetime = PhutilCalendarAbsoluteDateTime::newFromDictionary($dict); return $this->newDateTimeFromDateTime($datetime); } private function newDateTimeFromDateTime(PhutilCalendarDateTime $datetime) { $viewer_timezone = $this->viewerTimezone; if ($viewer_timezone) { $datetime->setViewerTimezone($viewer_timezone); } return $datetime; } public function getParameter($key, $default = null) { return idx($this->parameters, $key, $default); } public function setParameter($key, $value) { $this->parameters[$key] = $value; return $this; } public function setStartDateTime(PhutilCalendarDateTime $datetime) { return $this->setParameter( 'startDateTime', $datetime->newAbsoluteDateTime()->toDictionary()); } public function setEndDateTime(PhutilCalendarDateTime $datetime) { return $this->setParameter( 'endDateTime', $datetime->newAbsoluteDateTime()->toDictionary()); } public function setUntilDateTime(PhutilCalendarDateTime $datetime) { return $this->setParameter( 'untilDateTime', $datetime->newAbsoluteDateTime()->toDictionary()); } public function setRecurrenceRule(PhutilCalendarRecurrenceRule $rrule) { return $this->setParameter( 'recurrenceRule', $rrule->toDictionary()); } public function newRecurrenceRule() { if ($this->isChildEvent()) { return $this->getParentEvent()->newRecurrenceRule(); } if (!$this->getIsRecurring()) { return null; } $dict = $this->getParameter('recurrenceRule'); if (!$dict) { return null; } $rrule = PhutilCalendarRecurrenceRule::newFromDictionary($dict); $start = $this->newStartDateTime(); $rrule->setStartDateTime($start); $until = $this->newUntilDateTime(); if ($until) { $rrule->setUntil($until); } + $count = $this->getRecurrenceCount(); + if ($count) { + $rrule->setCount($count); + } + return $rrule; } + public function getRecurrenceCount() { + $count = (int)$this->getParameter('recurrenceCount'); + + if (!$count) { + return null; + } + + return $count; + } + public function newRecurrenceSet() { if ($this->isChildEvent()) { return $this->getParentEvent()->newRecurrenceSet(); } $set = new PhutilCalendarRecurrenceSet(); $rrule = $this->newRecurrenceRule(); if (!$rrule) { return null; } $set->addSource($rrule); return $set; } public function isImportedEvent() { return (bool)$this->getImportSourcePHID(); } public function getImportSource() { return $this->assertAttached($this->importSource); } public function attachImportSource( PhabricatorCalendarImport $import = null) { $this->importSource = $import; return $this; } /* -( Markup Interface )--------------------------------------------------- */ /** * @task markup */ public function getMarkupFieldKey($field) { $hash = PhabricatorHash::digest($this->getMarkupText($field)); $id = $this->getID(); return "calendar:T{$id}:{$field}:{$hash}"; } /** * @task markup */ public function getMarkupText($field) { return $this->getDescription(); } /** * @task markup */ public function newMarkupEngine($field) { return PhabricatorMarkupEngine::newCalendarMarkupEngine(); } /** * @task markup */ public function didMarkupText( $field, $output, PhutilMarkupEngine $engine) { return $output; } /** * @task markup */ public function shouldUseMarkupCache($field) { return (bool)$this->getID(); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: if ($this->getImportSource()) { return PhabricatorPolicies::POLICY_NOONE; } else { return $this->getEditPolicy(); } } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { if ($this->getImportSource()) { return false; } // The host of an event can always view and edit it. $user_phid = $this->getHostPHID(); if ($user_phid) { $viewer_phid = $viewer->getPHID(); if ($viewer_phid == $user_phid) { return true; } } if ($capability == PhabricatorPolicyCapability::CAN_VIEW) { $status = $this->getUserInviteStatus($viewer->getPHID()); if ($status == PhabricatorCalendarEventInvitee::STATUS_INVITED || $status == PhabricatorCalendarEventInvitee::STATUS_ATTENDING || $status == PhabricatorCalendarEventInvitee::STATUS_DECLINED) { return true; } } return false; } public function describeAutomaticCapability($capability) { if ($this->getImportSource()) { return pht( 'Events imported from external sources can not be edited in '. 'Phabricator.'); } return pht( 'The host of an event can always view and edit it. Users who are '. 'invited to an event can always view it.'); } /* -( PhabricatorExtendedPolicyInterface )--------------------------------- */ public function getExtendedPolicy($capability, PhabricatorUser $viewer) { $extended = array(); switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: $import_source = $this->getImportSource(); if ($import_source) { $extended[] = array( $import_source, PhabricatorPolicyCapability::CAN_VIEW, ); } break; } return $extended; } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorCalendarEventEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhabricatorCalendarEventTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } /* -( PhabricatorSubscribableInterface )----------------------------------- */ public function isAutomaticallySubscribed($phid) { return ($phid == $this->getHostPHID()); } /* -( PhabricatorTokenReceiverInterface )---------------------------------- */ public function getUsersToNotifyOfTokenGiven() { return array($this->getHostPHID()); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $this->delete(); $this->saveTransaction(); } /* -( PhabricatorSpacesInterface )----------------------------------------- */ public function getSpacePHID() { return $this->spacePHID; } /* -( PhabricatorFulltextInterface )--------------------------------------- */ public function newFulltextEngine() { return new PhabricatorCalendarEventFulltextEngine(); } /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { return array( id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('name') ->setType('string') ->setDescription(pht('The name of the event.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('description') ->setType('string') ->setDescription(pht('The event description.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('isAllDay') ->setType('bool') ->setDescription(pht('True if the event is an all day event.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('startDateTime') ->setType('datetime') ->setDescription(pht('Start date and time of the event.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('endDateTime') ->setType('datetime') ->setDescription(pht('End date and time of the event.')), ); } public function getFieldValuesForConduit() { $start_datetime = $this->newStartDateTime(); $end_datetime = $this->newEndDateTime(); return array( 'name' => $this->getName(), 'description' => $this->getDescription(), 'isAllDay' => (bool)$this->getIsAllDay(), 'startDateTime' => $this->getConduitDateTime($start_datetime), 'endDateTime' => $this->getConduitDateTime($end_datetime), ); } public function getConduitSearchAttachments() { return array(); } private function getConduitDateTime($datetime) { if (!$datetime) { return null; } $epoch = $datetime->getEpoch(); // TODO: Possibly pass the actual viewer in from the Conduit stuff, or // retain it when setting the viewer timezone? $viewer = id(new PhabricatorUser()) ->overrideTimezoneIdentifier($this->viewerTimezone); return array( 'epoch' => (int)$epoch, 'display' => array( 'default' => phabricator_datetime($epoch, $viewer), ), 'iso8601' => $datetime->getISO8601(), 'timezone' => $this->viewerTimezone, ); } }