mirror of
https://we.phorge.it/source/phorge.git
synced 2024-11-10 00:42:41 +01:00
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
This commit is contained in:
parent
fa6a5a46ba
commit
4819446fe5
9 changed files with 232 additions and 26 deletions
|
@ -2080,6 +2080,7 @@ phutil_register_library_map(array(
|
|||
'PhabricatorCalendarExportEditController' => 'applications/calendar/controller/PhabricatorCalendarExportEditController.php',
|
||||
'PhabricatorCalendarExportEditEngine' => 'applications/calendar/editor/PhabricatorCalendarExportEditEngine.php',
|
||||
'PhabricatorCalendarExportEditor' => 'applications/calendar/editor/PhabricatorCalendarExportEditor.php',
|
||||
'PhabricatorCalendarExportICSController' => 'applications/calendar/controller/PhabricatorCalendarExportICSController.php',
|
||||
'PhabricatorCalendarExportListController' => 'applications/calendar/controller/PhabricatorCalendarExportListController.php',
|
||||
'PhabricatorCalendarExportModeTransaction' => 'applications/calendar/xaction/PhabricatorCalendarExportModeTransaction.php',
|
||||
'PhabricatorCalendarExportNameTransaction' => 'applications/calendar/xaction/PhabricatorCalendarExportNameTransaction.php',
|
||||
|
@ -6851,6 +6852,7 @@ phutil_register_library_map(array(
|
|||
'PhabricatorCalendarExportEditController' => 'PhabricatorCalendarController',
|
||||
'PhabricatorCalendarExportEditEngine' => 'PhabricatorEditEngine',
|
||||
'PhabricatorCalendarExportEditor' => 'PhabricatorApplicationTransactionEditor',
|
||||
'PhabricatorCalendarExportICSController' => 'PhabricatorCalendarController',
|
||||
'PhabricatorCalendarExportListController' => 'PhabricatorCalendarController',
|
||||
'PhabricatorCalendarExportModeTransaction' => 'PhabricatorCalendarExportTransactionType',
|
||||
'PhabricatorCalendarExportNameTransaction' => 'PhabricatorCalendarExportTransactionType',
|
||||
|
|
|
@ -2,4 +2,43 @@
|
|||
|
||||
abstract class PhabricatorCalendarController extends PhabricatorController {
|
||||
|
||||
protected function newICSResponse(
|
||||
PhabricatorUser $viewer,
|
||||
$file_name,
|
||||
array $events) {
|
||||
$events = mpull($events, null, 'getPHID');
|
||||
|
||||
if ($events) {
|
||||
$child_map = id(new PhabricatorCalendarEventQuery())
|
||||
->setViewer($viewer)
|
||||
->withParentEventPHIDs(array_keys($events))
|
||||
->execute();
|
||||
$child_map = mpull($child_map, null, 'getPHID');
|
||||
} else {
|
||||
$child_map = array();
|
||||
}
|
||||
|
||||
$all_events = $events + $child_map;
|
||||
$child_groups = mgroup($child_map, 'getInstanceOfEventPHID');
|
||||
|
||||
$document_node = new PhutilCalendarDocumentNode();
|
||||
|
||||
foreach ($all_events as $event) {
|
||||
$child_events = idx($child_groups, $event->getPHID(), array());
|
||||
$event_node = $event->newIntermediateEventNode($viewer, $child_events);
|
||||
$document_node->appendChild($event_node);
|
||||
}
|
||||
|
||||
$root_node = id(new PhutilCalendarRootNode())
|
||||
->appendChild($document_node);
|
||||
|
||||
$ics_data = id(new PhutilICSWriter())
|
||||
->writeICSDocument($root_node);
|
||||
|
||||
return id(new AphrontFileResponse())
|
||||
->setDownload($file_name)
|
||||
->setMimeType('text/calendar')
|
||||
->setContent($ics_data);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -19,22 +19,16 @@ final class PhabricatorCalendarEventExportController
|
|||
return new Aphront404Response();
|
||||
}
|
||||
|
||||
$file_name = $event->getICSFilename();
|
||||
$event_node = $event->newIntermediateEventNode($viewer);
|
||||
if ($event->isChildEvent()) {
|
||||
$target = $event->getParentEvent();
|
||||
} else {
|
||||
$target = $event;
|
||||
}
|
||||
|
||||
$document_node = id(new PhutilCalendarDocumentNode())
|
||||
->appendChild($event_node);
|
||||
|
||||
$root_node = id(new PhutilCalendarRootNode())
|
||||
->appendChild($document_node);
|
||||
|
||||
$ics_data = id(new PhutilICSWriter())
|
||||
->writeICSDocument($root_node);
|
||||
|
||||
return id(new AphrontFileResponse())
|
||||
->setDownload($file_name)
|
||||
->setMimeType('text/calendar')
|
||||
->setContent($ics_data);
|
||||
return $this->newICSResponse(
|
||||
$viewer,
|
||||
$target->getICSFileName(),
|
||||
array($target));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -11,17 +11,19 @@ final class PhabricatorCalendarEventListController
|
|||
$year = $request->getURIData('year');
|
||||
$month = $request->getURIData('month');
|
||||
$day = $request->getURIData('day');
|
||||
|
||||
$engine = new PhabricatorCalendarEventSearchEngine();
|
||||
|
||||
if ($month && $year) {
|
||||
$engine->setCalendarYearAndMonthAndDay($year, $month, $day);
|
||||
}
|
||||
|
||||
$controller = id(new PhabricatorApplicationSearchController())
|
||||
->setQueryKey($request->getURIData('queryKey'))
|
||||
->setSearchEngine($engine);
|
||||
$nav_items = $this->buildNavigationItems();
|
||||
|
||||
return $this->delegateToController($controller);
|
||||
return $engine
|
||||
->setNavigationItems($nav_items)
|
||||
->setController($this)
|
||||
->buildResponse();
|
||||
}
|
||||
|
||||
protected function buildApplicationCrumbs() {
|
||||
|
@ -34,4 +36,18 @@ final class PhabricatorCalendarEventListController
|
|||
return $crumbs;
|
||||
}
|
||||
|
||||
protected function buildNavigationItems() {
|
||||
$items = array();
|
||||
|
||||
$items[] = id(new PHUIListItemView())
|
||||
->setType(PHUIListItemView::TYPE_LABEL)
|
||||
->setName(pht('Import/Export'));
|
||||
|
||||
$items[] = id(new PHUIListItemView())
|
||||
->setName('Exports')
|
||||
->setHref('/calendar/export/');
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
final class PhabricatorCalendarExportICSController
|
||||
extends PhabricatorCalendarController {
|
||||
|
||||
public function shouldRequireLogin() {
|
||||
// Export URIs are available if you know the secret key. We can't do any
|
||||
// other kind of authentication because third-party applications like
|
||||
// Google Calendar and Calendar.app need to be able to fetch these URIs.
|
||||
return false;
|
||||
}
|
||||
|
||||
public function handleRequest(AphrontRequest $request) {
|
||||
$omnipotent = PhabricatorUser::getOmnipotentUser();
|
||||
|
||||
// NOTE: We're using the omnipotent viewer to fetch the export, but the
|
||||
// URI must contain the secret key. Once we load the export we'll figure
|
||||
// out who the effective viewer is.
|
||||
$export = id(new PhabricatorCalendarExportQuery())
|
||||
->setViewer($omnipotent)
|
||||
->withSecretKeys(array($request->getURIData('secretKey')))
|
||||
->executeOne();
|
||||
if (!$export) {
|
||||
return new Aphront404Response();
|
||||
}
|
||||
|
||||
$author = id(new PhabricatorPeopleQuery())
|
||||
->setViewer($omnipotent)
|
||||
->withPHIDs(array($export->getAuthorPHID()))
|
||||
->needUserSettings(true)
|
||||
->executeOne();
|
||||
if (!$author) {
|
||||
return new Aphront404Response();
|
||||
}
|
||||
|
||||
$mode = $export->getPolicyMode();
|
||||
switch ($mode) {
|
||||
case PhabricatorCalendarExport::MODE_PUBLIC:
|
||||
$viewer = new PhabricatorUser();
|
||||
break;
|
||||
case PhabricatorCalendarExport::MODE_PRIVILEGED:
|
||||
$viewer = $author;
|
||||
break;
|
||||
default:
|
||||
throw new Exception(
|
||||
pht(
|
||||
'This export has an invalid mode ("%s").',
|
||||
$mode));
|
||||
}
|
||||
|
||||
$engine = id(new PhabricatorCalendarEventSearchEngine())
|
||||
->setViewer($viewer);
|
||||
|
||||
$query_key = $export->getQueryKey();
|
||||
$saved = id(new PhabricatorSavedQueryQuery())
|
||||
->setViewer($omnipotent)
|
||||
->withEngineClassNames(array(get_class($engine)))
|
||||
->withQueryKeys(array($query_key))
|
||||
->executeOne();
|
||||
if (!$saved) {
|
||||
$saved = $engine->buildSavedQueryFromBuiltin($query_key);
|
||||
}
|
||||
|
||||
if (!$saved) {
|
||||
return new Aphront404Response();
|
||||
}
|
||||
|
||||
$saved = clone $saved;
|
||||
|
||||
// Mark this as a query for export, so we get the correct ghost/recurring
|
||||
// behaviors. We also want to load all matching events.
|
||||
$saved->setParameter('export', true);
|
||||
$saved->setParameter('limit', 0xFFFF);
|
||||
|
||||
// Remove any range constraints. We always export all matching events into
|
||||
// ICS files.
|
||||
$saved->setParameter('rangeStart', null);
|
||||
$saved->setParameter('rangeEnd', null);
|
||||
$saved->setParameter('upcoming', null);
|
||||
|
||||
$query = $engine->buildQueryFromSavedQuery($saved);
|
||||
|
||||
$events = $query
|
||||
->setViewer($viewer)
|
||||
->execute();
|
||||
|
||||
return $this->newICSResponse(
|
||||
$viewer,
|
||||
$export->getICSFilename(),
|
||||
$events);
|
||||
}
|
||||
|
||||
}
|
|
@ -13,6 +13,7 @@ final class PhabricatorCalendarEventQuery
|
|||
private $eventsWithNoParent;
|
||||
private $instanceSequencePairs;
|
||||
private $isStub;
|
||||
private $parentEventPHIDs;
|
||||
|
||||
private $generateGhosts = false;
|
||||
|
||||
|
@ -71,6 +72,11 @@ final class PhabricatorCalendarEventQuery
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function withParentEventPHIDs(array $parent_phids) {
|
||||
$this->parentEventPHIDs = $parent_phids;
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function getDefaultOrderVector() {
|
||||
return array('start', 'id');
|
||||
}
|
||||
|
@ -315,14 +321,14 @@ final class PhabricatorCalendarEventQuery
|
|||
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
|
||||
$where = parent::buildWhereClauseParts($conn);
|
||||
|
||||
if ($this->ids) {
|
||||
if ($this->ids !== null) {
|
||||
$where[] = qsprintf(
|
||||
$conn,
|
||||
'event.id IN (%Ld)',
|
||||
$this->ids);
|
||||
}
|
||||
|
||||
if ($this->phids) {
|
||||
if ($this->phids !== null) {
|
||||
$where[] = qsprintf(
|
||||
$conn,
|
||||
'event.phid IN (%Ls)',
|
||||
|
@ -354,7 +360,7 @@ final class PhabricatorCalendarEventQuery
|
|||
$this->inviteePHIDs);
|
||||
}
|
||||
|
||||
if ($this->hostPHIDs) {
|
||||
if ($this->hostPHIDs !== null) {
|
||||
$where[] = qsprintf(
|
||||
$conn,
|
||||
'event.hostPHID IN (%Ls)',
|
||||
|
@ -398,6 +404,13 @@ final class PhabricatorCalendarEventQuery
|
|||
(int)$this->isStub);
|
||||
}
|
||||
|
||||
if ($this->parentEventPHIDs !== null) {
|
||||
$where[] = qsprintf(
|
||||
$conn,
|
||||
'event.instanceOfEventPHID IN (%Ls)',
|
||||
$this->parentEventPHIDs);
|
||||
}
|
||||
|
||||
return $where;
|
||||
}
|
||||
|
||||
|
|
|
@ -115,8 +115,12 @@ final class PhabricatorCalendarEventSearchEngine
|
|||
}
|
||||
|
||||
// Generate ghosts (and ignore stub events) if we aren't querying for
|
||||
// specific events.
|
||||
if (!$map['ids'] && !$map['phids']) {
|
||||
// specific events or exporting.
|
||||
if (!empty($map['export'])) {
|
||||
// This is a specific mode enabled by event exports.
|
||||
$query
|
||||
->withIsStub(false);
|
||||
} else if (!$map['ids'] && !$map['phids']) {
|
||||
$query
|
||||
->withIsStub(false)
|
||||
->setGenerateGhosts(true);
|
||||
|
|
|
@ -6,6 +6,7 @@ final class PhabricatorCalendarExportQuery
|
|||
private $ids;
|
||||
private $phids;
|
||||
private $authorPHIDs;
|
||||
private $secretKeys;
|
||||
private $isDisabled;
|
||||
|
||||
public function withIDs(array $ids) {
|
||||
|
@ -28,6 +29,11 @@ final class PhabricatorCalendarExportQuery
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function withSecretKeys(array $keys) {
|
||||
$this->secretKeys = $keys;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function newResultObject() {
|
||||
return new PhabricatorCalendarExport();
|
||||
}
|
||||
|
@ -67,6 +73,13 @@ final class PhabricatorCalendarExportQuery
|
|||
(int)$this->isDisabled);
|
||||
}
|
||||
|
||||
if ($this->secretKeys !== null) {
|
||||
$where[] = qsprintf(
|
||||
$conn,
|
||||
'export.secretKey IN (%Ls)',
|
||||
$this->secretKeys);
|
||||
}
|
||||
|
||||
return $where;
|
||||
}
|
||||
|
||||
|
|
|
@ -601,11 +601,28 @@ final class PhabricatorCalendarEvent extends PhabricatorCalendarDAO
|
|||
return $this->getMonogram().'.ics';
|
||||
}
|
||||
|
||||
public function newIntermediateEventNode(PhabricatorUser $viewer) {
|
||||
public function newIntermediateEventNode(
|
||||
PhabricatorUser $viewer,
|
||||
array $children) {
|
||||
|
||||
$base_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/'));
|
||||
$domain = $base_uri->getDomain();
|
||||
|
||||
$uid = $this->getPHID().'@'.$domain;
|
||||
// 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);
|
||||
|
@ -674,6 +691,8 @@ final class PhabricatorCalendarEvent extends PhabricatorCalendarDAO
|
|||
->setStatus($status);
|
||||
}
|
||||
|
||||
// TODO: Use $children to generate EXDATE/RDATE information.
|
||||
|
||||
$node = id(new PhutilCalendarEventNode())
|
||||
->setUID($uid)
|
||||
->setName($this->getName())
|
||||
|
@ -685,6 +704,14 @@ final class PhabricatorCalendarEvent extends PhabricatorCalendarDAO
|
|||
->setOrganizer($organizer)
|
||||
->setAttendees($attendees);
|
||||
|
||||
if ($rrule) {
|
||||
$node->setRecurrenceRule($rrule);
|
||||
}
|
||||
|
||||
if ($recurrence_id) {
|
||||
$node->setRecurrenceID($recurrence_id);
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
|
@ -833,6 +860,11 @@ final class PhabricatorCalendarEvent extends PhabricatorCalendarDAO
|
|||
$start = $this->newStartDateTime();
|
||||
$rrule->setStartDateTime($start);
|
||||
|
||||
$until = $this->newUntilDateTime();
|
||||
if ($until) {
|
||||
$rrule->setUntil($until);
|
||||
}
|
||||
|
||||
return $rrule;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue