@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Export recurring events and build ICS files for configured exports

Summary:
Ref T10747. This:

- Exports recurring events properly, with RRULE + RECURRENCE-ID.
- When exporting a part of an event series, export the whole series to ICS so it is represented faithfully.
- Make the subscribable URL for "Export" objects work.

Test Plan:
- Downloaded the ".ics" for a normal event, imported it into Calendar.app and Google Calendar.
- Downloaded the ".ics" for a recurring event, imported it into Calendar.app and Google Calendar.
- Defined an ".ics" Export of my events, subscribed to them in Calendar.app.
- Edited an event in Phabricator.
- Hit {key Command R} in Calendar.app, saw changes. (MAGIC!)
- This export included recurring events, which appeared the same way in Calendar.app and Phabricator.
- Can't import into Google Calendar from my local install easily since Google's servers can't hit my laptop, but I'll test once we deploy.

Reviewers: chad

Reviewed By: chad

Maniphest Tasks: T10747

Differential Revision: https://secure.phabricator.com/D16679

+232 -26
+2
src/__phutil_library_map__.php
··· 2080 2080 'PhabricatorCalendarExportEditController' => 'applications/calendar/controller/PhabricatorCalendarExportEditController.php', 2081 2081 'PhabricatorCalendarExportEditEngine' => 'applications/calendar/editor/PhabricatorCalendarExportEditEngine.php', 2082 2082 'PhabricatorCalendarExportEditor' => 'applications/calendar/editor/PhabricatorCalendarExportEditor.php', 2083 + 'PhabricatorCalendarExportICSController' => 'applications/calendar/controller/PhabricatorCalendarExportICSController.php', 2083 2084 'PhabricatorCalendarExportListController' => 'applications/calendar/controller/PhabricatorCalendarExportListController.php', 2084 2085 'PhabricatorCalendarExportModeTransaction' => 'applications/calendar/xaction/PhabricatorCalendarExportModeTransaction.php', 2085 2086 'PhabricatorCalendarExportNameTransaction' => 'applications/calendar/xaction/PhabricatorCalendarExportNameTransaction.php', ··· 6851 6852 'PhabricatorCalendarExportEditController' => 'PhabricatorCalendarController', 6852 6853 'PhabricatorCalendarExportEditEngine' => 'PhabricatorEditEngine', 6853 6854 'PhabricatorCalendarExportEditor' => 'PhabricatorApplicationTransactionEditor', 6855 + 'PhabricatorCalendarExportICSController' => 'PhabricatorCalendarController', 6854 6856 'PhabricatorCalendarExportListController' => 'PhabricatorCalendarController', 6855 6857 'PhabricatorCalendarExportModeTransaction' => 'PhabricatorCalendarExportTransactionType', 6856 6858 'PhabricatorCalendarExportNameTransaction' => 'PhabricatorCalendarExportTransactionType',
+39
src/applications/calendar/controller/PhabricatorCalendarController.php
··· 2 2 3 3 abstract class PhabricatorCalendarController extends PhabricatorController { 4 4 5 + protected function newICSResponse( 6 + PhabricatorUser $viewer, 7 + $file_name, 8 + array $events) { 9 + $events = mpull($events, null, 'getPHID'); 10 + 11 + if ($events) { 12 + $child_map = id(new PhabricatorCalendarEventQuery()) 13 + ->setViewer($viewer) 14 + ->withParentEventPHIDs(array_keys($events)) 15 + ->execute(); 16 + $child_map = mpull($child_map, null, 'getPHID'); 17 + } else { 18 + $child_map = array(); 19 + } 20 + 21 + $all_events = $events + $child_map; 22 + $child_groups = mgroup($child_map, 'getInstanceOfEventPHID'); 23 + 24 + $document_node = new PhutilCalendarDocumentNode(); 25 + 26 + foreach ($all_events as $event) { 27 + $child_events = idx($child_groups, $event->getPHID(), array()); 28 + $event_node = $event->newIntermediateEventNode($viewer, $child_events); 29 + $document_node->appendChild($event_node); 30 + } 31 + 32 + $root_node = id(new PhutilCalendarRootNode()) 33 + ->appendChild($document_node); 34 + 35 + $ics_data = id(new PhutilICSWriter()) 36 + ->writeICSDocument($root_node); 37 + 38 + return id(new AphrontFileResponse()) 39 + ->setDownload($file_name) 40 + ->setMimeType('text/calendar') 41 + ->setContent($ics_data); 42 + } 43 + 5 44 }
+9 -15
src/applications/calendar/controller/PhabricatorCalendarEventExportController.php
··· 19 19 return new Aphront404Response(); 20 20 } 21 21 22 - $file_name = $event->getICSFilename(); 23 - $event_node = $event->newIntermediateEventNode($viewer); 24 - 25 - $document_node = id(new PhutilCalendarDocumentNode()) 26 - ->appendChild($event_node); 27 - 28 - $root_node = id(new PhutilCalendarRootNode()) 29 - ->appendChild($document_node); 30 - 31 - $ics_data = id(new PhutilICSWriter()) 32 - ->writeICSDocument($root_node); 22 + if ($event->isChildEvent()) { 23 + $target = $event->getParentEvent(); 24 + } else { 25 + $target = $event; 26 + } 33 27 34 - return id(new AphrontFileResponse()) 35 - ->setDownload($file_name) 36 - ->setMimeType('text/calendar') 37 - ->setContent($ics_data); 28 + return $this->newICSResponse( 29 + $viewer, 30 + $target->getICSFileName(), 31 + array($target)); 38 32 } 39 33 40 34 }
+20 -4
src/applications/calendar/controller/PhabricatorCalendarEventListController.php
··· 11 11 $year = $request->getURIData('year'); 12 12 $month = $request->getURIData('month'); 13 13 $day = $request->getURIData('day'); 14 + 14 15 $engine = new PhabricatorCalendarEventSearchEngine(); 15 16 16 17 if ($month && $year) { 17 18 $engine->setCalendarYearAndMonthAndDay($year, $month, $day); 18 19 } 19 20 20 - $controller = id(new PhabricatorApplicationSearchController()) 21 - ->setQueryKey($request->getURIData('queryKey')) 22 - ->setSearchEngine($engine); 21 + $nav_items = $this->buildNavigationItems(); 23 22 24 - return $this->delegateToController($controller); 23 + return $engine 24 + ->setNavigationItems($nav_items) 25 + ->setController($this) 26 + ->buildResponse(); 25 27 } 26 28 27 29 protected function buildApplicationCrumbs() { ··· 32 34 ->addActionToCrumbs($crumbs); 33 35 34 36 return $crumbs; 37 + } 38 + 39 + protected function buildNavigationItems() { 40 + $items = array(); 41 + 42 + $items[] = id(new PHUIListItemView()) 43 + ->setType(PHUIListItemView::TYPE_LABEL) 44 + ->setName(pht('Import/Export')); 45 + 46 + $items[] = id(new PHUIListItemView()) 47 + ->setName('Exports') 48 + ->setHref('/calendar/export/'); 49 + 50 + return $items; 35 51 } 36 52 37 53 }
+93
src/applications/calendar/controller/PhabricatorCalendarExportICSController.php
··· 1 + <?php 2 + 3 + final class PhabricatorCalendarExportICSController 4 + extends PhabricatorCalendarController { 5 + 6 + public function shouldRequireLogin() { 7 + // Export URIs are available if you know the secret key. We can't do any 8 + // other kind of authentication because third-party applications like 9 + // Google Calendar and Calendar.app need to be able to fetch these URIs. 10 + return false; 11 + } 12 + 13 + public function handleRequest(AphrontRequest $request) { 14 + $omnipotent = PhabricatorUser::getOmnipotentUser(); 15 + 16 + // NOTE: We're using the omnipotent viewer to fetch the export, but the 17 + // URI must contain the secret key. Once we load the export we'll figure 18 + // out who the effective viewer is. 19 + $export = id(new PhabricatorCalendarExportQuery()) 20 + ->setViewer($omnipotent) 21 + ->withSecretKeys(array($request->getURIData('secretKey'))) 22 + ->executeOne(); 23 + if (!$export) { 24 + return new Aphront404Response(); 25 + } 26 + 27 + $author = id(new PhabricatorPeopleQuery()) 28 + ->setViewer($omnipotent) 29 + ->withPHIDs(array($export->getAuthorPHID())) 30 + ->needUserSettings(true) 31 + ->executeOne(); 32 + if (!$author) { 33 + return new Aphront404Response(); 34 + } 35 + 36 + $mode = $export->getPolicyMode(); 37 + switch ($mode) { 38 + case PhabricatorCalendarExport::MODE_PUBLIC: 39 + $viewer = new PhabricatorUser(); 40 + break; 41 + case PhabricatorCalendarExport::MODE_PRIVILEGED: 42 + $viewer = $author; 43 + break; 44 + default: 45 + throw new Exception( 46 + pht( 47 + 'This export has an invalid mode ("%s").', 48 + $mode)); 49 + } 50 + 51 + $engine = id(new PhabricatorCalendarEventSearchEngine()) 52 + ->setViewer($viewer); 53 + 54 + $query_key = $export->getQueryKey(); 55 + $saved = id(new PhabricatorSavedQueryQuery()) 56 + ->setViewer($omnipotent) 57 + ->withEngineClassNames(array(get_class($engine))) 58 + ->withQueryKeys(array($query_key)) 59 + ->executeOne(); 60 + if (!$saved) { 61 + $saved = $engine->buildSavedQueryFromBuiltin($query_key); 62 + } 63 + 64 + if (!$saved) { 65 + return new Aphront404Response(); 66 + } 67 + 68 + $saved = clone $saved; 69 + 70 + // Mark this as a query for export, so we get the correct ghost/recurring 71 + // behaviors. We also want to load all matching events. 72 + $saved->setParameter('export', true); 73 + $saved->setParameter('limit', 0xFFFF); 74 + 75 + // Remove any range constraints. We always export all matching events into 76 + // ICS files. 77 + $saved->setParameter('rangeStart', null); 78 + $saved->setParameter('rangeEnd', null); 79 + $saved->setParameter('upcoming', null); 80 + 81 + $query = $engine->buildQueryFromSavedQuery($saved); 82 + 83 + $events = $query 84 + ->setViewer($viewer) 85 + ->execute(); 86 + 87 + return $this->newICSResponse( 88 + $viewer, 89 + $export->getICSFilename(), 90 + $events); 91 + } 92 + 93 + }
+16 -3
src/applications/calendar/query/PhabricatorCalendarEventQuery.php
··· 13 13 private $eventsWithNoParent; 14 14 private $instanceSequencePairs; 15 15 private $isStub; 16 + private $parentEventPHIDs; 16 17 17 18 private $generateGhosts = false; 18 19 ··· 68 69 69 70 public function withInstanceSequencePairs(array $pairs) { 70 71 $this->instanceSequencePairs = $pairs; 72 + return $this; 73 + } 74 + 75 + public function withParentEventPHIDs(array $parent_phids) { 76 + $this->parentEventPHIDs = $parent_phids; 71 77 return $this; 72 78 } 73 79 ··· 315 321 protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { 316 322 $where = parent::buildWhereClauseParts($conn); 317 323 318 - if ($this->ids) { 324 + if ($this->ids !== null) { 319 325 $where[] = qsprintf( 320 326 $conn, 321 327 'event.id IN (%Ld)', 322 328 $this->ids); 323 329 } 324 330 325 - if ($this->phids) { 331 + if ($this->phids !== null) { 326 332 $where[] = qsprintf( 327 333 $conn, 328 334 'event.phid IN (%Ls)', ··· 354 360 $this->inviteePHIDs); 355 361 } 356 362 357 - if ($this->hostPHIDs) { 363 + if ($this->hostPHIDs !== null) { 358 364 $where[] = qsprintf( 359 365 $conn, 360 366 'event.hostPHID IN (%Ls)', ··· 396 402 $conn, 397 403 'event.isStub = %d', 398 404 (int)$this->isStub); 405 + } 406 + 407 + if ($this->parentEventPHIDs !== null) { 408 + $where[] = qsprintf( 409 + $conn, 410 + 'event.instanceOfEventPHID IN (%Ls)', 411 + $this->parentEventPHIDs); 399 412 } 400 413 401 414 return $where;
+6 -2
src/applications/calendar/query/PhabricatorCalendarEventSearchEngine.php
··· 115 115 } 116 116 117 117 // Generate ghosts (and ignore stub events) if we aren't querying for 118 - // specific events. 119 - if (!$map['ids'] && !$map['phids']) { 118 + // specific events or exporting. 119 + if (!empty($map['export'])) { 120 + // This is a specific mode enabled by event exports. 121 + $query 122 + ->withIsStub(false); 123 + } else if (!$map['ids'] && !$map['phids']) { 120 124 $query 121 125 ->withIsStub(false) 122 126 ->setGenerateGhosts(true);
+13
src/applications/calendar/query/PhabricatorCalendarExportQuery.php
··· 6 6 private $ids; 7 7 private $phids; 8 8 private $authorPHIDs; 9 + private $secretKeys; 9 10 private $isDisabled; 10 11 11 12 public function withIDs(array $ids) { ··· 25 26 26 27 public function withIsDisabled($is_disabled) { 27 28 $this->isDisabled = $is_disabled; 29 + return $this; 30 + } 31 + 32 + public function withSecretKeys(array $keys) { 33 + $this->secretKeys = $keys; 28 34 return $this; 29 35 } 30 36 ··· 65 71 $conn, 66 72 'export.isDisabled = %d', 67 73 (int)$this->isDisabled); 74 + } 75 + 76 + if ($this->secretKeys !== null) { 77 + $where[] = qsprintf( 78 + $conn, 79 + 'export.secretKey IN (%Ls)', 80 + $this->secretKeys); 68 81 } 69 82 70 83 return $where;
+34 -2
src/applications/calendar/storage/PhabricatorCalendarEvent.php
··· 601 601 return $this->getMonogram().'.ics'; 602 602 } 603 603 604 - public function newIntermediateEventNode(PhabricatorUser $viewer) { 604 + public function newIntermediateEventNode( 605 + PhabricatorUser $viewer, 606 + array $children) { 607 + 605 608 $base_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/')); 606 609 $domain = $base_uri->getDomain(); 607 610 608 - $uid = $this->getPHID().'@'.$domain; 611 + // NOTE: For recurring events, all of the events in the series have the 612 + // same UID (the UID of the parent). The child event instances are 613 + // differentiated by the "RECURRENCE-ID" field. 614 + if ($this->isChildEvent()) { 615 + $parent = $this->getParentEvent(); 616 + $instance_datetime = PhutilCalendarAbsoluteDateTime::newFromEpoch( 617 + $this->getUTCInstanceEpoch()); 618 + $recurrence_id = $instance_datetime->getISO8601(); 619 + $rrule = null; 620 + } else { 621 + $parent = $this; 622 + $recurrence_id = null; 623 + $rrule = $this->newRecurrenceRule(); 624 + } 625 + $uid = $parent->getPHID().'@'.$domain; 609 626 610 627 $created = $this->getDateCreated(); 611 628 $created = PhutilCalendarAbsoluteDateTime::newFromEpoch($created); ··· 674 691 ->setStatus($status); 675 692 } 676 693 694 + // TODO: Use $children to generate EXDATE/RDATE information. 695 + 677 696 $node = id(new PhutilCalendarEventNode()) 678 697 ->setUID($uid) 679 698 ->setName($this->getName()) ··· 684 703 ->setEndDateTime($date_end) 685 704 ->setOrganizer($organizer) 686 705 ->setAttendees($attendees); 706 + 707 + if ($rrule) { 708 + $node->setRecurrenceRule($rrule); 709 + } 710 + 711 + if ($recurrence_id) { 712 + $node->setRecurrenceID($recurrence_id); 713 + } 687 714 688 715 return $node; 689 716 } ··· 832 859 833 860 $start = $this->newStartDateTime(); 834 861 $rrule->setStartDateTime($start); 862 + 863 + $until = $this->newUntilDateTime(); 864 + if ($until) { 865 + $rrule->setUntil($until); 866 + } 835 867 836 868 return $rrule; 837 869 }