From 257b72fe18dde328dc427f98f4cd5f3c10c2eed8 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Wed, 24 Jun 2026 17:06:15 +0200 Subject: [PATCH 1/2] feat(nag): sync recurring task instance completion via ActiveSync Apply single-instance completion, un-completion, and series-master updates from EAS clients (iOS, Outlook) to the recurring Nag task without spawning duplicate rows. - fromASTask(): route dead-occurrence Adds and master Modifies to dedicated recurrence handlers; track completions[] instead of toggling - Api::replace()/import(): resolve the series master for instance messages and merge dead-occurrence Adds into the master UID - Driver::modifyFromHash(): persist recurrence/completion state without toggleComplete() side effects - preserve the total RRULE COUNT on master Modify (clients send the remaining-occurrence count, not the series total) Adds Nag_Unit_Task_ActiveSyncRecurrenceTest covering completion, un-completion, master advance, final-instance reopen, and count preservation. --- lib/Api.php | 334 ++++++- lib/Driver.php | 44 + lib/Task.php | 567 ++++++++++- .../Unit/Task/ActiveSyncRecurrenceTest.php | 929 ++++++++++++++++++ 4 files changed, 1843 insertions(+), 31 deletions(-) create mode 100644 test/Nag/Unit/Task/ActiveSyncRecurrenceTest.php diff --git a/lib/Api.php b/lib/Api.php index 13fb456c..4b47497e 100644 --- a/lib/Api.php +++ b/lib/Api.php @@ -1101,7 +1101,36 @@ public function import($content, $contentType, $tasklist = null) case 'activesync': $task = new Nag_Task(); + $task->tasklist = $tasklist; $task->fromASTask($content); + $master = $this->_activeSyncFindSeriesMaster( + $tasklist, + $task->name + ); + if (!$master) { + $master = $this->_activeSyncFindSeriesMasterInAllTasklists( + $task->name + ); + } + if ($master && $this->_activeSyncShouldMergeInstanceImport( + $content, + $master + )) { + $merged = new Nag_Task(); + $merged->tasklist = $tasklist; + $merged->fromASTask($content, $master); + $merged->uid = $master->uid; + $merged->owner = $master->owner; + $storage->modifyFromHash($master->id, $merged->toHash()); + Horde::log( + sprintf( + 'Merged ActiveSync recurrence instance into task %s', + $master->uid + ), + Horde_Log::INFO + ); + return $master->uid; + } $hash = $task->toHash(); unset($hash['uid']); $results = $storage->add($hash); @@ -1414,11 +1443,29 @@ public function replace($uid, $content, $contentType) break; case 'activesync': + if (!($content instanceof Horde_ActiveSync_Message_Task)) { + throw new Nag_Exception(_("Invalid ActiveSync task message.")); + } + $master = $this->_activeSyncResolveSeriesMasterForReplace( + $existing, + $content + ); + if ($master) { + $existing = $master; + $taskId = $existing->id; + $uid = $existing->uid; + $owner = $existing->owner; + } $task = new Nag_Task(); - $task->fromASTask($content); + $task->fromASTask($content, $existing); $task->owner = $owner; $task->uid = $uid; - $factory->create($existing->tasklist)->modify($taskId, $task->toHash()); + $storage = $factory->create($existing->tasklist); + if ($this->_activeSyncIsRecurrenceInstanceMessage($content)) { + $storage->modifyFromHash($taskId, $task->toHash()); + } else { + $storage->modify($taskId, $task->toHash()); + } break; default: throw new Nag_Exception(sprintf(_("Unsupported Content-Type: %s"), $contentType)); @@ -1745,4 +1792,287 @@ public function searchTags( return $return; } + + /** + * Returns whether an inbound ActiveSync task is a completed series instance. + * + * @param Horde_ActiveSync_Message_Task $message + * + * @return boolean + */ + protected function _activeSyncIsDeadOccurrenceImport( + Horde_ActiveSync_Message_Task $message + ) { + if ($message->complete) { + return true; + } + if (!empty($message->deadoccur)) { + return true; + } + if ($message->recurrence && !empty($message->recurrence->deadoccur)) { + return true; + } + + return false; + } + + /** + * Returns whether an inbound ActiveSync task should merge into $master. + * + * @param Horde_ActiveSync_Message_Task $message + * @param Nag_Task $master + * + * @return boolean + */ + protected function _activeSyncShouldMergeInstanceImport( + Horde_ActiveSync_Message_Task $message, + Nag_Task $master + ) { + if (!$master->recurs()) { + return false; + } + if ($this->_activeSyncIsDeadOccurrenceImport($message)) { + return $this->_activeSyncShouldMergeDeadOccurrenceImport( + $message, + $master + ); + } + if ($message->getRecurrence() || $message->complete) { + return false; + } + + if ($this->_activeSyncMessageDueMatchesCompletion($message, $master)) { + return true; + } + + return $this->_activeSyncIsInstanceUncompleteBeforeMasterDue( + $message, + $master + ); + } + + /** + * Whether an uncomplete instance is due before the series master due date. + * + * @param Horde_ActiveSync_Message_Task $message + * @param Nag_Task $master + * + * @return boolean + */ + protected function _activeSyncIsInstanceUncompleteBeforeMasterDue( + Horde_ActiveSync_Message_Task $message, + Nag_Task $master + ) { + $occurrence = Nag_Task::activeSyncMessageOccurrenceDate($message); + if (!$occurrence || !$master->due) { + return false; + } + $occurrenceTs = $occurrence->timestamp(); + if (!is_numeric($occurrenceTs) + || (int) $occurrenceTs < strtotime('1980-01-01')) { + return false; + } + + $masterDue = new Horde_Date($master->due); + + return $occurrence->compareDate($masterDue) <= 0; + } + + /** + * Returns whether a message due date matches a stored series completion. + * + * @param Horde_ActiveSync_Message_Task $message + * @param Nag_Task $master + * + * @return boolean + */ + protected function _activeSyncMessageDueMatchesCompletion( + Horde_ActiveSync_Message_Task $message, + Nag_Task $master + ) { + return Nag_Task::activeSyncFindMatchingCompletion( + $master->recurrence, + $message + ) !== null; + } + + /** + * Resolve the series master UID for an ActiveSync task change. + * + * iOS maps dead occurrence client IDs to ghost task UIDs; instance + * completion toggles must be applied to the recurring series master. + * + * @param string $uid Mapped server UID + * @param Horde_ActiveSync_Message_Task $message Incoming task + * + * @return string Series master UID, or $uid when none applies + */ + public function resolveActiveSyncSeriesMasterUid( + $uid, + Horde_ActiveSync_Message_Task $message + ) { + $factory = $GLOBALS['injector']->getInstance('Nag_Factory_Driver'); + try { + $existing = $factory->create('')->getByUID($uid); + } catch (Horde_Exception_NotFound $e) { + return $uid; + } + + $master = $this->_activeSyncResolveSeriesMasterForReplace( + $existing, + $message + ); + + return $master ? $master->uid : $uid; + } + + /** + * Returns whether an inbound ActiveSync task is a recurrence instance + * (dead occurrence) rather than a series master update. + * + * @param Horde_ActiveSync_Message_Task $message + * + * @return boolean + */ + protected function _activeSyncIsRecurrenceInstanceMessage( + Horde_ActiveSync_Message_Task $message + ) { + if ($message->getRecurrence()) { + return false; + } + if (!empty($message->deadoccur) + || ($message->recurrence && !empty($message->recurrence->deadoccur))) { + return true; + } + + return (bool) ($message->duedate || $message->utcduedate); + } + + /** + * Redirect ActiveSync replace to a series master when needed. + * + * iOS dead-occurrence Adds may be stored as separate non-recurring tasks + * while duplicate client ids still map to those UIDs. + * + * @param Nag_Task $existing + * @param Horde_ActiveSync_Message_Task $message + * + * @return Nag_Task|null + */ + protected function _activeSyncResolveSeriesMasterForReplace( + Nag_Task $existing, + Horde_ActiveSync_Message_Task $message + ) { + if (!$this->_activeSyncIsRecurrenceInstanceMessage($message)) { + return null; + } + + $subject = $message->subject ?: $existing->name; + if ($subject === null || $subject === '') { + return null; + } + + if ($existing->recurs()) { + return $existing; + } + + $master = $this->_activeSyncFindSeriesMaster( + $existing->tasklist, + $subject + ); + if ($master) { + return $master; + } + + return $this->_activeSyncFindSeriesMasterInAllTasklists($subject); + } + + /** + * Find a recurring series master by subject across all editable tasklists. + * + * @param string $subject + * + * @return Nag_Task|null + */ + protected function _activeSyncFindSeriesMasterInAllTasklists($subject) + { + try { + $tasklists = Nag::listTasklists(false, Horde_Perms::EDIT); + } catch (Horde_Exception $e) { + Horde::log($e, Horde_Log::ERR); + + return null; + } + + foreach (array_keys($tasklists) as $tasklist) { + $master = $this->_activeSyncFindSeriesMaster($tasklist, $subject); + if ($master) { + return $master; + } + } + + return null; + } + + /** + * Returns whether a dead occurrence import should merge into $master. + * + * @param Horde_ActiveSync_Message_Task $message + * @param Nag_Task $master + * + * @return boolean + */ + protected function _activeSyncShouldMergeDeadOccurrenceImport( + Horde_ActiveSync_Message_Task $message, + Nag_Task $master + ) { + if (!$this->_activeSyncIsDeadOccurrenceImport($message)) { + return false; + } + if ($message->getRecurrence() + && empty($message->deadoccur) + && !($message->recurrence && !empty($message->recurrence->deadoccur))) { + return false; + } + + return true; + } + + /** + * Find a recurring series master by normalized subject in a tasklist. + * + * @param string $tasklist + * @param string $subject + * + * @return Nag_Task|null + */ + protected function _activeSyncFindSeriesMaster($tasklist, $subject) + { + $storage = $GLOBALS['injector'] + ->getInstance('Nag_Factory_Driver') + ->create($tasklist); + $storage->retrieve(Nag::VIEW_ALL, false); + $normalized = $this->_activeSyncNormalizeSubject($subject); + $storage->tasks->reset(); + while ($task = $storage->tasks->each()) { + if ($task->recurs() + && $this->_activeSyncNormalizeSubject($task->name) === $normalized) { + return $storage->get($task->id); + } + } + + return null; + } + + /** + * Normalize a task subject for ActiveSync series matching. + * + * @param string $subject + * + * @return string + */ + protected function _activeSyncNormalizeSubject($subject) + { + return trim($subject); + } } diff --git a/lib/Driver.php b/lib/Driver.php index 97d5a21e..89e7f971 100644 --- a/lib/Driver.php +++ b/lib/Driver.php @@ -385,6 +385,50 @@ public function modify($taskId, array $properties) } } + /** + * Persists task properties without toggleComplete() side effects. + * + * ActiveSync recurrence instance updates already encode completion + * state and recurrence completions in $properties. + * + * @param string $taskId The task to modify. + * @param array $properties A hash with properties. + * + * @return Nag_Task The updated task. + */ + public function modifyFromHash($taskId, array $properties) + { + $task = $this->get($taskId); + if (isset($properties['parent']) + && $properties['parent'] == $taskId) { + unset($properties['parent']); + } + + $this->_modify($taskId, array_merge($task->toHash(), $properties)); + + $new_task = $this->get($task->id); + if (!empty($task->uid)) { + try { + $GLOBALS['injector']->getInstance('Horde_History') + ->log( + 'nag:' . $this->_tasklist . ':' . $task->uid, + ['action' => 'modify'], + true + ); + } catch (Exception $e) { + Horde::log($e, 'ERR'); + } + } + + try { + Nag::sendNotification('edit', $new_task, $task); + } catch (Nag_Exception $e) { + Horde::log($e, 'ERR'); + } + + return $new_task; + } + /** * @see modify() */ diff --git a/lib/Task.php b/lib/Task.php index d18f7d82..d0080438 100644 --- a/lib/Task.php +++ b/lib/Task.php @@ -707,6 +707,7 @@ public function toggleComplete($ignore_children = false) /* Only mark this due date completed if there is another * occurence. */ if ($next = $this->recurrence->nextActiveRecurrence($current)) { + $this->due = $next->timestamp(); $this->completed = false; return; } @@ -756,15 +757,34 @@ public function getNextStart() */ public function getNextDue() { - if (!$this->due) { + if (!$this->recurs()) { + return ($this->due && $this->_isPlausibleTaskDue($this->due)) + ? new Horde_Date($this->due) + : null; + } + + $after = ($this->due && $this->_isPlausibleTaskDue($this->due)) + ? $this->due + : null; + if ($after === null) { + $start = $this->recurrence->getRecurStart(); + if ($start && $this->_isPlausibleTaskDue($start->timestamp())) { + $probe = clone $start; + $probe->mday--; + $after = $probe; + } + } + if ($after === null) { return null; } - if (!$this->recurs()) { - return new Horde_Date($this->due); + + if (!($nextActive = $this->recurrence->nextActiveRecurrence($after))) { + return null; } - if (!($nextActive = $this->recurrence->nextActiveRecurrence($this->due))) { + if (!$this->_isPlausibleTaskDue($nextActive->timestamp())) { return null; } + return $nextActive; } @@ -949,9 +969,16 @@ public function process($indent = null) /* Create task links. */ $this->view_link = $view_url_list[$this->tasklist]->copy()->add('task', $this->id); + $listReturnUrl = Horde::url('list.php'); + $vars = Horde_Variables::getDefaultVariables(); + if ($vars->exists('show_completed')) { + $listReturnUrl->add('show_completed', (int) $vars->get('show_completed')); + } elseif ($vars->exists('tab_name') && Nag::isTaskViewFilter($vars->get('tab_name'))) { + $listReturnUrl->add('show_completed', (int) $vars->get('tab_name')); + } $task_url_task = $task_url_list[$this->tasklist]->copy()->add('task', $this->id); $this->complete_link = Horde::url('t/complete')->add([ - 'url' => Horde::signUrl(Horde::url('list.php')), + 'url' => Horde::signUrl($listReturnUrl), 'task' => $this->id, 'tasklist' => $this->tasklist, ]); @@ -1562,7 +1589,7 @@ public function toASTask(array $options = []) $message->subject = $this->name; /* Completion */ - if ($this->completed) { + if ($this->seriesIsFullyComplete()) { if ($this->completed_date) { $message->datecompleted = new Horde_Date($this->completed_date); } @@ -1572,11 +1599,15 @@ public function toASTask(array $options = []) } /* Due Date */ - if (!empty($this->due)) { - if ($this->due) { - $message->utcduedate = new Horde_Date($this->getNextDue()); + if ($this->due) { + $nextDue = $this->getNextDue(); + if ($nextDue) { + $message->utcduedate = clone $nextDue; + $message->duedate = clone $nextDue; + } elseif ($this->_isPlausibleTaskDue($this->due)) { + $message->utcduedate = new Horde_Date($this->due); + $message->duedate = clone $message->utcduedate; } - $message->duedate = clone($message->utcduedate); } /* Start Date */ @@ -1612,7 +1643,10 @@ public function toASTask(array $options = []) /* Recurrence */ if ($this->recurs()) { - $message->setRecurrence($this->recurrence); + $message->setRecurrence( + $this->recurrence, + $this->getRemainingOccurrenceCount() + ); } /* Categories */ @@ -1944,12 +1978,69 @@ public function fromiCalendar(Horde_Icalendar_Vtodo $vTodo) } } + /** + * Returns how many series instances are still open for a COUNT-limited + * recurrence. + * + * ActiveSync clients treat POOMTASKS:Occurrences as the remaining instance + * count from the current master due forward, not the original RRULE COUNT. + * + * @return integer|null Remaining instances, or null if not count-limited. + */ + public function getRemainingOccurrenceCount() + { + if (!$this->recurs() || !$this->recurrence->hasRecurCount()) { + return null; + } + + $remaining = 0; + $probe = clone $this->recurrence->getRecurStart(); + $probe->mday--; + $total = $this->recurrence->getRecurCount(); + + for ($i = 0; $i < $total; $i++) { + $next = $this->recurrence->nextRecurrence($probe); + if (!$next) { + break; + } + if (!$this->recurrence->hasCompletion( + $next->year, + $next->month, + $next->mday + )) { + $remaining++; + } + $probe = clone $next; + $probe->mday++; + } + + return $remaining; + } + + /** + * Returns whether a recurring task series has no remaining occurrences. + * + * @return boolean + */ + public function seriesIsFullyComplete() + { + if ($this->completed) { + return true; + } + if (!$this->recurs()) { + return false; + } + + return !$this->getNextDue(); + } + /** * Create a nag Task object from an activesync message * - * @param Horde_ActiveSync_Message_Task $message The task object + * @param Horde_ActiveSync_Message_Task $message The task object + * @param Nag_Task|null $existing Existing task on replace */ - public function fromASTask(Horde_ActiveSync_Message_Task $message) + public function fromASTask(Horde_ActiveSync_Message_Task $message, ?Nag_Task $existing = null) { /* Owner is always current user. */ $this->owner = $GLOBALS['registry']->getAuth(); @@ -1962,29 +2053,25 @@ public function fromASTask(Horde_ActiveSync_Message_Task $message) /* Notes and Title */ if ($message->getProtocolVersion() >= Horde_ActiveSync::VERSION_TWELVE) { - if ($message->airsyncbasebody->type == Horde_ActiveSync::BODYPREF_TYPE_HTML) { - $this->desc = Horde_Text_Filter::filter($message->airsyncbasebody->data, 'Html2text'); + $body = $message->getProperty('airsyncbasebody'); + if ($body instanceof Horde_ActiveSync_Message_AirSyncBaseBody) { + if ($body->type == Horde_ActiveSync::BODYPREF_TYPE_HTML) { + $this->desc = Horde_Text_Filter::filter($body->data ?? '', 'Html2text'); + } else { + $this->desc = $body->data ?? ''; + } + } elseif (!empty($message->body)) { + $this->desc = $message->body; } else { - $this->desc = $message->airsyncbasebody->data; + $this->desc = ''; } } else { - $this->desc = $message->body; + $this->desc = $message->body ?? ''; } $this->name = $message->subject; $tz = date_default_timezone_get(); - /* Completion: Note we don't use self::toggleCompletion() becuase of - * the way that EAS hanldes recurring tasks (see below). */ - if ($this->completed = $message->complete) { - if ($message->datecompleted) { - $message->datecompleted->setTimezone($tz); - $this->completed_date = $message->datecompleted->timestamp(); - } else { - $this->completed_date = null; - } - } - /* Due Date */ if ($due = $message->utcduedate) { $due->setTimezone($tz); @@ -2046,13 +2133,44 @@ public function fromASTask(Horde_ActiveSync_Message_Task $message) $this->alarm = ($this->due - $alarm->timestamp()) / 60; } - $this->tasklist = $GLOBALS['prefs']->getValue('default_tasklist'); + if (empty($this->tasklist)) { + $this->tasklist = $GLOBALS['prefs']->getValue('default_tasklist'); + } /* Categories */ if (is_array($message->categories) && count($message->categories)) { $this->tags = implode(',', $message->categories); } + if ($existing && $existing->recurs()) { + if (isset($this->due) && !$this->_isPlausibleTaskDue($this->due)) { + $this->due = null; + } + $deadOccur = !empty($message->deadoccur) + || ($message->recurrence + && !empty($message->recurrence->deadoccur)); + if ($message->getRecurrence() && !$deadOccur) { + $this->_applyActiveSyncRecurrenceMasterChange( + $message, + $existing + ); + return; + } + $this->_applyActiveSyncRecurrenceInstance($message, $existing); + return; + } + + /* Completion: Note we don't use self::toggleCompletion() because of + * the way that EAS handles recurring tasks (see below). */ + if ($this->completed = $message->complete) { + if ($message->datecompleted) { + $message->datecompleted->setTimezone($tz); + $this->completed_date = $message->datecompleted->timestamp(); + } else { + $this->completed_date = null; + } + } + // Recurrence is handled by the client deleting the original event // and recreating a "dead" completed event and an active recurring // event with the first due date being the next due date in the @@ -2068,4 +2186,395 @@ public function fromASTask(Horde_ActiveSync_Message_Task $message) } } + /** + * Calendar date of a recurring instance in an ActiveSync task message. + * + * iOS sends both UtcDueDate and DueDate; the latter encodes the user's + * local occurrence date and must be preferred when matching completions. + * + * @param Horde_ActiveSync_Message_Task $message + * + * @return Horde_Date|null + */ + public static function activeSyncMessageOccurrenceDate( + Horde_ActiveSync_Message_Task $message + ) { + if ($message->duedate) { + $tz = date_default_timezone_get(); + + return new Horde_Date( + [ + 'year' => (int) $message->duedate->year, + 'month' => (int) $message->duedate->month, + 'mday' => (int) $message->duedate->mday, + 'hour' => 12, + 'min' => 0, + ], + $tz + ); + } + if ($message->utcduedate) { + $due = clone $message->utcduedate; + $due->setTimezone(date_default_timezone_get()); + + return $due; + } + + return null; + } + + /** + * Find a stored completion date matching an ActiveSync instance message. + * + * @param Horde_Date_Recurrence $recurrence + * @param Horde_ActiveSync_Message_Task $message + * + * @return Horde_Date|null + */ + public static function activeSyncFindMatchingCompletion( + Horde_Date_Recurrence $recurrence, + Horde_ActiveSync_Message_Task $message + ) { + $dates = []; + if ($occurrence = self::activeSyncMessageOccurrenceDate($message)) { + $dates[] = $occurrence; + } + if ($message->utcduedate) { + $utc = clone $message->utcduedate; + $utc->setTimezone(date_default_timezone_get()); + $dates[] = $utc; + } + + $seen = []; + foreach ($dates as $date) { + $key = sprintf( + '%04d%02d%02d', + (int) $date->year, + (int) $date->month, + (int) $date->mday + ); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + if ($recurrence->hasCompletion( + (int) $date->year, + (int) $date->month, + (int) $date->mday + )) { + return $date; + } + } + + return null; + } + + /** + * Remove stored completions matching an ActiveSync instance message. + * + * @param Horde_Date_Recurrence $recurrence + * @param Horde_ActiveSync_Message_Task $message + */ + public static function activeSyncDeleteMatchingCompletions( + Horde_Date_Recurrence $recurrence, + Horde_ActiveSync_Message_Task $message + ) { + $dates = []; + if ($occurrence = self::activeSyncMessageOccurrenceDate($message)) { + $dates[] = $occurrence; + } + if ($message->utcduedate) { + $utc = clone $message->utcduedate; + $utc->setTimezone(date_default_timezone_get()); + $dates[] = $utc; + } + + $seen = []; + foreach ($dates as $date) { + $key = sprintf( + '%04d%02d%02d', + (int) $date->year, + (int) $date->month, + (int) $date->mday + ); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + if ($recurrence->hasCompletion( + (int) $date->year, + (int) $date->month, + (int) $date->mday + )) { + $recurrence->deleteCompletion( + (int) $date->year, + (int) $date->month, + (int) $date->mday + ); + } + } + } + + /** + * Apply a recurring series master update from ActiveSync (next due date, + * remaining occurrence count). Completions are left to companion dead- + * occurrence Adds in the same SYNC batch. + * + * @param Horde_ActiveSync_Message_Task $message The task object + * @param Nag_Task $existing Existing recurring task + */ + protected function _applyActiveSyncRecurrenceMasterChange( + Horde_ActiveSync_Message_Task $message, + Nag_Task $existing + ) { + $this->recurrence = Horde_Date_Recurrence::fromHash( + $existing->recurrence->toHash() + ); + $this->tasklist = $existing->tasklist; + $this->uid = $existing->uid; + + $reopened = false; + if (!$message->complete && $existing->seriesIsFullyComplete()) { + $occurrence = self::activeSyncMessageOccurrenceDate($message); + if ($occurrence + && $this->_isPlausibleTaskDue($occurrence->timestamp())) { + self::activeSyncDeleteMatchingCompletions( + $this->recurrence, + $message + ); + $this->due = $occurrence->timestamp(); + $this->_setActiveSyncCompletion(false, $message); + $reopened = true; + } + } + + // Do not adopt POOMTASKS:Occurrences here: ActiveSync clients send the + // *remaining* instance count from the current master due forward, while + // Horde_Date_Recurrence::setRecurCount() expects the *total* series + // count. Overwriting it would truncate the series. The stored total + // count stays canonical; completions track progress and + // getRemainingOccurrenceCount() derives the remaining value for export. + + if (!$reopened) { + if (!isset($this->due) || !$this->_isPlausibleTaskDue($this->due)) { + $this->due = $existing->due; + } + + $this->_setActiveSyncCompletion( + $this->getRemainingOccurrenceCount() === 0, + $message + ); + } + } + + /** + * Set this task's completion flag and date from an ActiveSync update. + * + * Centralizes the "mark complete using the message's DateCompleted (or + * now), otherwise clear completion" logic shared by the master-change and + * single-instance recurrence paths. + * + * @param boolean $completed Whether the task/series + * is complete. + * @param Horde_ActiveSync_Message_Task $message Source message, used for + * the completion date. + */ + protected function _setActiveSyncCompletion( + $completed, + Horde_ActiveSync_Message_Task $message + ) { + if (!$completed) { + $this->completed = false; + $this->completed_date = null; + + return; + } + + $this->completed = true; + if ($message->datecompleted) { + $tz = date_default_timezone_get(); + $message->datecompleted->setTimezone($tz); + $this->completed_date = $message->datecompleted->timestamp(); + } else { + $this->completed_date = time(); + } + } + + /** + * Apply single-instance completion state from an ActiveSync message to an + * existing recurring task. + * + * @param Horde_ActiveSync_Message_Task $message The task object + * @param Nag_Task $existing Existing recurring task + */ + protected function _applyActiveSyncRecurrenceInstance( + Horde_ActiveSync_Message_Task $message, + Nag_Task $existing + ) { + if (isset($this->due) && !$this->_isPlausibleTaskDue($this->due)) { + $this->due = null; + } + + $this->recurrence = Horde_Date_Recurrence::fromHash( + $existing->recurrence->toHash() + ); + $this->tasklist = $existing->tasklist; + $this->uid = $existing->uid; + + $deadOccur = !empty($message->deadoccur) + || ($message->recurrence && !empty($message->recurrence->deadoccur)); + $messageComplete = (bool) $message->complete; + $completedOccurrence = null; + $reopenedOccurrence = false; + $occurrenceDate = self::activeSyncMessageOccurrenceDate($message); + + if ($messageComplete && $occurrenceDate) { + if ($existing->getRemainingOccurrenceCount() === 0) { + $this->_restoreActiveSyncRecurrenceMaster($existing); + return; + } + $completedOccurrence = clone $occurrenceDate; + $this->recurrence->addCompletion( + $completedOccurrence->year, + $completedOccurrence->month, + $completedOccurrence->mday + ); + } elseif (!$messageComplete && $occurrenceDate) { + $storedCompletion = self::activeSyncFindMatchingCompletion( + $this->recurrence, + $message + ); + if ($storedCompletion) { + $this->recurrence->deleteCompletion( + $storedCompletion->year, + $storedCompletion->month, + $storedCompletion->mday + ); + $this->due = $storedCompletion->timestamp(); + $reopenedOccurrence = true; + } elseif (!$deadOccur && $existing->due) { + $masterDue = new Horde_Date($existing->due); + if ($occurrenceDate->compareDate($masterDue) <= 0 + && $this->_isPlausibleTaskDue($occurrenceDate->timestamp())) { + self::activeSyncDeleteMatchingCompletions( + $this->recurrence, + $message + ); + $this->due = $occurrenceDate->timestamp(); + $reopenedOccurrence = true; + } else { + $previous = $existing->getNextDue(); + if ($previous + && $occurrenceDate->compareDate($previous) > 0) { + if ($existing->getRemainingOccurrenceCount() === 0) { + self::activeSyncDeleteMatchingCompletions( + $this->recurrence, + $message + ); + $this->due = $occurrenceDate->timestamp(); + $reopenedOccurrence = true; + } else { + $completedOccurrence = clone $previous; + $this->recurrence->addCompletion( + $previous->year, + $previous->month, + $previous->mday + ); + } + } + } + } + } + + if ($completedOccurrence) { + $this->due = $completedOccurrence->timestamp(); + } elseif (!$reopenedOccurrence && $existing->due) { + $this->due = $existing->due; + } + + if (!$reopenedOccurrence && ($messageComplete || $completedOccurrence) + && ($nextDue = $this->getNextDue())) { + $this->due = $nextDue->timestamp(); + } + + $this->_finalizeActiveSyncRecurrenceState( + $message, + $messageComplete, + $completedOccurrence + ); + } + + /** + * Set due date and completion flags after applying recurrence instance + * changes from ActiveSync. + * + * @param Horde_ActiveSync_Message_Task $message The task object + * @param boolean $messageComplete Message complete flag + * @param Horde_Date|null $completedOccurrence Completed instance + */ + protected function _finalizeActiveSyncRecurrenceState( + Horde_ActiveSync_Message_Task $message, + $messageComplete, + $completedOccurrence = null + ) { + if (!$this->recurs()) { + return; + } + + $seriesContinues = false; + if ($completedOccurrence) { + $probe = clone $completedOccurrence; + $probe->mday++; + if ($this->recurrence->nextActiveRecurrence($probe)) { + $seriesContinues = true; + } + } elseif ($this->due) { + $probe = new Horde_Date($this->due); + $probe->mday++; + if ($this->recurrence->nextActiveRecurrence($probe)) { + $seriesContinues = true; + } + } + + if ($seriesContinues && $this->getRemainingOccurrenceCount() !== 0) { + $this->_setActiveSyncCompletion(false, $message); + return; + } + + $this->_setActiveSyncCompletion( + $messageComplete || $this->getRemainingOccurrenceCount() === 0, + $message + ); + } + + /** + * Restore master state when a client sends a completion past COUNT. + * + * @param Nag_Task $existing Existing recurring task + */ + protected function _restoreActiveSyncRecurrenceMaster(Nag_Task $existing) + { + $this->recurrence = Horde_Date_Recurrence::fromHash( + $existing->recurrence->toHash() + ); + $this->tasklist = $existing->tasklist; + $this->uid = $existing->uid; + $this->due = $existing->due; + $this->completed = $existing->completed; + $this->completed_date = $existing->completed_date; + } + + /** + * Returns whether a stored due timestamp is usable for task scheduling. + * + * @param integer $timestamp Unix timestamp. + * + * @return boolean + */ + protected function _isPlausibleTaskDue($timestamp) + { + return is_numeric($timestamp) + && (int) $timestamp >= strtotime('1980-01-01'); + } + } diff --git a/test/Nag/Unit/Task/ActiveSyncRecurrenceTest.php b/test/Nag/Unit/Task/ActiveSyncRecurrenceTest.php new file mode 100644 index 00000000..96a6f326 --- /dev/null +++ b/test/Nag/Unit/Task/ActiveSyncRecurrenceTest.php @@ -0,0 +1,929 @@ + + * @license http://www.horde.org/licenses/gpl GPLv2 + * @copyright 2026 The Horde Project (http://www.horde.org/) + * @package Nag + */ + +use PHPUnit\Framework\TestCase; + +class Nag_Unit_Task_ActiveSyncRecurrenceTest extends TestCase +{ + protected function setUp(): void + { + $registry = $this->createMock(Horde_Registry::class); + $registry->method('getAuth')->willReturn('alice@example.com'); + $GLOBALS['registry'] = $registry; + + $prefs = $this->createMock(Horde_Prefs::class); + $prefs->method('getValue')->willReturn('tasklist1'); + $GLOBALS['prefs'] = $prefs; + } + + public function testFromASTaskIgnoresMalformedAirSyncBaseBody() + { + $message = new Horde_ActiveSync_Message_Task( + ['protocolversion' => Horde_ActiveSync::VERSION_TWELVE] + ); + $message->subject = 'Body Test'; + $message->airsyncbasebody = ''; + + $task = new Nag_Task(); + $task->fromASTask($message); + + $this->assertSame('', $task->desc); + $this->assertSame('Body Test', $task->name); + } + + public function testFromASTaskCompletesSingleInstanceWithDeadOccur() + { + $existing = $this->_createWeeklySeries('2026-06-23', 5); + $existing->due = strtotime('2026-06-24 12:00:00'); + + $message = $this->_createMessage([ + 'complete' => true, + 'deadoccur' => true, + 'due' => '2026-06-23', + ]); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertTrue( + $task->recurrence->hasCompletion(2026, 6, 23) + ); + $this->assertFalse($task->completed); + $this->assertEquals( + strtotime('2026-06-24 12:00:00'), + $task->due + ); + } + + public function testFromASTaskCompletesSingleInstanceWithCompleteFlag() + { + $existing = $this->_createWeeklySeries('2026-06-23', 5); + $existing->due = strtotime('2026-06-24 12:00:00'); + + $message = $this->_createMessage([ + 'complete' => true, + 'due' => '2026-06-23', + ]); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertTrue( + $task->recurrence->hasCompletion(2026, 6, 23) + ); + $this->assertFalse($task->completed); + } + + public function testFromASTaskDeadOccurDoesNotReplaceRRule() + { + $existing = $this->_createWeeklySeries('2026-06-23', 5); + + $message = $this->_createMessage([ + 'complete' => true, + 'deadoccur' => true, + 'due' => '2026-06-23', + ]); + $recurrence = Horde_ActiveSync::messageFactory('TaskRecurrence'); + $recurrence->type = Horde_ActiveSync_Message_Recurrence::TYPE_WEEKLY; + $message->recurrence = $recurrence; + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertTrue( + $existing->recurrence->hasRecurType(Horde_Date_Recurrence::RECUR_DAILY) + ); + $this->assertTrue( + $task->recurrence->hasRecurType(Horde_Date_Recurrence::RECUR_DAILY) + ); + } + + public function testFromASTaskFinalInstanceSetsCompleted() + { + $existing = $this->_createWeeklySeries('2026-06-23', 1); + + $message = $this->_createMessage([ + 'complete' => true, + 'due' => '2026-06-23', + ]); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertTrue($task->completed); + $this->assertTrue( + $task->recurrence->hasCompletion(2026, 6, 23) + ); + } + + public function testFromASTaskUncompleteRemovesCompletion() + { + $existing = $this->_createWeeklySeries('2026-06-23', 5); + $existing->due = strtotime('2026-06-26 12:00:00'); + $existing->recurrence->addCompletion(2026, 6, 23); + $existing->recurrence->addCompletion(2026, 6, 24); + + $message = $this->_createMessage([ + 'complete' => false, + 'deadoccur' => true, + 'due' => '2026-06-23', + ]); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertFalse( + $task->recurrence->hasCompletion(2026, 6, 23) + ); + $this->assertTrue( + $task->recurrence->hasCompletion(2026, 6, 24) + ); + $this->assertFalse($task->completed); + } + + public function testFromASTaskUncompleteWithoutDeadOccurRollsBackDue() + { + $due = strtotime('2026-06-24 12:00:00'); + $recurrence = new Horde_Date_Recurrence($due); + $recurrence->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $recurrence->setRecurInterval(5); + $recurrence->setRecurOnDay(Horde_Date::MASK_WEDNESDAY); + $recurrence->setRecurCount(5); + $recurrence->addCompletion(2026, 6, 24); + $recurrence->addCompletion(2026, 7, 29); + $recurrence->addCompletion(2026, 9, 2); + + $existing = new Nag_Task(); + $existing->due = strtotime('2026-10-07 12:00:00'); + $existing->recurrence = $recurrence; + $existing->name = 'Series F'; + $existing->uid = 'series-f'; + $existing->tasklist = 'tasklist1'; + $existing->tags = []; + + $message = $this->_createMessage([ + 'complete' => false, + 'due' => '2026-09-02', + ]); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertFalse( + $task->recurrence->hasCompletion(2026, 9, 2) + ); + $this->assertEquals( + strtotime('2026-09-02'), + strtotime(date('Y-m-d', $task->due)) + ); + $this->assertFalse($task->completed); + } + + public function testFromASTaskUncompletePrefersIosDueDateOverUtcDueDate() + { + $due = strtotime('2026-06-24 12:00:00'); + $recurrence = new Horde_Date_Recurrence($due); + $recurrence->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $recurrence->setRecurInterval(5); + $recurrence->setRecurOnDay(Horde_Date::MASK_WEDNESDAY); + $recurrence->setRecurCount(5); + $recurrence->addCompletion(2026, 6, 24); + $recurrence->addCompletion(2026, 7, 29); + $recurrence->addCompletion(2026, 9, 2); + $recurrence->addCompletion(2026, 10, 7); + + $existing = new Nag_Task(); + $existing->due = strtotime('2026-11-11 12:00:00'); + $existing->recurrence = $recurrence; + $existing->name = 'Series F'; + $existing->uid = 'series-f'; + $existing->tasklist = 'tasklist1'; + $existing->tags = []; + + $message = new Horde_ActiveSync_Message_Task( + ['protocolversion' => Horde_ActiveSync::VERSION_TWELVE] + ); + $message->subject = 'Series F'; + $message->complete = false; + $message->utcduedate = new Horde_Date('2026-10-06T22:00:00.000Z'); + $message->duedate = new Horde_Date('2026-10-07T00:00:00.000Z'); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertFalse( + $task->recurrence->hasCompletion(2026, 10, 7) + ); + $this->assertEquals( + strtotime('2026-10-07'), + strtotime(date('Y-m-d', $task->due)) + ); + $this->assertFalse($task->completed); + } + + public function testFromASTaskUncompleteRollsBackMasterDueToReopenedInstance() + { + $due = strtotime('2026-06-24 12:00:00'); + $recurrence = new Horde_Date_Recurrence($due); + $recurrence->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $recurrence->setRecurInterval(1); + $recurrence->setRecurOnDay(Horde_Date::MASK_WEDNESDAY); + $recurrence->setRecurCount(5); + $recurrence->addCompletion(2026, 6, 24); + $recurrence->addCompletion(2026, 7, 1); + + $existing = new Nag_Task(); + $existing->due = strtotime('2026-07-08 12:00:00'); + $existing->recurrence = $recurrence; + $existing->name = 'Series G'; + $existing->uid = 'series-g'; + $existing->tasklist = 'tasklist1'; + $existing->tags = []; + + $message = new Horde_ActiveSync_Message_Task( + ['protocolversion' => Horde_ActiveSync::VERSION_TWELVE] + ); + $message->subject = 'Series G'; + $message->complete = false; + $message->utcduedate = new Horde_Date('2026-06-30T22:00:00.000Z'); + $message->duedate = new Horde_Date('2026-07-01T00:00:00.000Z'); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertFalse( + $task->recurrence->hasCompletion(2026, 7, 1) + ); + $this->assertEquals( + strtotime('2026-07-01'), + strtotime(date('Y-m-d', $task->due)) + ); + $this->assertFalse($task->completed); + } + + public function testFromASTaskUncompleteRollsBackDueBeforeAdvancedMasterDue() + { + $due = strtotime('2026-06-24 12:00:00'); + $recurrence = new Horde_Date_Recurrence($due); + $recurrence->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $recurrence->setRecurInterval(1); + $recurrence->setRecurOnDay(Horde_Date::MASK_WEDNESDAY); + $recurrence->setRecurCount(5); + $recurrence->addCompletion(2026, 6, 24); + $recurrence->addCompletion(2026, 7, 1); + $recurrence->addCompletion(2026, 7, 8); + + $existing = new Nag_Task(); + $existing->due = strtotime('2026-07-15 12:00:00'); + $existing->recurrence = $recurrence; + $existing->name = 'Series Test'; + $existing->uid = 'series-test'; + $existing->tasklist = 'tasklist1'; + $existing->tags = []; + + $message = new Horde_ActiveSync_Message_Task( + ['protocolversion' => Horde_ActiveSync::VERSION_TWELVE] + ); + $message->subject = 'Series Test'; + $message->complete = false; + $message->utcduedate = new Horde_Date('2026-07-07T22:00:00.000Z'); + $message->duedate = new Horde_Date('2026-07-08T00:00:00.000Z'); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertFalse( + $task->recurrence->hasCompletion(2026, 7, 8) + ); + $this->assertEquals( + strtotime('2026-07-08'), + strtotime(date('Y-m-d', $task->due)) + ); + $nextDue = $task->getNextDue(); + $this->assertNotNull($nextDue); + $this->assertEquals( + strtotime('2026-07-08'), + strtotime(date('Y-m-d', $nextDue->timestamp())) + ); + $this->assertFalse($task->completed); + } + + public function testFromASTaskUncompleteRollsBackWhenMasterDueEqualsOccurrence() + { + $due = strtotime('2026-06-24 12:00:00'); + $recurrence = new Horde_Date_Recurrence($due); + $recurrence->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $recurrence->setRecurInterval(1); + $recurrence->setRecurOnDay(Horde_Date::MASK_WEDNESDAY); + $recurrence->setRecurCount(5); + $recurrence->addCompletion(2026, 6, 24); + $recurrence->addCompletion(2026, 7, 1); + $recurrence->addCompletion(2026, 7, 8); + + $existing = new Nag_Task(); + $existing->due = strtotime('2026-07-08 12:00:00'); + $existing->recurrence = $recurrence; + $existing->name = 'Series G'; + $existing->uid = 'series-g-equal-due'; + $existing->tasklist = 'tasklist1'; + $existing->tags = []; + + $message = new Horde_ActiveSync_Message_Task( + ['protocolversion' => Horde_ActiveSync::VERSION_TWELVE] + ); + $message->subject = 'Series G'; + $message->complete = false; + $message->utcduedate = new Horde_Date('2026-07-07T22:00:00.000Z'); + $message->duedate = new Horde_Date('2026-07-08T00:00:00.000Z'); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertFalse( + $task->recurrence->hasCompletion(2026, 7, 8) + ); + $this->assertEquals( + strtotime('2026-07-08'), + strtotime(date('Y-m-d', $task->due)) + ); + $nextDue = $task->getNextDue(); + $this->assertNotNull($nextDue); + $this->assertEquals( + strtotime('2026-07-08'), + strtotime(date('Y-m-d', $nextDue->timestamp())) + ); + $this->assertFalse($task->completed); + } + + public function testFromASTaskUncompleteRollsBackDueWithoutStoredCompletion() + { + $due = strtotime('2026-06-24 12:00:00'); + $recurrence = new Horde_Date_Recurrence($due); + $recurrence->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $recurrence->setRecurInterval(1); + $recurrence->setRecurOnDay(Horde_Date::MASK_WEDNESDAY); + $recurrence->setRecurCount(5); + $recurrence->addCompletion(2026, 6, 24); + + $existing = new Nag_Task(); + $existing->due = strtotime('2026-07-08 12:00:00'); + $existing->recurrence = $recurrence; + $existing->name = 'Series G'; + $existing->uid = 'series-g'; + $existing->tasklist = 'tasklist1'; + $existing->tags = []; + + $message = new Horde_ActiveSync_Message_Task( + ['protocolversion' => Horde_ActiveSync::VERSION_TWELVE] + ); + $message->subject = 'Series G'; + $message->complete = false; + $message->utcduedate = new Horde_Date('2026-06-30T22:00:00.000Z'); + $message->duedate = new Horde_Date('2026-07-01T00:00:00.000Z'); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertFalse( + $task->recurrence->hasCompletion(2026, 7, 1) + ); + $this->assertEquals( + strtotime('2026-07-01'), + strtotime(date('Y-m-d', $task->due)) + ); + $this->assertFalse($task->completed); + } + + public function testActiveSyncFindMatchingCompletionUsesUtcFallback() + { + $due = strtotime('2026-06-24 12:00:00'); + $recurrence = new Horde_Date_Recurrence($due); + $recurrence->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $recurrence->setRecurInterval(1); + $recurrence->setRecurOnDay(Horde_Date::MASK_WEDNESDAY); + $recurrence->addCompletion(2026, 6, 30); + + $message = new Horde_ActiveSync_Message_Task( + ['protocolversion' => Horde_ActiveSync::VERSION_TWELVE] + ); + $message->utcduedate = new Horde_Date('2026-06-30T22:00:00.000Z'); + $message->duedate = new Horde_Date('2026-07-01T00:00:00.000Z'); + + $match = Nag_Task::activeSyncFindMatchingCompletion( + $recurrence, + $message + ); + + $this->assertNotNull($match); + $this->assertSame('20260630', sprintf( + '%04d%02d%02d', + (int) $match->year, + (int) $match->month, + (int) $match->mday + )); + } + + public function testFromASTaskOutlookDeadOccurComplete() + { + $existing = $this->_createWeeklySeries('2026-06-23', 5); + $existing->due = strtotime('2026-06-24 12:00:00'); + + $message = $this->_createMessage([ + 'complete' => true, + 'due' => '2026-06-23', + ]); + $recurrence = Horde_ActiveSync::messageFactory('TaskRecurrence'); + $recurrence->deadoccur = true; + $recurrence->type = Horde_ActiveSync_Message_Recurrence::TYPE_WEEKLY; + $message->recurrence = $recurrence; + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertTrue( + $task->recurrence->hasCompletion(2026, 6, 23) + ); + $this->assertFalse($task->completed); + } + + public function testFromASTaskMasterDueAdvanceAddsCompletion() + { + $existing = $this->_createWeeklySeries('2026-06-23', 5); + + $message = $this->_createMessage([ + 'complete' => false, + 'due' => '2026-06-24', + ]); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertTrue( + $task->recurrence->hasCompletion(2026, 6, 23) + ); + $this->assertEquals( + strtotime('2026-06-24 12:00:00'), + $task->due + ); + $this->assertFalse($task->completed); + } + + public function testFromASTaskIgnoresEpochDueOnMasterModify() + { + $existing = $this->_createWeeklySeries('2026-06-23', 5); + $existing->due = strtotime('2026-06-23 12:00:00'); + + $message = $this->_createMessage([ + 'complete' => false, + 'due' => '1970-01-01', + ]); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertFalse( + $task->recurrence->hasCompletion(2026, 6, 23) + ); + $this->assertEquals( + strtotime('2026-06-23 12:00:00'), + $task->due + ); + $this->assertFalse($task->completed); + } + + public function testToASTaskRecoversExportDueFromRecurrenceStart() + { + $task = $this->_createWeeklySeries('2026-06-23', 5); + $task->due = 2; + + $message = $task->toASTask(['protocolversion' => 2.5]); + + $this->assertEquals( + strtotime('2026-06-23 12:00:00'), + $message->utcduedate->timestamp() + ); + $this->assertSame( + strtotime('2026-06-23 12:00:00'), + $message->recurrence->start->timestamp() + ); + } + + public function testToggleCompleteAdvancesDueDate() + { + $task = $this->_createWeeklySeries('2026-06-23', 5); + $task->due = strtotime('2026-06-23 12:00:00'); + + $task->toggleComplete(); + + $this->assertEquals( + strtotime('2026-06-24 12:00:00'), + $task->due + ); + $this->assertFalse($task->completed); + } + + public function testToASTaskMidSeriesExportsIncompleteWithNextDue() + { + $task = $this->_createWeeklySeries('2026-06-23', 5); + $task->recurrence->addCompletion(2026, 6, 23); + $task->recurrence->addCompletion(2026, 6, 24); + + $message = $task->toASTask(['protocolversion' => 2.5]); + + $this->assertEquals( + Horde_ActiveSync_Message_Task::TASK_COMPLETE_FALSE, + $message->complete + ); + $this->assertEquals( + strtotime('2026-06-25 12:00:00'), + $message->utcduedate->timestamp() + ); + } + + public function testToASTaskFinalInstanceExportsComplete() + { + $task = $this->_createWeeklySeries('2026-06-23', 1); + $task->recurrence->addCompletion(2026, 6, 23); + $task->completed = true; + $task->completed_date = time(); + + $message = $task->toASTask(['protocolversion' => 2.5]); + + $this->assertEquals( + Horde_ActiveSync_Message_Task::TASK_COMPLETE_TRUE, + $message->complete + ); + } + + public function testGetRemainingOccurrenceCount() + { + $task = $this->_createWeeklySeries('2026-06-23', 5); + $this->assertSame(5, $task->getRemainingOccurrenceCount()); + + $task->recurrence->addCompletion(2026, 6, 23); + $task->recurrence->addCompletion(2026, 6, 24); + $this->assertSame(3, $task->getRemainingOccurrenceCount()); + } + + public function testToASTaskExportsRemainingOccurrenceCount() + { + $task = $this->_createWeeklySeries('2026-06-23', 5); + $task->recurrence->addCompletion(2026, 6, 23); + $task->recurrence->addCompletion(2026, 6, 24); + + $message = $task->toASTask(['protocolversion' => 2.5]); + + $this->assertSame(3, $message->recurrence->occurrences); + } + + public function testFromASTaskIgnoresCompletionBeyondSeriesCount() + { + $existing = $this->_createWeeklySeries('2026-06-23', 1); + $existing->due = strtotime('2026-06-23 12:00:00'); + $existing->recurrence->addCompletion(2026, 6, 23); + $existing->completed = true; + + $message = $this->_createMessage([ + 'complete' => true, + 'due' => '2026-06-30', + ]); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertTrue($existing->completed); + $this->assertEquals($existing->due, $task->due); + $this->assertTrue($task->completed); + } + + public function testFromASTaskFinalInstanceMarksSeriesComplete() + { + $existing = $this->_createWeeklySeries('2026-06-23', 1); + $existing->due = strtotime('2026-06-23 12:00:00'); + + $message = $this->_createMessage([ + 'complete' => true, + 'due' => '2026-06-23', + ]); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertTrue($task->completed); + $this->assertSame(0, $task->getRemainingOccurrenceCount()); + } + + public function testFromASTaskSeriesIMasterModifyThenCompleteThenUncomplete() + { + $due = strtotime('2026-06-24 12:00:00'); + $recurrence = new Horde_Date_Recurrence($due); + $recurrence->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $recurrence->setRecurInterval(1); + $recurrence->setRecurOnDay(Horde_Date::MASK_WEDNESDAY); + $recurrence->setRecurCount(5); + $recurrence->addCompletion(2026, 6, 24); + + $existing = new Nag_Task(); + $existing->due = strtotime('2026-07-01 12:00:00'); + $existing->recurrence = $recurrence; + $existing->name = 'Series I'; + $existing->uid = 'series-i'; + $existing->tasklist = 'tasklist1'; + $existing->tags = []; + + $masterMessage = $this->_createMessage([ + 'complete' => false, + 'due' => '2026-07-08', + 'recurrence' => true, + 'occurrences' => 3, + 'weekly' => true, + ]); + $masterMessage->subject = 'Series I'; + + $afterMaster = new Nag_Task(); + $afterMaster->fromASTask($masterMessage, $existing); + + $this->assertFalse( + $afterMaster->recurrence->hasCompletion(2026, 7, 1) + ); + $this->assertEquals( + strtotime('2026-07-08'), + strtotime(date('Y-m-d', $afterMaster->due)) + ); + + $completeMessage = $this->_createMessage([ + 'complete' => true, + 'due' => '2026-07-01', + ]); + $completeMessage->subject = 'Series I'; + $completeMessage->utcduedate = new Horde_Date('2026-06-30T22:00:00.000Z'); + $completeMessage->duedate = new Horde_Date('2026-07-01T00:00:00.000Z'); + + $afterComplete = new Nag_Task(); + $afterComplete->fromASTask($completeMessage, $afterMaster); + + $this->assertTrue( + $afterComplete->recurrence->hasCompletion(2026, 7, 1) + ); + $nextDue = $afterComplete->getNextDue(); + $this->assertNotNull($nextDue); + $this->assertEquals( + strtotime('2026-07-08'), + strtotime(date('Y-m-d', $nextDue->timestamp())) + ); + + $uncompleteMessage = $this->_createMessage([ + 'complete' => false, + 'due' => '2026-07-01', + ]); + $uncompleteMessage->subject = 'Series I'; + $uncompleteMessage->utcduedate = new Horde_Date('2026-06-30T22:00:00.000Z'); + $uncompleteMessage->duedate = new Horde_Date('2026-07-01T00:00:00.000Z'); + + $afterUncomplete = new Nag_Task(); + $afterUncomplete->fromASTask($uncompleteMessage, $afterComplete); + + $this->assertFalse( + $afterUncomplete->recurrence->hasCompletion(2026, 7, 1) + ); + $this->assertEquals( + strtotime('2026-07-01'), + strtotime(date('Y-m-d', $afterUncomplete->due)) + ); + $reopenedDue = $afterUncomplete->getNextDue(); + $this->assertNotNull($reopenedDue); + $this->assertEquals( + strtotime('2026-07-01'), + strtotime(date('Y-m-d', $reopenedDue->timestamp())) + ); + } + + public function testFromASTaskMasterModifyWithRecurrencePreservesCompletions() + { + $due = strtotime('2026-06-24 12:00:00'); + $recurrence = new Horde_Date_Recurrence($due); + $recurrence->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $recurrence->setRecurInterval(1); + $recurrence->setRecurOnDay(Horde_Date::MASK_WEDNESDAY); + $recurrence->setRecurCount(5); + $recurrence->addCompletion(2026, 6, 24); + + $existing = new Nag_Task(); + $existing->due = strtotime('2026-07-01 12:00:00'); + $existing->recurrence = $recurrence; + $existing->name = 'Series I'; + $existing->uid = 'series-i'; + $existing->tasklist = 'tasklist1'; + $existing->tags = []; + + $message = $this->_createMessage([ + 'complete' => false, + 'due' => '2026-07-08', + 'recurrence' => true, + 'occurrences' => 3, + 'weekly' => true, + ]); + $message->subject = 'Series I'; + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertTrue( + $task->recurrence->hasCompletion(2026, 6, 24) + ); + $this->assertFalse( + $task->recurrence->hasCompletion(2026, 7, 1) + ); + $this->assertEquals( + strtotime('2026-07-08'), + strtotime(date('Y-m-d', $task->due)) + ); + } + + public function testFromASTaskMasterModifyPreservesTotalSeriesCount() + { + $due = strtotime('2026-06-24 12:00:00'); + $recurrence = new Horde_Date_Recurrence($due); + $recurrence->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $recurrence->setRecurInterval(1); + $recurrence->setRecurOnDay(Horde_Date::MASK_WEDNESDAY); + $recurrence->setRecurCount(5); + $recurrence->addCompletion(2026, 6, 24); + $recurrence->addCompletion(2026, 7, 1); + + $existing = new Nag_Task(); + $existing->due = strtotime('2026-07-08 12:00:00'); + $existing->recurrence = $recurrence; + $existing->name = 'Series I'; + $existing->uid = 'series-i-count'; + $existing->tasklist = 'tasklist1'; + $existing->tags = []; + + // iOS sends the *remaining* count (3) as POOMTASKS:Occurrences. + $message = $this->_createMessage([ + 'complete' => false, + 'due' => '2026-07-08', + 'recurrence' => true, + 'occurrences' => 3, + 'weekly' => true, + ]); + $message->subject = 'Series I'; + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + // Total series count must remain 5, not be truncated to the remaining 3. + $this->assertSame(5, $task->recurrence->getRecurCount()); + $this->assertSame(3, $task->getRemainingOccurrenceCount()); + + // The full series must still be reachable through to the last instance. + $task->recurrence->addCompletion(2026, 7, 8); + $task->recurrence->addCompletion(2026, 7, 15); + $this->assertSame(1, $task->getRemainingOccurrenceCount()); + $task->recurrence->addCompletion(2026, 7, 22); + $this->assertSame(0, $task->getRemainingOccurrenceCount()); + } + + public function testFromASTaskUncompleteFinalInstanceViaMasterModify() + { + $due = strtotime('2026-06-24 12:00:00'); + $recurrence = new Horde_Date_Recurrence($due); + $recurrence->setRecurType(Horde_Date_Recurrence::RECUR_WEEKLY); + $recurrence->setRecurInterval(1); + $recurrence->setRecurOnDay(Horde_Date::MASK_WEDNESDAY); + $recurrence->setRecurCount(5); + $recurrence->addCompletion(2026, 6, 24); + $recurrence->addCompletion(2026, 7, 1); + $recurrence->addCompletion(2026, 7, 8); + $recurrence->addCompletion(2026, 7, 15); + $recurrence->addCompletion(2026, 7, 22); + + $existing = new Nag_Task(); + $existing->due = strtotime('2026-07-22 12:00:00'); + $existing->recurrence = $recurrence; + $existing->completed = true; + $existing->completed_date = time(); + $existing->name = 'Series I'; + $existing->uid = 'series-i-final'; + $existing->tasklist = 'tasklist1'; + $existing->tags = []; + + $message = $this->_createMessage([ + 'complete' => false, + 'due' => '2026-07-22', + 'recurrence' => true, + 'occurrences' => 1, + 'weekly' => true, + ]); + $message->subject = 'Series I'; + $message->utcduedate = new Horde_Date('2026-07-21T22:00:00.000Z'); + $message->duedate = new Horde_Date('2026-07-22T00:00:00.000Z'); + + $task = new Nag_Task(); + $task->fromASTask($message, $existing); + + $this->assertFalse($task->completed); + $this->assertNull($task->completed_date); + $this->assertFalse( + $task->recurrence->hasCompletion(2026, 7, 22) + ); + $this->assertEquals( + strtotime('2026-07-22'), + strtotime(date('Y-m-d', $task->due)) + ); + $nextDue = $task->getNextDue(); + $this->assertNotNull($nextDue); + $this->assertEquals( + strtotime('2026-07-22'), + strtotime(date('Y-m-d', $nextDue->timestamp())) + ); + } + + public function testSeriesIsFullyComplete() + { + $task = $this->_createWeeklySeries('2026-06-23', 5); + $this->assertFalse($task->seriesIsFullyComplete()); + + $task->recurrence->addCompletion(2026, 6, 23); + $this->assertFalse($task->seriesIsFullyComplete()); + + $single = $this->_createWeeklySeries('2026-06-23', 1); + $single->recurrence->addCompletion(2026, 6, 23); + $single->completed = true; + $this->assertTrue($single->seriesIsFullyComplete()); + } + + /** + * @param string $dueDate YYYY-MM-DD + * @param integer|null $count + * + * @return Nag_Task + */ + protected function _createWeeklySeries($dueDate, $count = null) + { + $due = strtotime($dueDate . ' 12:00:00'); + $recurrence = new Horde_Date_Recurrence($due); + $recurrence->setRecurType(Horde_Date_Recurrence::RECUR_DAILY); + if ($count !== null) { + $recurrence->setRecurCount($count); + } + + $task = new Nag_Task(); + $task->due = $due; + $task->recurrence = $recurrence; + $task->name = 'Series Test'; + $task->uid = 'series-uid'; + $task->tasklist = 'tasklist1'; + $task->tags = []; + + return $task; + } + + /** + * @param array $options + * + * @return Horde_ActiveSync_Message_Task + */ + protected function _createMessage(array $options = []) + { + $message = new Horde_ActiveSync_Message_Task( + ['protocolversion' => 2.5] + ); + $message->subject = 'Series Test'; + $message->complete = !empty($options['complete']); + + if (!empty($options['deadoccur'])) { + $message->deadoccur = true; + } + + if (!empty($options['due'])) { + $due = strtotime($options['due'] . ' 12:00:00'); + $message->utcduedate = new Horde_Date($due); + $message->duedate = clone $message->utcduedate; + } + + if (!empty($options['recurrence'])) { + $recurrence = Horde_ActiveSync::messageFactory('TaskRecurrence'); + if (!empty($options['weekly'])) { + $recurrence->type = Horde_ActiveSync_Message_Recurrence::TYPE_WEEKLY; + $recurrence->dayofweek = Horde_Date::MASK_WEDNESDAY; + } else { + $recurrence->type = Horde_ActiveSync_Message_Recurrence::TYPE_DAILY; + } + if (!empty($options['occurrences'])) { + $recurrence->occurrences = $options['occurrences']; + } + $message->recurrence = $recurrence; + } + + return $message; + } +} From f06c82d4c1b443f891c52e8a4188912c40ab99cb Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Wed, 24 Jun 2026 17:26:11 +0200 Subject: [PATCH 2/2] fix(nag): guard null getNextDue() in task view and summary block Recurring tasks whose series is fully complete (or whose due date is implausible) return null from Nag_Task::getNextDue(). The task detail view and the portal Summary block dereferenced the result directly, causing "Call to a member function timestamp() on null". Guard both call sites. --- lib/Block/Summary.php | 5 ++++- templates/view/task.inc | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/Block/Summary.php b/lib/Block/Summary.php index 11d07c9a..bc38c682 100644 --- a/lib/Block/Summary.php +++ b/lib/Block/Summary.php @@ -105,7 +105,10 @@ protected function _content() . ''; } foreach ($alarmList as $task) { - $differential = $task->getNextDue()->timestamp() - $_SERVER['REQUEST_TIME']; + if (!($nextDue = $task->getNextDue())) { + continue; + } + $differential = $nextDue->timestamp() - $_SERVER['REQUEST_TIME']; $key = $differential; while (isset($messages[$key])) { $key++; diff --git a/templates/view/task.inc b/templates/view/task.inc index 905abcf2..f9cfe6e1 100644 --- a/templates/view/task.inc +++ b/templates/view/task.inc @@ -49,7 +49,7 @@ : - due ? Nag::formatDate($task->getNextDue()->timestamp()) : '' ?> + due ? $task->getNextDue() : null; echo $nextDue ? Nag::formatDate($nextDue->timestamp()) : ''; ?> recurs()): ?>