diff --git a/src/applications/calendar/import/PhabricatorCalendarImportEngine.php b/src/applications/calendar/import/PhabricatorCalendarImportEngine.php index 96069e0ee1..bf2200b981 100644 --- a/src/applications/calendar/import/PhabricatorCalendarImportEngine.php +++ b/src/applications/calendar/import/PhabricatorCalendarImportEngine.php @@ -1,563 +1,571 @@ 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(); } 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($viewer->getPHID())) ->withImportUIDs(array_keys($node_map)) ->execute(); $events = mpull($events, null, 'getImportUID'); } else { $events = null; } $xactions = array(); $update_map = array(); $invitee_map = array(); $attendee_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; + $attendee_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 (preg_match('/@/', $name)) { $name = new PhutilEmailAddress($name); $name = $name->getDisplayName(); } // 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 (!strlen($name) || preg_match('/@/', $name)) { $name = pht('Private User %d', $private_index); $private_index++; } $attendee_map[$full_uid][$name] = $attendee; } } $attendee_names = array(); foreach ($attendee_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($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]); 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); // 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]; $old_map = $event->getInvitees(); $old_map = mpull($old_map, null, 'getInviteePHID'); $new_map = array(); foreach ($attendees as $name => $attendee) { $phid = $external_invitees[$name]->getPHID(); $invitee = idx($old_map, $phid); if (!$invitee) { $invitee = id(new PhabricatorCalendarEventInvitee()) ->setEventPHID($event->getPHID()) ->setInviteePHID($phid) ->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; break; } $invitee->setStatus($status); $invitee->save(); $new_map[$phid] = $invitee; } 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 (!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(); 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($start_datetime->getIsAllDay()); + $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) { - $any_event = id(new PhabricatorCalendarEventQuery()) - ->setViewer($viewer) - ->withImportSourcePHIDs(array($import->getPHID())) - ->setLimit(1) - ->execute(); + $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; } } diff --git a/src/applications/calendar/notifications/PhabricatorCalendarNotificationEngine.php b/src/applications/calendar/notifications/PhabricatorCalendarNotificationEngine.php index 68d4f37a2c..b0a762b577 100644 --- a/src/applications/calendar/notifications/PhabricatorCalendarNotificationEngine.php +++ b/src/applications/calendar/notifications/PhabricatorCalendarNotificationEngine.php @@ -1,303 +1,304 @@ cursor) { $now = PhabricatorTime::getNow(); $this->cursor = $now - phutil_units('10 minutes in seconds'); } return $this->cursor; } public function setCursor($cursor) { $this->cursor = $cursor; return $this; } public function setNotifyWindow($notify_window) { $this->notifyWindow = $notify_window; return $this; } public function getNotifyWindow() { if (!$this->notifyWindow) { return phutil_units('15 minutes in seconds'); } return $this->notifyWindow; } public function publishNotifications() { $cursor = $this->getCursor(); $now = PhabricatorTime::getNow(); if ($cursor > $now) { return; } $calendar_class = 'PhabricatorCalendarApplication'; if (!PhabricatorApplication::isClassInstalled($calendar_class)) { return; } try { $lock = PhabricatorGlobalLock::newLock('calendar.notify') ->lock(5); } catch (PhutilLockException $ex) { return; } $caught = null; try { $this->sendNotifications(); } catch (Exception $ex) { $caught = $ex; } $lock->unlock(); // Wait a little while before checking for new notifications to send. $this->setCursor($cursor + phutil_units('1 minute in seconds')); if ($caught) { throw $caught; } } private function sendNotifications() { $cursor = $this->getCursor(); $window_min = $cursor - phutil_units('16 hours in seconds'); $window_max = $cursor + phutil_units('16 hours in seconds'); $viewer = PhabricatorUser::getOmnipotentUser(); $events = id(new PhabricatorCalendarEventQuery()) ->setViewer($viewer) ->withDateRange($window_min, $window_max) ->withIsCancelled(false) ->withIsImported(false) ->setGenerateGhosts(true) ->execute(); if (!$events) { // No events are starting soon in any timezone, so there is nothing // left to be done. return; } $attendee_map = array(); foreach ($events as $key => $event) { $notifiable_phids = array(); foreach ($event->getInvitees() as $invitee) { if (!$invitee->isAttending()) { continue; } $notifiable_phids[] = $invitee->getInviteePHID(); } if (!$notifiable_phids) { unset($events[$key]); } $attendee_map[$key] = array_fuse($notifiable_phids); } if (!$attendee_map) { // None of the events have any notifiable attendees, so there is no // one to notify of anything. return; } $all_attendees = array(); foreach ($attendee_map as $key => $attendee_phids) { foreach ($attendee_phids as $attendee_phid) { $all_attendees[$attendee_phid] = $attendee_phid; } } $user_map = id(new PhabricatorPeopleQuery()) ->setViewer($viewer) ->withPHIDs($all_attendees) ->withIsDisabled(false) ->needUserSettings(true) ->execute(); $user_map = mpull($user_map, null, 'getPHID'); if (!$user_map) { // None of the attendees are valid users: they're all imported users // or projects or invalid or some other kind of unnotifiable entity. return; } $all_event_phids = array(); foreach ($events as $key => $event) { foreach ($event->getNotificationPHIDs() as $phid) { $all_event_phids[$phid] = $phid; } } $table = new PhabricatorCalendarNotification(); $conn = $table->establishConnection('w'); $rows = queryfx_all( $conn, 'SELECT * FROM %T WHERE eventPHID IN (%Ls) AND targetPHID IN (%Ls)', $table->getTableName(), $all_event_phids, $all_attendees); $sent_map = array(); foreach ($rows as $row) { $event_phid = $row['eventPHID']; $target_phid = $row['targetPHID']; $initial_epoch = $row['utcInitialEpoch']; $sent_map[$event_phid][$target_phid][$initial_epoch] = $row; } - $notify_min = $cursor; - $notify_max = $cursor + $this->getNotifyWindow(); + $now = PhabricatorTime::getNow(); + $notify_min = $now; + $notify_max = $now + $this->getNotifyWindow(); $notify_map = array(); foreach ($events as $key => $event) { $initial_epoch = $event->getUTCInitialEpoch(); $event_phids = $event->getNotificationPHIDs(); // Select attendees who actually exist, and who we have not sent any // notifications to yet. $attendee_phids = $attendee_map[$key]; $users = array_select_keys($user_map, $attendee_phids); foreach ($users as $user_phid => $user) { foreach ($event_phids as $event_phid) { if (isset($sent_map[$event_phid][$user_phid][$initial_epoch])) { unset($users[$user_phid]); continue 2; } } } if (!$users) { continue; } // Discard attendees for whom the event start time isn't soon. Events // may start at different times for different users, so we need to // check every user's start time. foreach ($users as $user_phid => $user) { $user_datetime = $event->newStartDateTime() ->setViewerTimezone($user->getTimezoneIdentifier()); $user_epoch = $user_datetime->getEpoch(); if ($user_epoch < $notify_min || $user_epoch > $notify_max) { unset($users[$user_phid]); continue; } $view = id(new PhabricatorCalendarEventNotificationView()) ->setViewer($user) ->setEvent($event) ->setDateTime($user_datetime) ->setEpoch($user_epoch); $notify_map[$user_phid][] = $view; } } $mail_list = array(); $mark_list = array(); $now = PhabricatorTime::getNow(); foreach ($notify_map as $user_phid => $events) { $user = $user_map[$user_phid]; $locale = PhabricatorEnv::beginScopedLocale($user->getTranslation()); $caught = null; try { $mail_list[] = $this->newMailMessage($user, $events); } catch (Exception $ex) { $caught = $ex; } unset($locale); if ($caught) { throw $ex; } foreach ($events as $view) { $event = $view->getEvent(); foreach ($event->getNotificationPHIDs() as $phid) { $mark_list[] = qsprintf( $conn, '(%s, %s, %d, %d)', $phid, $user_phid, $event->getUTCInitialEpoch(), $now); } } } // Mark all the notifications we're about to send as delivered so we // do not double-notify. foreach (PhabricatorLiskDAO::chunkSQL($mark_list) as $chunk) { queryfx( $conn, 'INSERT IGNORE INTO %T (eventPHID, targetPHID, utcInitialEpoch, didNotifyEpoch) VALUES %Q', $table->getTableName(), $chunk); } foreach ($mail_list as $mail) { $mail->saveAndSend(); } } private function newMailMessage(PhabricatorUser $viewer, array $events) { $events = msort($events, 'getEpoch'); $next_event = head($events); $body = new PhabricatorMetaMTAMailBody(); foreach ($events as $event) { $body->addTextSection( null, pht( '%s is starting in %s minute(s), at %s.', $event->getEvent()->getName(), $event->getDisplayMinutes(), $event->getDisplayTime())); $body->addLinkSection( pht('EVENT DETAIL'), PhabricatorEnv::getProductionURI($event->getEvent()->getURI())); } $next_event = head($events)->getEvent(); $subject = $next_event->getName(); if (count($events) > 1) { $more = pht( '(+%s more...)', new PhutilNumber(count($events) - 1)); $subject = "{$subject} {$more}"; } $calendar_phid = id(new PhabricatorCalendarApplication()) ->getPHID(); return id(new PhabricatorMetaMTAMail()) ->setSubject($subject) ->addTos(array($viewer->getPHID())) ->setSensitiveContent(false) ->setFrom($calendar_phid) ->setIsBulk(true) ->setSubjectPrefix(pht('[Calendar]')) ->setVarySubjectPrefix(pht('[Reminder]')) ->setThreadID($next_event->getPHID(), false) ->setRelatedPHID($next_event->getPHID()) ->setBody($body->render()) ->setHTMLBody($body->renderHTML()); } } diff --git a/src/applications/calendar/query/PhabricatorCalendarEventQuery.php b/src/applications/calendar/query/PhabricatorCalendarEventQuery.php index 3980c0635d..0c762ffa54 100644 --- a/src/applications/calendar/query/PhabricatorCalendarEventQuery.php +++ b/src/applications/calendar/query/PhabricatorCalendarEventQuery.php @@ -1,684 +1,684 @@ 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 withUTCInitialEpochBetween($min, $max) { $this->utcInitialEpochMin = $min; $this->utcInitialEpochMax = $max; 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; } public function withIsImported($is_imported) { $this->isImported = $is_imported; 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', + 'column' => 'utcInitialEpoch', '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->utcInitialEpochMin !== null) { $where[] = qsprintf( $conn, 'event.utcInitialEpoch >= %d', $this->utcInitialEpochMin); } if ($this->utcInitialEpochMax !== null) { $where[] = qsprintf( $conn, 'event.utcInitialEpoch <= %d', $this->utcInitialEpochMax); } 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); } if ($this->isImported !== null) { if ($this->isImported) { $where[] = qsprintf( $conn, 'event.importSourcePHID IS NOT NULL'); } else { $where[] = qsprintf( $conn, 'event.importSourcePHID IS NULL'); } } 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; } }