Remove all "FileHasObject" edge reads and writes
[phabricator.git] / src / applications / calendar / import / PhabricatorCalendarImportEngine.php
blob35e94634fefc458c9c4ba76dcd988b39ffe7e1e2
1 <?php
3 abstract class PhabricatorCalendarImportEngine
4 extends Phobject {
6 const QUEUE_BYTE_LIMIT = 524288;
8 final public function getImportEngineType() {
9 return $this->getPhobjectClassConstant('ENGINETYPE', 64);
12 abstract public function getImportEngineName();
13 abstract public function getImportEngineTypeName();
14 abstract public function getImportEngineHint();
16 public function appendImportProperties(
17 PhabricatorUser $viewer,
18 PhabricatorCalendarImport $import,
19 PHUIPropertyListView $properties) {
20 return;
23 abstract public function newEditEngineFields(
24 PhabricatorEditEngine $engine,
25 PhabricatorCalendarImport $import);
27 abstract public function getDisplayName(PhabricatorCalendarImport $import);
29 abstract public function importEventsFromSource(
30 PhabricatorUser $viewer,
31 PhabricatorCalendarImport $import,
32 $should_queue);
34 abstract public function canDisable(
35 PhabricatorUser $viewer,
36 PhabricatorCalendarImport $import);
38 public function explainCanDisable(
39 PhabricatorUser $viewer,
40 PhabricatorCalendarImport $import) {
41 throw new PhutilMethodNotImplementedException();
44 abstract public function supportsTriggers(
45 PhabricatorCalendarImport $import);
47 final public static function getAllImportEngines() {
48 return id(new PhutilClassMapQuery())
49 ->setAncestorClass(__CLASS__)
50 ->setUniqueMethod('getImportEngineType')
51 ->setSortMethod('getImportEngineName')
52 ->execute();
55 final protected function importEventDocument(
56 PhabricatorUser $viewer,
57 PhabricatorCalendarImport $import,
58 PhutilCalendarRootNode $root = null) {
60 $event_type = PhutilCalendarEventNode::NODETYPE;
62 $nodes = array();
63 if ($root) {
64 foreach ($root->getChildren() as $document) {
65 foreach ($document->getChildren() as $node) {
66 $node_type = $node->getNodeType();
67 if ($node_type != $event_type) {
68 $import->newLogMessage(
69 PhabricatorCalendarImportIgnoredNodeLogType::LOGTYPE,
70 array(
71 'node.type' => $node_type,
72 ));
73 continue;
76 $nodes[] = $node;
81 // Reject events which have dates outside of the range of a signed
82 // 32-bit integer. We'll need to accommodate a wider range of events
83 // eventually, but have about 20 years until it's an issue and we'll
84 // all be dead by then.
85 foreach ($nodes as $key => $node) {
86 $dates = array();
87 $dates[] = $node->getStartDateTime();
88 $dates[] = $node->getEndDateTime();
89 $dates[] = $node->getCreatedDateTime();
90 $dates[] = $node->getModifiedDateTime();
91 $rrule = $node->getRecurrenceRule();
92 if ($rrule) {
93 $dates[] = $rrule->getUntil();
96 $bad_date = false;
97 foreach ($dates as $date) {
98 if ($date === null) {
99 continue;
102 $year = $date->getYear();
103 if ($year < 1970 || $year > 2037) {
104 $bad_date = true;
105 break;
109 if ($bad_date) {
110 $import->newLogMessage(
111 PhabricatorCalendarImportEpochLogType::LOGTYPE,
112 array());
113 unset($nodes[$key]);
117 // Reject events which occur too frequently. Users do not normally define
118 // these events and the UI and application make many assumptions which are
119 // incompatible with events recurring once per second.
120 foreach ($nodes as $key => $node) {
121 $rrule = $node->getRecurrenceRule();
122 if (!$rrule) {
123 // This is not a recurring event, so we don't need to check the
124 // frequency.
125 continue;
127 $scale = $rrule->getFrequencyScale();
128 if ($scale >= PhutilCalendarRecurrenceRule::SCALE_DAILY) {
129 // This is a daily, weekly, monthly, or yearly event. These are
130 // supported.
131 } else {
132 // This is an hourly, minutely, or secondly event.
133 $import->newLogMessage(
134 PhabricatorCalendarImportFrequencyLogType::LOGTYPE,
135 array(
136 'frequency' => $rrule->getFrequency(),
138 unset($nodes[$key]);
142 $node_map = array();
143 foreach ($nodes as $node) {
144 $full_uid = $this->getFullNodeUID($node);
145 if (isset($node_map[$full_uid])) {
146 $import->newLogMessage(
147 PhabricatorCalendarImportDuplicateLogType::LOGTYPE,
148 array(
149 'uid.full' => $full_uid,
151 continue;
153 $node_map[$full_uid] = $node;
156 // If we already know about some of these events and they were created
157 // here, we're not going to import it again. This can happen if a user
158 // exports an event and then tries to import it again. This is probably
159 // not what they meant to do and this pathway generally leads to madness.
160 $likely_phids = array();
161 foreach ($node_map as $full_uid => $node) {
162 $uid = $node->getUID();
163 $matches = null;
164 if (preg_match('/^(PHID-.*)@(.*)\z/', $uid, $matches)) {
165 $likely_phids[$full_uid] = $matches[1];
169 if ($likely_phids) {
170 // NOTE: We're using the omnipotent viewer here because we don't want
171 // to collide with events that already exist, even if you can't see
172 // them.
173 $events = id(new PhabricatorCalendarEventQuery())
174 ->setViewer(PhabricatorUser::getOmnipotentUser())
175 ->withPHIDs($likely_phids)
176 ->execute();
177 $events = mpull($events, null, 'getPHID');
178 foreach ($node_map as $full_uid => $node) {
179 $phid = idx($likely_phids, $full_uid);
180 if (!$phid) {
181 continue;
184 $event = idx($events, $phid);
185 if (!$event) {
186 continue;
189 $import->newLogMessage(
190 PhabricatorCalendarImportOriginalLogType::LOGTYPE,
191 array(
192 'phid' => $event->getPHID(),
195 unset($node_map[$full_uid]);
199 if ($node_map) {
200 $events = id(new PhabricatorCalendarEventQuery())
201 ->setViewer($viewer)
202 ->withImportAuthorPHIDs(array($import->getAuthorPHID()))
203 ->withImportUIDs(array_keys($node_map))
204 ->execute();
205 $events = mpull($events, null, 'getImportUID');
206 } else {
207 $events = null;
210 $xactions = array();
211 $update_map = array();
212 $invitee_map = array();
213 $attendee_map = array();
214 foreach ($node_map as $full_uid => $node) {
215 $event = idx($events, $full_uid);
216 if (!$event) {
217 $event = PhabricatorCalendarEvent::initializeNewCalendarEvent($viewer);
220 $event
221 ->setImportAuthorPHID($import->getAuthorPHID())
222 ->setImportSourcePHID($import->getPHID())
223 ->setImportUID($full_uid)
224 ->attachImportSource($import);
226 $this->updateEventFromNode($viewer, $event, $node);
227 $xactions[$full_uid] = $this->newUpdateTransactions($event, $node);
228 $update_map[$full_uid] = $event;
230 $attendee_map[$full_uid] = array();
231 $attendees = $node->getAttendees();
232 $private_index = 1;
233 foreach ($attendees as $attendee) {
234 // Generate a "name" for this attendee which is not an email address.
235 // We avoid disclosing email addresses to be consistent with the rest
236 // of the product.
237 $name = $attendee->getName();
238 if (preg_match('/@/', $name)) {
239 $name = new PhutilEmailAddress($name);
240 $name = $name->getDisplayName();
243 // If we don't have a name or the name still looks like it's an
244 // email address, give them a dummy placeholder name.
245 if (!strlen($name) || preg_match('/@/', $name)) {
246 $name = pht('Private User %d', $private_index);
247 $private_index++;
250 $attendee_map[$full_uid][$name] = $attendee;
254 $attendee_names = array();
255 foreach ($attendee_map as $full_uid => $event_attendees) {
256 foreach ($event_attendees as $name => $attendee) {
257 $attendee_names[$name] = $attendee;
261 if ($attendee_names) {
262 $external_invitees = id(new PhabricatorCalendarExternalInviteeQuery())
263 ->setViewer($viewer)
264 ->withNames(array_keys($attendee_names))
265 ->execute();
266 $external_invitees = mpull($external_invitees, null, 'getName');
268 foreach ($attendee_names as $name => $attendee) {
269 if (isset($external_invitees[$name])) {
270 continue;
273 $external_invitee = id(new PhabricatorCalendarExternalInvitee())
274 ->setName($name)
275 ->setURI($attendee->getURI())
276 ->setSourcePHID($import->getPHID());
278 try {
279 $external_invitee->save();
280 } catch (AphrontDuplicateKeyQueryException $ex) {
281 $external_invitee =
282 id(new PhabricatorCalendarExternalInviteeQuery())
283 ->setViewer($viewer)
284 ->withNames(array($name))
285 ->executeOne();
288 $external_invitees[$name] = $external_invitee;
292 // Reorder events so we create parents first. This allows us to populate
293 // "instanceOfEventPHID" correctly.
294 $insert_order = array();
295 foreach ($update_map as $full_uid => $event) {
296 $parent_uid = $this->getParentNodeUID($node_map[$full_uid]);
297 if ($parent_uid === null) {
298 $insert_order[$full_uid] = $full_uid;
299 continue;
302 if (empty($update_map[$parent_uid])) {
303 // The parent was not present in this import, which means it either
304 // does not exist or we're going to delete it anyway. We just drop
305 // this node.
307 $import->newLogMessage(
308 PhabricatorCalendarImportOrphanLogType::LOGTYPE,
309 array(
310 'uid.full' => $full_uid,
311 'uid.parent' => $parent_uid,
314 continue;
317 // Otherwise, we're going to insert the parent first, then insert
318 // the child.
319 $insert_order[$parent_uid] = $parent_uid;
320 $insert_order[$full_uid] = $full_uid;
323 // TODO: Define per-engine content sources so this can say "via Upload" or
324 // whatever.
325 $content_source = PhabricatorContentSource::newForSource(
326 PhabricatorWebContentSource::SOURCECONST);
328 // NOTE: We're using the omnipotent user here because imported events are
329 // otherwise immutable.
330 $edit_actor = PhabricatorUser::getOmnipotentUser();
332 $update_map = array_select_keys($update_map, $insert_order);
333 foreach ($update_map as $full_uid => $event) {
334 $parent_uid = $this->getParentNodeUID($node_map[$full_uid]);
335 if ($parent_uid) {
336 $parent_phid = $update_map[$parent_uid]->getPHID();
337 } else {
338 $parent_phid = null;
341 $event->setInstanceOfEventPHID($parent_phid);
343 $event_xactions = $xactions[$full_uid];
345 $editor = id(new PhabricatorCalendarEventEditor())
346 ->setActor($edit_actor)
347 ->setActingAsPHID($import->getPHID())
348 ->setContentSource($content_source)
349 ->setContinueOnNoEffect(true)
350 ->setContinueOnMissingFields(true);
352 $is_new = !$event->getID();
354 $editor->applyTransactions($event, $event_xactions);
356 // We're just forcing attendees to the correct values here because
357 // transactions intentionally don't let you RSVP for other users. This
358 // might need to be turned into a special type of transaction eventually.
359 $attendees = $attendee_map[$full_uid];
360 $old_map = $event->getInvitees();
361 $old_map = mpull($old_map, null, 'getInviteePHID');
363 $new_map = array();
364 foreach ($attendees as $name => $attendee) {
365 $phid = $external_invitees[$name]->getPHID();
367 $invitee = idx($old_map, $phid);
368 if (!$invitee) {
369 $invitee = id(new PhabricatorCalendarEventInvitee())
370 ->setEventPHID($event->getPHID())
371 ->setInviteePHID($phid)
372 ->setInviterPHID($import->getPHID());
375 switch ($attendee->getStatus()) {
376 case PhutilCalendarUserNode::STATUS_ACCEPTED:
377 $status = PhabricatorCalendarEventInvitee::STATUS_ATTENDING;
378 break;
379 case PhutilCalendarUserNode::STATUS_DECLINED:
380 $status = PhabricatorCalendarEventInvitee::STATUS_DECLINED;
381 break;
382 case PhutilCalendarUserNode::STATUS_INVITED:
383 default:
384 $status = PhabricatorCalendarEventInvitee::STATUS_INVITED;
385 break;
387 $invitee->setStatus($status);
388 $invitee->save();
390 $new_map[$phid] = $invitee;
393 foreach ($old_map as $phid => $invitee) {
394 if (empty($new_map[$phid])) {
395 $invitee->delete();
399 $event->attachInvitees($new_map);
401 $import->newLogMessage(
402 PhabricatorCalendarImportUpdateLogType::LOGTYPE,
403 array(
404 'new' => $is_new,
405 'phid' => $event->getPHID(),
409 if (!$update_map) {
410 $import->newLogMessage(
411 PhabricatorCalendarImportEmptyLogType::LOGTYPE,
412 array());
415 // Delete any events which are no longer present in the source.
416 $updated_events = mpull($update_map, null, 'getPHID');
417 $source_events = id(new PhabricatorCalendarEventQuery())
418 ->setViewer($viewer)
419 ->withImportSourcePHIDs(array($import->getPHID()))
420 ->execute();
422 $engine = new PhabricatorDestructionEngine();
423 foreach ($source_events as $source_event) {
424 if (isset($updated_events[$source_event->getPHID()])) {
425 // We imported and updated this event, so keep it around.
426 continue;
429 $import->newLogMessage(
430 PhabricatorCalendarImportDeleteLogType::LOGTYPE,
431 array(
432 'name' => $source_event->getName(),
435 $engine->destroyObject($source_event);
439 private function getFullNodeUID(PhutilCalendarEventNode $node) {
440 $uid = $node->getUID();
441 $instance_epoch = $this->getNodeInstanceEpoch($node);
442 $full_uid = $uid.'/'.$instance_epoch;
444 return $full_uid;
447 private function getParentNodeUID(PhutilCalendarEventNode $node) {
448 $recurrence_id = $node->getRecurrenceID();
450 if (!strlen($recurrence_id)) {
451 return null;
454 return $node->getUID().'/';
457 private function getNodeInstanceEpoch(PhutilCalendarEventNode $node) {
458 $instance_iso = $node->getRecurrenceID();
459 if (strlen($instance_iso)) {
460 $instance_datetime = PhutilCalendarAbsoluteDateTime::newFromISO8601(
461 $instance_iso);
462 $instance_epoch = $instance_datetime->getEpoch();
463 } else {
464 $instance_epoch = null;
467 return $instance_epoch;
470 private function newUpdateTransactions(
471 PhabricatorCalendarEvent $event,
472 PhutilCalendarEventNode $node) {
474 $xactions = array();
475 $uid = $node->getUID();
477 if (!$event->getID()) {
478 $xactions[] = id(new PhabricatorCalendarEventTransaction())
479 ->setTransactionType(PhabricatorTransactions::TYPE_CREATE)
480 ->setNewValue(true);
483 $name = $node->getName();
484 if (!strlen($name)) {
485 if (strlen($uid)) {
486 $name = pht('Unnamed Event "%s"', $uid);
487 } else {
488 $name = pht('Unnamed Imported Event');
491 $xactions[] = id(new PhabricatorCalendarEventTransaction())
492 ->setTransactionType(
493 PhabricatorCalendarEventNameTransaction::TRANSACTIONTYPE)
494 ->setNewValue($name);
496 $description = $node->getDescription();
497 $xactions[] = id(new PhabricatorCalendarEventTransaction())
498 ->setTransactionType(
499 PhabricatorCalendarEventDescriptionTransaction::TRANSACTIONTYPE)
500 ->setNewValue((string)$description);
502 $is_recurring = (bool)$node->getRecurrenceRule();
503 $xactions[] = id(new PhabricatorCalendarEventTransaction())
504 ->setTransactionType(
505 PhabricatorCalendarEventRecurringTransaction::TRANSACTIONTYPE)
506 ->setNewValue($is_recurring);
508 return $xactions;
511 private function updateEventFromNode(
512 PhabricatorUser $actor,
513 PhabricatorCalendarEvent $event,
514 PhutilCalendarEventNode $node) {
516 $instance_epoch = $this->getNodeInstanceEpoch($node);
517 $event->setUTCInstanceEpoch($instance_epoch);
519 $timezone = $actor->getTimezoneIdentifier();
521 // TODO: These should be transactional, but the transaction only accepts
522 // epoch timestamps right now.
523 $start_datetime = $node->getStartDateTime()
524 ->setViewerTimezone($timezone);
525 $end_datetime = $node->getEndDateTime()
526 ->setViewerTimezone($timezone);
528 $event
529 ->setStartDateTime($start_datetime)
530 ->setEndDateTime($end_datetime);
532 $event->setIsAllDay((int)$start_datetime->getIsAllDay());
534 // TODO: This should be transactional, but the transaction only accepts
535 // simple frequency rules right now.
536 $rrule = $node->getRecurrenceRule();
537 if ($rrule) {
538 $event->setRecurrenceRule($rrule);
540 $until_datetime = $rrule->getUntil();
541 if ($until_datetime) {
542 $until_datetime->setViewerTimezone($timezone);
543 $event->setUntilDateTime($until_datetime);
546 $count = $rrule->getCount();
547 $event->setParameter('recurrenceCount', $count);
550 return $event;
553 public function canDeleteAnyEvents(
554 PhabricatorUser $viewer,
555 PhabricatorCalendarImport $import) {
557 $table = new PhabricatorCalendarEvent();
558 $conn = $table->establishConnection('r');
560 // Using a CalendarEventQuery here was failing oddly in a way that was
561 // difficult to reproduce locally (see T11808). Just check the table
562 // directly; this is significantly more efficient anyway.
564 $any_event = queryfx_all(
565 $conn,
566 'SELECT phid FROM %T WHERE importSourcePHID = %s LIMIT 1',
567 $table->getTableName(),
568 $import->getPHID());
570 return (bool)$any_event;
573 final protected function shouldQueueDataImport($data) {
574 return (strlen($data) > self::QUEUE_BYTE_LIMIT);
577 final protected function queueDataImport(
578 PhabricatorCalendarImport $import,
579 $data) {
581 $import->newLogMessage(
582 PhabricatorCalendarImportQueueLogType::LOGTYPE,
583 array(
584 'data.size' => strlen($data),
585 'data.limit' => self::QUEUE_BYTE_LIMIT,
588 // When we queue on this pathway, we're queueing in response to an explicit
589 // user action (like uploading a big `.ics` file), so we queue at normal
590 // priority instead of bulk/import priority.
592 PhabricatorWorker::scheduleTask(
593 'PhabricatorCalendarImportReloadWorker',
594 array(
595 'importPHID' => $import->getPHID(),
596 'via' => PhabricatorCalendarImportReloadWorker::VIA_BACKGROUND,
598 array(
599 'objectPHID' => $import->getPHID(),