Path: blob/master/src/applications/calendar/storage/PhabricatorCalendarEvent.php
12253 views
<?php12final class PhabricatorCalendarEvent extends PhabricatorCalendarDAO3implements4PhabricatorPolicyInterface,5PhabricatorExtendedPolicyInterface,6PhabricatorPolicyCodexInterface,7PhabricatorProjectInterface,8PhabricatorMarkupInterface,9PhabricatorApplicationTransactionInterface,10PhabricatorSubscribableInterface,11PhabricatorTokenReceiverInterface,12PhabricatorDestructibleInterface,13PhabricatorMentionableInterface,14PhabricatorFlaggableInterface,15PhabricatorSpacesInterface,16PhabricatorFulltextInterface,17PhabricatorFerretInterface,18PhabricatorConduitResultInterface {1920protected $name;21protected $hostPHID;22protected $description;23protected $isCancelled;24protected $isAllDay;25protected $icon;26protected $isStub;2728protected $isRecurring = 0;2930protected $seriesParentPHID;31protected $instanceOfEventPHID;32protected $sequenceIndex;3334protected $viewPolicy;35protected $editPolicy;3637protected $spacePHID;3839protected $utcInitialEpoch;40protected $utcUntilEpoch;41protected $utcInstanceEpoch;42protected $parameters = array();4344protected $importAuthorPHID;45protected $importSourcePHID;46protected $importUIDIndex;47protected $importUID;4849private $parentEvent = self::ATTACHABLE;50private $invitees = self::ATTACHABLE;51private $importSource = self::ATTACHABLE;52private $rsvps = self::ATTACHABLE;5354private $viewerTimezone;5556private $isGhostEvent = false;57private $stubInvitees;5859public static function initializeNewCalendarEvent(PhabricatorUser $actor) {60$app = id(new PhabricatorApplicationQuery())61->setViewer($actor)62->withClasses(array('PhabricatorCalendarApplication'))63->executeOne();6465$view_default = PhabricatorCalendarEventDefaultViewCapability::CAPABILITY;66$edit_default = PhabricatorCalendarEventDefaultEditCapability::CAPABILITY;67$view_policy = $app->getPolicy($view_default);68$edit_policy = $app->getPolicy($edit_default);6970$now = PhabricatorTime::getNow();7172$default_icon = 'fa-calendar';7374$datetime_defaults = self::newDefaultEventDateTimes(75$actor,76$now);77list($datetime_start, $datetime_end) = $datetime_defaults;7879// When importing events from a context like "bin/calendar reload", we may80// be acting as the omnipotent user.81$host_phid = $actor->getPHID();82if (!$host_phid) {83$host_phid = $app->getPHID();84}8586return id(new PhabricatorCalendarEvent())87->setDescription('')88->setHostPHID($host_phid)89->setIsCancelled(0)90->setIsAllDay(0)91->setIsStub(0)92->setIsRecurring(0)93->setIcon($default_icon)94->setViewPolicy($view_policy)95->setEditPolicy($edit_policy)96->setSpacePHID($actor->getDefaultSpacePHID())97->attachInvitees(array())98->setStartDateTime($datetime_start)99->setEndDateTime($datetime_end)100->attachImportSource(null)101->applyViewerTimezone($actor);102}103104public static function newDefaultEventDateTimes(105PhabricatorUser $viewer,106$now) {107108$datetime_start = PhutilCalendarAbsoluteDateTime::newFromEpoch(109$now,110$viewer->getTimezoneIdentifier());111112// Advance the time by an hour, then round downwards to the nearest hour.113// For example, if it is currently 3:25 PM, we suggest a default start time114// of 4 PM.115$datetime_start = $datetime_start116->newRelativeDateTime('PT1H')117->newAbsoluteDateTime();118$datetime_start->setMinute(0);119$datetime_start->setSecond(0);120121// Default the end time to an hour after the start time.122$datetime_end = $datetime_start123->newRelativeDateTime('PT1H')124->newAbsoluteDateTime();125126return array($datetime_start, $datetime_end);127}128129private function newChild(130PhabricatorUser $actor,131$sequence,132PhutilCalendarDateTime $start = null) {133if (!$this->isParentEvent()) {134throw new Exception(135pht(136'Unable to generate a new child event for an event which is not '.137'a recurring parent event!'));138}139140$series_phid = $this->getSeriesParentPHID();141if (!$series_phid) {142$series_phid = $this->getPHID();143}144145$child = id(new self())146->setIsCancelled(0)147->setIsStub(0)148->setInstanceOfEventPHID($this->getPHID())149->setSeriesParentPHID($series_phid)150->setSequenceIndex($sequence)151->setIsRecurring(true)152->attachParentEvent($this)153->attachImportSource(null);154155return $child->copyFromParent($actor, $start);156}157158protected function readField($field) {159static $inherit = array(160'hostPHID' => true,161'isAllDay' => true,162'icon' => true,163'spacePHID' => true,164'viewPolicy' => true,165'editPolicy' => true,166'name' => true,167'description' => true,168'isCancelled' => true,169);170171// Read these fields from the parent event instead of this event. For172// example, we want any changes to the parent event's name to apply to173// the child.174if (isset($inherit[$field])) {175if ($this->getIsStub()) {176// TODO: This should be unconditional, but the execution order of177// CalendarEventQuery and applyViewerTimezone() are currently odd.178if ($this->parentEvent !== self::ATTACHABLE) {179return $this->getParentEvent()->readField($field);180}181}182}183184return parent::readField($field);185}186187188public function copyFromParent(189PhabricatorUser $actor,190PhutilCalendarDateTime $start = null) {191192if (!$this->isChildEvent()) {193throw new Exception(194pht(195'Unable to copy from parent event: this is not a child event.'));196}197198$parent = $this->getParentEvent();199200$this201->setHostPHID($parent->getHostPHID())202->setIsAllDay($parent->getIsAllDay())203->setIcon($parent->getIcon())204->setSpacePHID($parent->getSpacePHID())205->setViewPolicy($parent->getViewPolicy())206->setEditPolicy($parent->getEditPolicy())207->setName($parent->getName())208->setDescription($parent->getDescription())209->setIsCancelled($parent->getIsCancelled());210211if ($start) {212$start_datetime = $start;213} else {214$sequence = $this->getSequenceIndex();215$start_datetime = $parent->newSequenceIndexDateTime($sequence);216217if (!$start_datetime) {218throw new Exception(219pht(220'Sequence "%s" is not valid for event!',221$sequence));222}223}224225$duration = $parent->newDuration();226$end_datetime = $start_datetime->newRelativeDateTime($duration);227228$this229->setStartDateTime($start_datetime)230->setEndDateTime($end_datetime);231232if ($parent->isImportedEvent()) {233$full_uid = $parent->getImportUID().'/'.$start_datetime->getEpoch();234235// NOTE: We don't attach the import source because this gets called236// from CalendarEventQuery while building ghosts, before we've loaded237// and attached sources. Possibly this sequence should be flipped.238239$this240->setImportAuthorPHID($parent->getImportAuthorPHID())241->setImportSourcePHID($parent->getImportSourcePHID())242->setImportUID($full_uid);243}244245return $this;246}247248public function isValidSequenceIndex(PhabricatorUser $viewer, $sequence) {249return (bool)$this->newSequenceIndexDateTime($sequence);250}251252public function newSequenceIndexDateTime($sequence) {253$set = $this->newRecurrenceSet();254if (!$set) {255return null;256}257258$limit = $sequence + 1;259$count = $this->getRecurrenceCount();260if ($count && ($count < $limit)) {261return null;262}263264$instances = $set->getEventsBetween(265null,266$this->newUntilDateTime(),267$limit);268269return idx($instances, $sequence, null);270}271272public function newStub(PhabricatorUser $actor, $sequence) {273$stub = $this->newChild($actor, $sequence);274275$stub->setIsStub(1);276277$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();278$stub->save();279unset($unguarded);280281$stub->applyViewerTimezone($actor);282283return $stub;284}285286public function newGhost(287PhabricatorUser $actor,288$sequence,289PhutilCalendarDateTime $start = null) {290291$ghost = $this->newChild($actor, $sequence, $start);292293$ghost294->setIsGhostEvent(true)295->makeEphemeral();296297$ghost->applyViewerTimezone($actor);298299return $ghost;300}301302public function applyViewerTimezone(PhabricatorUser $viewer) {303$this->viewerTimezone = $viewer->getTimezoneIdentifier();304return $this;305}306307public function getDuration() {308return ($this->getEndDateTimeEpoch() - $this->getStartDateTimeEpoch());309}310311public function updateUTCEpochs() {312// The "intitial" epoch is the start time of the event, in UTC.313$start_date = $this->newStartDateTime()314->setViewerTimezone('UTC');315$start_epoch = $start_date->getEpoch();316$this->setUTCInitialEpoch($start_epoch);317318// The "until" epoch is the last UTC epoch on which any instance of this319// event occurs. For infinitely recurring events, it is `null`.320321if (!$this->getIsRecurring()) {322$end_date = $this->newEndDateTime()323->setViewerTimezone('UTC');324$until_epoch = $end_date->getEpoch();325} else {326$until_epoch = null;327$until_date = $this->newUntilDateTime();328if ($until_date) {329$until_date->setViewerTimezone('UTC');330$duration = $this->newDuration();331$until_epoch = id(new PhutilCalendarRelativeDateTime())332->setOrigin($until_date)333->setDuration($duration)334->getEpoch();335}336}337$this->setUTCUntilEpoch($until_epoch);338339// The "instance" epoch is a property of instances of recurring events.340// It's the original UTC epoch on which the instance started. Usually that341// is the same as the start date, but they may be different if the instance342// has been edited.343344// The ICS format uses this value (original start time) to identify event345// instances, and must do so because it allows additional arbitrary346// instances to be added (with "RDATE").347348$instance_epoch = null;349$instance_date = $this->newInstanceDateTime();350if ($instance_date) {351$instance_epoch = $instance_date352->setViewerTimezone('UTC')353->getEpoch();354}355$this->setUTCInstanceEpoch($instance_epoch);356357return $this;358}359360public function save() {361$import_uid = $this->getImportUID();362if ($import_uid !== null) {363$index = PhabricatorHash::digestForIndex($import_uid);364} else {365$index = null;366}367$this->setImportUIDIndex($index);368369$this->updateUTCEpochs();370371return parent::save();372}373374/**375* Get the event start epoch for evaluating invitee availability.376*377* When assessing availability, we pretend events start earlier than they378* really do. This allows us to mark users away for the entire duration of a379* series of back-to-back meetings, even if they don't strictly overlap.380*381* @return int Event start date for availability caches.382*/383public function getStartDateTimeEpochForCache() {384$epoch = $this->getStartDateTimeEpoch();385$window = phutil_units('15 minutes in seconds');386return ($epoch - $window);387}388389public function getEndDateTimeEpochForCache() {390return $this->getEndDateTimeEpoch();391}392393protected function getConfiguration() {394return array(395self::CONFIG_AUX_PHID => true,396self::CONFIG_COLUMN_SCHEMA => array(397'name' => 'text',398'description' => 'text',399'isCancelled' => 'bool',400'isAllDay' => 'bool',401'icon' => 'text32',402'isRecurring' => 'bool',403'seriesParentPHID' => 'phid?',404'instanceOfEventPHID' => 'phid?',405'sequenceIndex' => 'uint32?',406'isStub' => 'bool',407'utcInitialEpoch' => 'epoch',408'utcUntilEpoch' => 'epoch?',409'utcInstanceEpoch' => 'epoch?',410411'importAuthorPHID' => 'phid?',412'importSourcePHID' => 'phid?',413'importUIDIndex' => 'bytes12?',414'importUID' => 'text?',415),416self::CONFIG_KEY_SCHEMA => array(417'key_instance' => array(418'columns' => array('instanceOfEventPHID', 'sequenceIndex'),419'unique' => true,420),421'key_epoch' => array(422'columns' => array('utcInitialEpoch', 'utcUntilEpoch'),423),424'key_rdate' => array(425'columns' => array('instanceOfEventPHID', 'utcInstanceEpoch'),426'unique' => true,427),428'key_series' => array(429'columns' => array('seriesParentPHID', 'utcInitialEpoch'),430),431),432self::CONFIG_SERIALIZATION => array(433'parameters' => self::SERIALIZATION_JSON,434),435) + parent::getConfiguration();436}437438public function getPHIDType() {439return PhabricatorCalendarEventPHIDType::TYPECONST;440}441442public function getMonogram() {443return 'E'.$this->getID();444}445446public function getInvitees() {447if ($this->getIsGhostEvent() || $this->getIsStub()) {448if ($this->stubInvitees === null) {449$this->stubInvitees = $this->newStubInvitees();450}451return $this->stubInvitees;452}453454return $this->assertAttached($this->invitees);455}456457public function getInviteeForPHID($phid) {458$invitees = $this->getInvitees();459$invitees = mpull($invitees, null, 'getInviteePHID');460return idx($invitees, $phid);461}462463public static function getFrequencyMap() {464return array(465PhutilCalendarRecurrenceRule::FREQUENCY_DAILY => array(466'label' => pht('Daily'),467),468PhutilCalendarRecurrenceRule::FREQUENCY_WEEKLY => array(469'label' => pht('Weekly'),470),471PhutilCalendarRecurrenceRule::FREQUENCY_MONTHLY => array(472'label' => pht('Monthly'),473),474PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY => array(475'label' => pht('Yearly'),476),477);478}479480private function newStubInvitees() {481$parent = $this->getParentEvent();482483$parent_invitees = $parent->getInvitees();484$stub_invitees = array();485486foreach ($parent_invitees as $invitee) {487$stub_invitee = id(new PhabricatorCalendarEventInvitee())488->setInviteePHID($invitee->getInviteePHID())489->setInviterPHID($invitee->getInviterPHID())490->setStatus(PhabricatorCalendarEventInvitee::STATUS_INVITED);491492$stub_invitees[] = $stub_invitee;493}494495return $stub_invitees;496}497498public function attachInvitees(array $invitees) {499$this->invitees = $invitees;500return $this;501}502503public function getInviteePHIDsForEdit() {504$invitees = array();505506foreach ($this->getInvitees() as $invitee) {507if ($invitee->isUninvited()) {508continue;509}510$invitees[] = $invitee->getInviteePHID();511}512513return $invitees;514}515516public function getUserInviteStatus($phid) {517$invitees = $this->getInvitees();518$invitees = mpull($invitees, null, 'getInviteePHID');519520$invited = idx($invitees, $phid);521if (!$invited) {522return PhabricatorCalendarEventInvitee::STATUS_UNINVITED;523}524525$invited = $invited->getStatus();526return $invited;527}528529public function getIsUserAttending($phid) {530$attending_status = PhabricatorCalendarEventInvitee::STATUS_ATTENDING;531532$old_status = $this->getUserInviteStatus($phid);533$is_attending = ($old_status == $attending_status);534535return $is_attending;536}537538public function getIsGhostEvent() {539return $this->isGhostEvent;540}541542public function setIsGhostEvent($is_ghost_event) {543$this->isGhostEvent = $is_ghost_event;544return $this;545}546547public function getURI() {548if ($this->getIsGhostEvent()) {549$base = $this->getParentEvent()->getURI();550$sequence = $this->getSequenceIndex();551return "{$base}/{$sequence}/";552}553554return '/'.$this->getMonogram();555}556557public function getParentEvent() {558return $this->assertAttached($this->parentEvent);559}560561public function attachParentEvent(PhabricatorCalendarEvent $event = null) {562$this->parentEvent = $event;563return $this;564}565566public function isParentEvent() {567return ($this->getIsRecurring() && !$this->getInstanceOfEventPHID());568}569570public function isChildEvent() {571return ($this->instanceOfEventPHID !== null);572}573574public function renderEventDate(575PhabricatorUser $viewer,576$show_end) {577578$start = $this->newStartDateTime();579$end = $this->newEndDateTime();580581$min_date = $start->newPHPDateTime();582$max_date = $end->newPHPDateTime();583584if ($this->getIsAllDay()) {585// Subtract one second since the stored date is exclusive.586$max_date = $max_date->modify('-1 second');587}588589if ($show_end) {590$min_day = $min_date->format('Y m d');591$max_day = $max_date->format('Y m d');592593$show_end_date = ($min_day != $max_day);594} else {595$show_end_date = false;596}597598$min_epoch = $min_date->format('U');599$max_epoch = $max_date->format('U');600601if ($this->getIsAllDay()) {602if ($show_end_date) {603return pht(604'%s - %s, All Day',605phabricator_date($min_epoch, $viewer),606phabricator_date($max_epoch, $viewer));607} else {608return pht(609'%s, All Day',610phabricator_date($min_epoch, $viewer));611}612} else if ($show_end_date) {613return pht(614'%s - %s',615phabricator_datetime($min_epoch, $viewer),616phabricator_datetime($max_epoch, $viewer));617} else if ($show_end) {618return pht(619'%s - %s',620phabricator_datetime($min_epoch, $viewer),621phabricator_time($max_epoch, $viewer));622} else {623return pht(624'%s',625phabricator_datetime($min_epoch, $viewer));626}627}628629630public function getDisplayIcon(PhabricatorUser $viewer) {631if ($this->getIsCancelled()) {632return 'fa-times';633}634635if ($viewer->isLoggedIn()) {636$viewer_phid = $viewer->getPHID();637if ($this->isRSVPInvited($viewer_phid)) {638return 'fa-users';639} else {640$status = $this->getUserInviteStatus($viewer_phid);641switch ($status) {642case PhabricatorCalendarEventInvitee::STATUS_ATTENDING:643return 'fa-check-circle';644case PhabricatorCalendarEventInvitee::STATUS_INVITED:645return 'fa-user-plus';646case PhabricatorCalendarEventInvitee::STATUS_DECLINED:647return 'fa-times-circle';648}649}650}651652if ($this->isImportedEvent()) {653return 'fa-download';654}655656return $this->getIcon();657}658659public function getDisplayIconColor(PhabricatorUser $viewer) {660if ($this->getIsCancelled()) {661return 'red';662}663664if ($this->isImportedEvent()) {665return 'orange';666}667668if ($viewer->isLoggedIn()) {669$viewer_phid = $viewer->getPHID();670if ($this->isRSVPInvited($viewer_phid)) {671return 'green';672}673674$status = $this->getUserInviteStatus($viewer_phid);675switch ($status) {676case PhabricatorCalendarEventInvitee::STATUS_ATTENDING:677return 'green';678case PhabricatorCalendarEventInvitee::STATUS_INVITED:679return 'green';680case PhabricatorCalendarEventInvitee::STATUS_DECLINED:681return 'grey';682}683}684685return 'bluegrey';686}687688public function getDisplayIconLabel(PhabricatorUser $viewer) {689if ($this->getIsCancelled()) {690return pht('Cancelled');691}692693if ($viewer->isLoggedIn()) {694$status = $this->getUserInviteStatus($viewer->getPHID());695switch ($status) {696case PhabricatorCalendarEventInvitee::STATUS_ATTENDING:697return pht('Attending');698case PhabricatorCalendarEventInvitee::STATUS_INVITED:699return pht('Invited');700case PhabricatorCalendarEventInvitee::STATUS_DECLINED:701return pht('Declined');702}703}704705return null;706}707708public function getICSFilename() {709return $this->getMonogram().'.ics';710}711712public function newIntermediateEventNode(713PhabricatorUser $viewer,714array $children) {715716$base_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/'));717$domain = $base_uri->getDomain();718719// NOTE: For recurring events, all of the events in the series have the720// same UID (the UID of the parent). The child event instances are721// differentiated by the "RECURRENCE-ID" field.722if ($this->isChildEvent()) {723$parent = $this->getParentEvent();724$instance_datetime = PhutilCalendarAbsoluteDateTime::newFromEpoch(725$this->getUTCInstanceEpoch());726$recurrence_id = $instance_datetime->getISO8601();727$rrule = null;728} else {729$parent = $this;730$recurrence_id = null;731$rrule = $this->newRecurrenceRule();732}733$uid = $parent->getPHID().'@'.$domain;734735$created = $this->getDateCreated();736$created = PhutilCalendarAbsoluteDateTime::newFromEpoch($created);737738$modified = $this->getDateModified();739$modified = PhutilCalendarAbsoluteDateTime::newFromEpoch($modified);740741$date_start = $this->newStartDateTime();742$date_end = $this->newEndDateTime();743744if ($this->getIsAllDay()) {745$date_start->setIsAllDay(true);746$date_end->setIsAllDay(true);747}748749$host_phid = $this->getHostPHID();750751$invitees = $this->getInvitees();752foreach ($invitees as $key => $invitee) {753if ($invitee->isUninvited()) {754unset($invitees[$key]);755}756}757758$phids = array();759$phids[] = $host_phid;760foreach ($invitees as $invitee) {761$phids[] = $invitee->getInviteePHID();762}763764$handles = $viewer->loadHandles($phids);765766$host_handle = $handles[$host_phid];767$host_name = $host_handle->getFullName();768769// NOTE: Gmail shows "Who: Unknown Organizer*" if the organizer URI does770// not look like an email address. Use a synthetic address so it shows771// the host name instead.772$install_uri = PhabricatorEnv::getProductionURI('/');773$install_uri = new PhutilURI($install_uri);774775// This should possibly use "metamta.reply-handler-domain" instead, but776// we do not currently accept mail for users anyway, and that option may777// not be configured.778$mail_domain = $install_uri->getDomain();779$host_uri = "mailto:{$host_phid}@{$mail_domain}";780781$organizer = id(new PhutilCalendarUserNode())782->setName($host_name)783->setURI($host_uri);784785$attendees = array();786foreach ($invitees as $invitee) {787$invitee_phid = $invitee->getInviteePHID();788$invitee_handle = $handles[$invitee_phid];789$invitee_name = $invitee_handle->getFullName();790$invitee_uri = $invitee_handle->getURI();791$invitee_uri = PhabricatorEnv::getURI($invitee_uri);792793switch ($invitee->getStatus()) {794case PhabricatorCalendarEventInvitee::STATUS_ATTENDING:795$status = PhutilCalendarUserNode::STATUS_ACCEPTED;796break;797case PhabricatorCalendarEventInvitee::STATUS_DECLINED:798$status = PhutilCalendarUserNode::STATUS_DECLINED;799break;800case PhabricatorCalendarEventInvitee::STATUS_INVITED:801default:802$status = PhutilCalendarUserNode::STATUS_INVITED;803break;804}805806$attendees[] = id(new PhutilCalendarUserNode())807->setName($invitee_name)808->setURI($invitee_uri)809->setStatus($status);810}811812// TODO: Use $children to generate EXDATE/RDATE information.813814$node = id(new PhutilCalendarEventNode())815->setUID($uid)816->setName($this->getName())817->setDescription($this->getDescription())818->setCreatedDateTime($created)819->setModifiedDateTime($modified)820->setStartDateTime($date_start)821->setEndDateTime($date_end)822->setOrganizer($organizer)823->setAttendees($attendees);824825if ($rrule) {826$node->setRecurrenceRule($rrule);827}828829if ($recurrence_id) {830$node->setRecurrenceID($recurrence_id);831}832833return $node;834}835836public function newStartDateTime() {837$datetime = $this->getParameter('startDateTime');838return $this->newDateTimeFromDictionary($datetime);839}840841public function getStartDateTimeEpoch() {842return $this->newStartDateTime()->getEpoch();843}844845public function newEndDateTimeForEdit() {846$datetime = $this->getParameter('endDateTime');847return $this->newDateTimeFromDictionary($datetime);848}849850public function newEndDateTime() {851$datetime = $this->newEndDateTimeForEdit();852853// If this is an all day event, we move the end date time forward to the854// first second of the following day. This is consistent with what users855// expect: an all day event from "Nov 1" to "Nov 1" lasts the entire day.856857// For imported events, the end date is already stored with this858// adjustment.859860if ($this->getIsAllDay() && !$this->isImportedEvent()) {861$datetime = $datetime862->newAbsoluteDateTime()863->setHour(0)864->setMinute(0)865->setSecond(0)866->newRelativeDateTime('P1D')867->newAbsoluteDateTime();868}869870return $datetime;871}872873public function getEndDateTimeEpoch() {874return $this->newEndDateTime()->getEpoch();875}876877public function newUntilDateTime() {878$datetime = $this->getParameter('untilDateTime');879if ($datetime) {880return $this->newDateTimeFromDictionary($datetime);881}882883return null;884}885886public function getUntilDateTimeEpoch() {887$datetime = $this->newUntilDateTime();888889if (!$datetime) {890return null;891}892893return $datetime->getEpoch();894}895896public function newDuration() {897return id(new PhutilCalendarDuration())898->setSeconds($this->getDuration());899}900901public function newInstanceDateTime() {902if (!$this->getIsRecurring()) {903return null;904}905906$index = $this->getSequenceIndex();907if (!$index) {908return null;909}910911return $this->newSequenceIndexDateTime($index);912}913914private function newDateTimeFromEpoch($epoch) {915$datetime = PhutilCalendarAbsoluteDateTime::newFromEpoch($epoch);916917if ($this->getIsAllDay()) {918$datetime->setIsAllDay(true);919}920921return $this->newDateTimeFromDateTime($datetime);922}923924private function newDateTimeFromDictionary(array $dict) {925$datetime = PhutilCalendarAbsoluteDateTime::newFromDictionary($dict);926return $this->newDateTimeFromDateTime($datetime);927}928929private function newDateTimeFromDateTime(PhutilCalendarDateTime $datetime) {930$viewer_timezone = $this->viewerTimezone;931if ($viewer_timezone) {932$datetime->setViewerTimezone($viewer_timezone);933}934935return $datetime;936}937938public function getParameter($key, $default = null) {939return idx($this->parameters, $key, $default);940}941942public function setParameter($key, $value) {943$this->parameters[$key] = $value;944return $this;945}946947public function setStartDateTime(PhutilCalendarDateTime $datetime) {948return $this->setParameter(949'startDateTime',950$datetime->newAbsoluteDateTime()->toDictionary());951}952953public function setEndDateTime(PhutilCalendarDateTime $datetime) {954return $this->setParameter(955'endDateTime',956$datetime->newAbsoluteDateTime()->toDictionary());957}958959public function setUntilDateTime(PhutilCalendarDateTime $datetime = null) {960if ($datetime) {961$value = $datetime->newAbsoluteDateTime()->toDictionary();962} else {963$value = null;964}965966return $this->setParameter('untilDateTime', $value);967}968969public function setRecurrenceRule(PhutilCalendarRecurrenceRule $rrule) {970return $this->setParameter(971'recurrenceRule',972$rrule->toDictionary());973}974975public function newRecurrenceRule() {976if ($this->isChildEvent()) {977return $this->getParentEvent()->newRecurrenceRule();978}979980if (!$this->getIsRecurring()) {981return null;982}983984$dict = $this->getParameter('recurrenceRule');985if (!$dict) {986return null;987}988989$rrule = PhutilCalendarRecurrenceRule::newFromDictionary($dict);990991$start = $this->newStartDateTime();992$rrule->setStartDateTime($start);993994$until = $this->newUntilDateTime();995if ($until) {996$rrule->setUntil($until);997}998999$count = $this->getRecurrenceCount();1000if ($count) {1001$rrule->setCount($count);1002}10031004return $rrule;1005}10061007public function getRecurrenceCount() {1008$count = (int)$this->getParameter('recurrenceCount');10091010if (!$count) {1011return null;1012}10131014return $count;1015}10161017public function newRecurrenceSet() {1018if ($this->isChildEvent()) {1019return $this->getParentEvent()->newRecurrenceSet();1020}10211022$set = new PhutilCalendarRecurrenceSet();10231024if ($this->viewerTimezone) {1025$set->setViewerTimezone($this->viewerTimezone);1026}10271028$rrule = $this->newRecurrenceRule();1029if (!$rrule) {1030return null;1031}10321033$set->addSource($rrule);10341035return $set;1036}10371038public function isImportedEvent() {1039return (bool)$this->getImportSourcePHID();1040}10411042public function getImportSource() {1043return $this->assertAttached($this->importSource);1044}10451046public function attachImportSource(1047PhabricatorCalendarImport $import = null) {1048$this->importSource = $import;1049return $this;1050}10511052public function loadForkTarget(PhabricatorUser $viewer) {1053if (!$this->getIsRecurring()) {1054// Can't fork an event which isn't recurring.1055return null;1056}10571058if ($this->isChildEvent()) {1059// If this is a child event, this is the fork target.1060return $this;1061}10621063if (!$this->isValidSequenceIndex($viewer, 1)) {1064// This appears to be a "recurring" event with no valid instances: for1065// example, its "until" date is before the second instance would occur.1066// This can happen if we already forked the event or if users entered1067// silly stuff. Just edit the event directly without forking anything.1068return null;1069}107010711072$next_event = id(new PhabricatorCalendarEventQuery())1073->setViewer($viewer)1074->withInstanceSequencePairs(1075array(1076array($this->getPHID(), 1),1077))1078->requireCapabilities(1079array(1080PhabricatorPolicyCapability::CAN_VIEW,1081PhabricatorPolicyCapability::CAN_EDIT,1082))1083->executeOne();10841085if (!$next_event) {1086$next_event = $this->newStub($viewer, 1);1087}10881089return $next_event;1090}10911092public function loadFutureEvents(PhabricatorUser $viewer) {1093// NOTE: If you can't edit some of the future events, we just1094// don't try to update them. This seems like it's probably what1095// users are likely to expect.10961097// NOTE: This only affects events that are currently in the same1098// series, not all events that were ever in the original series.1099// We could use series PHIDs instead of parent PHIDs to affect more1100// events if this turns out to be counterintuitive. Other1101// applications differ in their behavior.11021103return id(new PhabricatorCalendarEventQuery())1104->setViewer($viewer)1105->withParentEventPHIDs(array($this->getPHID()))1106->withUTCInitialEpochBetween($this->getUTCInitialEpoch(), null)1107->requireCapabilities(1108array(1109PhabricatorPolicyCapability::CAN_VIEW,1110PhabricatorPolicyCapability::CAN_EDIT,1111))1112->execute();1113}11141115public function getNotificationPHIDs() {1116$phids = array();1117if ($this->getPHID()) {1118$phids[] = $this->getPHID();1119}11201121if ($this->getSeriesParentPHID()) {1122$phids[] = $this->getSeriesParentPHID();1123}11241125return $phids;1126}11271128public function getRSVPs($phid) {1129return $this->assertAttachedKey($this->rsvps, $phid);1130}11311132public function attachRSVPs(array $rsvps) {1133$this->rsvps = $rsvps;1134return $this;1135}11361137public function isRSVPInvited($phid) {1138$status_invited = PhabricatorCalendarEventInvitee::STATUS_INVITED;1139return ($this->getRSVPStatus($phid) == $status_invited);1140}11411142public function hasRSVPAuthority($phid, $other_phid) {1143foreach ($this->getRSVPs($phid) as $rsvp) {1144if ($rsvp->getInviteePHID() == $other_phid) {1145return true;1146}1147}11481149return false;1150}11511152public function getRSVPStatus($phid) {1153// Check for an individual invitee record first.1154$invitees = $this->invitees;1155$invitees = mpull($invitees, null, 'getInviteePHID');1156$invitee = idx($invitees, $phid);1157if ($invitee) {1158return $invitee->getStatus();1159}11601161// If we don't have one, try to find an invited status for the user's1162// projects.1163$status_invited = PhabricatorCalendarEventInvitee::STATUS_INVITED;1164foreach ($this->getRSVPs($phid) as $rsvp) {1165if ($rsvp->getStatus() == $status_invited) {1166return $status_invited;1167}1168}11691170return PhabricatorCalendarEventInvitee::STATUS_UNINVITED;1171}1172117311741175/* -( Markup Interface )--------------------------------------------------- */117611771178/**1179* @task markup1180*/1181public function getMarkupFieldKey($field) {1182$content = $this->getMarkupText($field);1183return PhabricatorMarkupEngine::digestRemarkupContent($this, $content);1184}118511861187/**1188* @task markup1189*/1190public function getMarkupText($field) {1191return $this->getDescription();1192}119311941195/**1196* @task markup1197*/1198public function newMarkupEngine($field) {1199return PhabricatorMarkupEngine::newCalendarMarkupEngine();1200}120112021203/**1204* @task markup1205*/1206public function didMarkupText(1207$field,1208$output,1209PhutilMarkupEngine $engine) {1210return $output;1211}121212131214/**1215* @task markup1216*/1217public function shouldUseMarkupCache($field) {1218return (bool)$this->getID();1219}12201221/* -( PhabricatorPolicyInterface )----------------------------------------- */122212231224public function getCapabilities() {1225return array(1226PhabricatorPolicyCapability::CAN_VIEW,1227PhabricatorPolicyCapability::CAN_EDIT,1228);1229}12301231public function getPolicy($capability) {1232switch ($capability) {1233case PhabricatorPolicyCapability::CAN_VIEW:1234return $this->getViewPolicy();1235case PhabricatorPolicyCapability::CAN_EDIT:1236if ($this->isImportedEvent()) {1237return PhabricatorPolicies::POLICY_NOONE;1238} else {1239return $this->getEditPolicy();1240}1241}1242}12431244public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {1245if ($this->isImportedEvent()) {1246return false;1247}12481249// The host of an event can always view and edit it.1250$user_phid = $this->getHostPHID();1251if ($user_phid) {1252$viewer_phid = $viewer->getPHID();1253if ($viewer_phid == $user_phid) {1254return true;1255}1256}12571258if ($capability == PhabricatorPolicyCapability::CAN_VIEW) {1259$status = $this->getUserInviteStatus($viewer->getPHID());1260if ($status == PhabricatorCalendarEventInvitee::STATUS_INVITED ||1261$status == PhabricatorCalendarEventInvitee::STATUS_ATTENDING ||1262$status == PhabricatorCalendarEventInvitee::STATUS_DECLINED) {1263return true;1264}1265}12661267return false;1268}126912701271/* -( PhabricatorExtendedPolicyInterface )--------------------------------- */127212731274public function getExtendedPolicy($capability, PhabricatorUser $viewer) {1275$extended = array();12761277switch ($capability) {1278case PhabricatorPolicyCapability::CAN_VIEW:1279$import_source = $this->getImportSource();1280if ($import_source) {1281$extended[] = array(1282$import_source,1283PhabricatorPolicyCapability::CAN_VIEW,1284);1285}1286break;1287}12881289return $extended;1290}12911292/* -( PhabricatorPolicyCodexInterface )------------------------------------ */12931294public function newPolicyCodex() {1295return new PhabricatorCalendarEventPolicyCodex();1296}129712981299/* -( PhabricatorApplicationTransactionInterface )------------------------- */130013011302public function getApplicationTransactionEditor() {1303return new PhabricatorCalendarEventEditor();1304}13051306public function getApplicationTransactionTemplate() {1307return new PhabricatorCalendarEventTransaction();1308}130913101311/* -( PhabricatorSubscribableInterface )----------------------------------- */131213131314public function isAutomaticallySubscribed($phid) {1315return ($phid == $this->getHostPHID());1316}13171318/* -( PhabricatorTokenReceiverInterface )---------------------------------- */131913201321public function getUsersToNotifyOfTokenGiven() {1322return array($this->getHostPHID());1323}13241325/* -( PhabricatorDestructibleInterface )----------------------------------- */132613271328public function destroyObjectPermanently(1329PhabricatorDestructionEngine $engine) {13301331$this->openTransaction();1332$invitees = id(new PhabricatorCalendarEventInvitee())->loadAllWhere(1333'eventPHID = %s',1334$this->getPHID());1335foreach ($invitees as $invitee) {1336$invitee->delete();1337}13381339$notifications = id(new PhabricatorCalendarNotification())->loadAllWhere(1340'eventPHID = %s',1341$this->getPHID());1342foreach ($notifications as $notification) {1343$notification->delete();1344}13451346$this->delete();1347$this->saveTransaction();1348}13491350/* -( PhabricatorSpacesInterface )----------------------------------------- */135113521353public function getSpacePHID() {1354return $this->spacePHID;1355}135613571358/* -( PhabricatorFulltextInterface )--------------------------------------- */135913601361public function newFulltextEngine() {1362return new PhabricatorCalendarEventFulltextEngine();1363}136413651366/* -( PhabricatorFerretInterface )----------------------------------------- */136713681369public function newFerretEngine() {1370return new PhabricatorCalendarEventFerretEngine();1371}137213731374/* -( PhabricatorConduitResultInterface )---------------------------------- */137513761377public function getFieldSpecificationsForConduit() {1378return array(1379id(new PhabricatorConduitSearchFieldSpecification())1380->setKey('name')1381->setType('string')1382->setDescription(pht('The name of the event.')),1383id(new PhabricatorConduitSearchFieldSpecification())1384->setKey('description')1385->setType('string')1386->setDescription(pht('The event description.')),1387id(new PhabricatorConduitSearchFieldSpecification())1388->setKey('isAllDay')1389->setType('bool')1390->setDescription(pht('True if the event is an all day event.')),1391id(new PhabricatorConduitSearchFieldSpecification())1392->setKey('startDateTime')1393->setType('datetime')1394->setDescription(pht('Start date and time of the event.')),1395id(new PhabricatorConduitSearchFieldSpecification())1396->setKey('endDateTime')1397->setType('datetime')1398->setDescription(pht('End date and time of the event.')),1399);1400}14011402public function getFieldValuesForConduit() {1403$start_datetime = $this->newStartDateTime();1404$end_datetime = $this->newEndDateTime();14051406return array(1407'name' => $this->getName(),1408'description' => $this->getDescription(),1409'isAllDay' => (bool)$this->getIsAllDay(),1410'startDateTime' => $this->getConduitDateTime($start_datetime),1411'endDateTime' => $this->getConduitDateTime($end_datetime),1412);1413}14141415public function getConduitSearchAttachments() {1416return array();1417}14181419private function getConduitDateTime($datetime) {1420if (!$datetime) {1421return null;1422}14231424$epoch = $datetime->getEpoch();14251426// TODO: Possibly pass the actual viewer in from the Conduit stuff, or1427// retain it when setting the viewer timezone?1428$viewer = id(new PhabricatorUser())1429->overrideTimezoneIdentifier($this->viewerTimezone);14301431return array(1432'epoch' => (int)$epoch,1433'display' => array(1434'default' => phabricator_datetime($epoch, $viewer),1435),1436'iso8601' => $datetime->getISO8601(),1437'timezone' => $this->viewerTimezone,1438);1439}14401441}144214431444