From 2481741d892030c8fc1183fd4220f6e488e4e9bb Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 15:51:04 +0200 Subject: [PATCH 01/11] Refactor sync_lists handling in task management --- lib/Nag.php | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 174 insertions(+), 3 deletions(-) diff --git a/lib/Nag.php b/lib/Nag.php index f80546c7..1dc96ad7 100644 --- a/lib/Nag.php +++ b/lib/Nag.php @@ -590,9 +590,8 @@ public static function getDefaultTasklist($permission = Horde_Perms::SHOW) * * @return Horde_Share The new share. * - * Note: Does not update the sync_lists preference. Web-created lists are - * opt-in for ActiveSync via Nag preferences; EAS-created lists use - * Nag_Api::addTasklist() with synchronize => true. + * Note: Does not update sync_lists itself. The create-task-list form and + * Nag_Api::addTasklist() with synchronize => true call addTasklistToSyncLists(). */ public static function addTasklist(array $info, $display = true) { @@ -678,6 +677,8 @@ public static function deleteTasklist(Horde_Share_Object $tasklist) throw new Horde_Exception_PermissionDenied(_("You are not allowed to delete this task list.")); } + self::removeTasklistFromSyncLists($tasklist->getName()); + // Delete the task list. $storage = &$GLOBALS['injector']->getInstance('Nag_Factory_Driver')->create($tasklist->getName()); $result = $storage->deleteAll(); @@ -688,6 +689,9 @@ public static function deleteTasklist(Horde_Share_Object $tasklist) } catch (Horde_Share_Exception $e) { throw new Nag_Exception($e); } + + self::pruneActiveSyncTaskCache(); + self::touchActiveSyncDeviceCaches(); } /** @@ -1758,6 +1762,173 @@ protected static function _getOwner($task) return $owner; } + /** + * Add a task list to the sync_lists preference. + * + * @param string $tasklistId Task list share id. + */ + public static function addTasklistToSyncLists($tasklistId) + { + $sync = @unserialize($GLOBALS['prefs']->getValue('sync_lists')); + if (!is_array($sync)) { + $sync = []; + } + if (in_array($tasklistId, $sync, true)) { + return; + } + + $sync[] = $tasklistId; + $GLOBALS['prefs']->setValue('sync_lists', serialize(array_values($sync))); + } + + /** + * Drop cached task folder/collection mappings that are no longer synced. + * + * FolderSync state in storage is kept so the next client FolderSync can + * diff and emit FolderHierarchy:Remove. Pruning stale cache entries causes + * PING on those folders to fail with FolderGone, which prompts clients to + * run FolderSync (status FolderSync required). + * + * @param array|null $allowedShareIds Share ids that may remain cached; + * defaults to current getSyncLists(). + * + * @return boolean True if at least one device cache was updated. + * + * @throws Horde_ActiveSync_Exception + */ + public static function pruneActiveSyncTaskCache(array $allowedShareIds = null) + { + if (empty($GLOBALS['conf']['activesync']['enabled']) + || !$GLOBALS['prefs']->getValue('activesync_no_multiplex')) { + return false; + } + + $user = $GLOBALS['registry']->getAuth(); + if (!$user) { + return false; + } + + if ($allowedShareIds === null) { + $allowedShareIds = array_keys(self::getSyncLists()); + } + + $allowed = []; + foreach ($allowedShareIds as $tasklistId) { + $allowed[Horde_ActiveSync::CLASS_TASKS . ':' . $tasklistId] = true; + } + + $sm = $GLOBALS['injector']->getInstance('Horde_ActiveSyncState'); + $logger = $GLOBALS['injector']->getInstance('Horde_Log_Logger'); + $sm->setLogger($logger); + $devices = $sm->listDevices($user); + if (!count($devices)) { + return false; + } + + $updated = false; + foreach ($devices as $device) { + $devId = $device['device_id']; + $cache = new Horde_ActiveSync_SyncCache($sm, $devId, $user, $logger); + if (!count($cache->getFolders())) { + continue; + } + + $deviceUpdated = false; + foreach ($cache->getFolders() as $clientUid => $folder) { + if (($folder['class'] ?? '') !== Horde_ActiveSync::CLASS_TASKS) { + continue; + } + $backendId = $folder['serverid'] ?? ''; + if (empty($backendId) || !isset($allowed[$backendId])) { + $cache->deleteFolder($clientUid); + $deviceUpdated = true; + } + } + + foreach ($cache->getCollections(false) as $collectionId => $collection) { + if (($collection['class'] ?? '') !== Horde_ActiveSync::CLASS_TASKS) { + continue; + } + $backendId = $collection['serverid'] ?? ''; + if (empty($backendId) && isset($cache->getFolders()[$collectionId]['serverid'])) { + $backendId = $cache->getFolders()[$collectionId]['serverid']; + } + if (empty($backendId) || !isset($allowed[$backendId])) { + $cache->removeCollection($collectionId, true); + $deviceUpdated = true; + } + } + + if ($deviceUpdated) { + $cache->save(); + $updated = true; + } + } + + return $updated; + } + + /** + * Bump the ActiveSync cache timestamp on all devices for the current user. + * + * @return boolean True if at least one device cache was touched. + * + * @throws Horde_ActiveSync_Exception + */ + public static function touchActiveSyncDeviceCaches() + { + if (empty($GLOBALS['conf']['activesync']['enabled'])) { + return false; + } + + $user = $GLOBALS['registry']->getAuth(); + if (!$user) { + return false; + } + + $sm = $GLOBALS['injector']->getInstance('Horde_ActiveSyncState'); + $logger = $GLOBALS['injector']->getInstance('Horde_Log_Logger'); + $sm->setLogger($logger); + $devices = $sm->listDevices($user); + if (!count($devices)) { + return false; + } + + foreach ($devices as $device) { + $cache = new Horde_ActiveSync_SyncCache( + $sm, + $device['device_id'], + $user, + $logger + ); + $cache->updateTimestamp(); + $cache->save(); + } + + return true; + } + + /** + * Remove a task list from the sync_lists preference (no UI notification). + * + * @param string $tasklistId Task list share id. + */ + public static function removeTasklistFromSyncLists($tasklistId) + { + $sync = @unserialize($GLOBALS['prefs']->getValue('sync_lists')); + if (!is_array($sync)) { + return; + } + + $key = array_search($tasklistId, $sync); + if ($key === false) { + return; + } + + unset($sync[$key]); + $GLOBALS['prefs']->setValue('sync_lists', serialize(array_values($sync))); + } + /** * Returns the tasklists that should be used for syncing. * From cebe7cc04fe75b6f9fc8781f5b84ba4da16b42ed Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 15:52:29 +0200 Subject: [PATCH 02/11] Refactor synchronization of tasklist addition --- lib/Api.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/Api.php b/lib/Api.php index 22011474..9a0779af 100644 --- a/lib/Api.php +++ b/lib/Api.php @@ -160,9 +160,7 @@ public function addTasklist($name, $description = '', $color = '', array $params $name = $tasklist->getName(); if (!empty($params['synchronize'])) { - $sync = @unserialize($GLOBALS['prefs']->getValue('sync_lists')); - $sync[] = $name; - $GLOBALS['prefs']->setValue('sync_lists', serialize($sync)); + Nag::addTasklistToSyncLists($name); } return $name; From 7e60811453228bed36e5d9dc7783a0bd73a0eca0 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 15:54:11 +0200 Subject: [PATCH 03/11] Sync tasklist after adding it Add tasklist to sync lists after creation. --- lib/Form/CreateTaskList.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/Form/CreateTaskList.php b/lib/Form/CreateTaskList.php index 3b98e54b..03e66450 100644 --- a/lib/Form/CreateTaskList.php +++ b/lib/Form/CreateTaskList.php @@ -46,6 +46,9 @@ public function execute() $info[$key] = $this->_vars->get($key); } - return Nag::addTasklist($info); + $tasklist = Nag::addTasklist($info); + Nag::addTasklistToSyncLists($tasklist->getName()); + + return $tasklist; } } From a6aca43f7614bd78362856cb9c852bac53c4bd60 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 16:00:47 +0200 Subject: [PATCH 04/11] Refactor ActiveSync error handling and notifications --- config/prefs.php | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/config/prefs.php b/config/prefs.php index 89ea6d6b..6b42b8c5 100644 --- a/config/prefs.php +++ b/config/prefs.php @@ -312,21 +312,20 @@ $sync[] = $default; $GLOBALS['prefs']->setValue('sync_lists', serialize($sync)); } - if ($GLOBALS['conf']['activesync']['enabled'] && !$GLOBALS['prefs']->getValue('activesync_no_multiplex')) { + if ($GLOBALS['conf']['activesync']['enabled'] + && $GLOBALS['prefs']->getValue('activesync_no_multiplex')) { try { - $sm = $GLOBALS['injector']->getInstance('Horde_ActiveSyncState'); - $sm->setLogger($GLOBALS['injector']->getInstance('Horde_Log_Logger')); - $devices = $sm->listDevices($GLOBALS['registry']->getAuth()); - foreach ($devices as $device) { - $sm->removeState([ - 'devId' => $device['device_id'], - 'id' => Horde_Core_ActiveSync_Driver::TASKS_FOLDER_UID, - 'user' => $GLOBALS['registry']->getAuth(), - ]); - } - $GLOBALS['notification']->push(_("All state removed for your ActiveSync devices. They will resynchronize next time they connect to the server.")); + Nag::pruneActiveSyncTaskCache(); + Nag::touchActiveSyncDeviceCaches(); + $GLOBALS['notification']->push( + _("Task list sync preferences were saved. Your device will update task list folders on the next folder sync."), + 'horde.message' + ); } catch (Horde_ActiveSync_Exception $e) { - $GLOBALS['notification']->push(_("There was an error communicating with the ActiveSync server: %s"), $e->getMessage(), 'horde.error'); + $GLOBALS['notification']->push( + sprintf(_("There was an error communicating with the ActiveSync server: %s"), $e->getMessage()), + 'horde.error' + ); } } }, From ea0b41d7fb1dffdeb34d5582e8fb4dee8863e78d Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 17:02:04 +0200 Subject: [PATCH 05/11] Enhance tasklist management with sync functionality Updated addTasklist method to include sync parameter and modified related methods to handle tasklist preferences more effectively. --- lib/Nag.php | 211 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 186 insertions(+), 25 deletions(-) diff --git a/lib/Nag.php b/lib/Nag.php index 1dc96ad7..f7e7df52 100644 --- a/lib/Nag.php +++ b/lib/Nag.php @@ -586,14 +586,12 @@ public static function getDefaultTasklist($permission = Horde_Perms::SHOW) * Creates a new share. * * @param array $info Hash with tasklist information. - * @param boolean $display Add the new tasklist to display_tasklists + * @param boolean $display Add the new tasklist to display_tasklists. + * @param boolean $sync Add the new tasklist to sync_lists (ActiveSync). * * @return Horde_Share The new share. - * - * Note: Does not update sync_lists itself. The create-task-list form and - * Nag_Api::addTasklist() with synchronize => true call addTasklistToSyncLists(). */ - public static function addTasklist(array $info, $display = true) + public static function addTasklist(array $info, $display = true, $sync = false) { try { $tasklist = $GLOBALS['nag_shares']->newShare( @@ -619,8 +617,10 @@ public static function addTasklist(array $info, $display = true) } if ($display) { - $GLOBALS['display_tasklists'][] = $tasklist->getName(); - $GLOBALS['prefs']->setValue('display_tasklists', serialize($GLOBALS['display_tasklists'])); + self::addTasklistToDisplayListsPref($tasklist->getName()); + } + if ($sync) { + self::addTasklistToSyncLists($tasklist->getName()); } return $tasklist; @@ -677,6 +677,7 @@ public static function deleteTasklist(Horde_Share_Object $tasklist) throw new Horde_Exception_PermissionDenied(_("You are not allowed to delete this task list.")); } + self::removeTasklistFromDisplayListsPref($tasklist->getName()); self::removeTasklistFromSyncLists($tasklist->getName()); // Delete the task list. @@ -690,8 +691,7 @@ public static function deleteTasklist(Horde_Share_Object $tasklist) throw new Nag_Exception($e); } - self::pruneActiveSyncTaskCache(); - self::touchActiveSyncDeviceCaches(); + self::removeActiveSyncTaskListFromDeviceCache($tasklist->getName()); } /** @@ -995,10 +995,12 @@ public static function initialize() $_SERVER['REQUEST_TIME'] = time(); } + self::refreshWebSessionState(); + // Update the preference for what task lists to display. If the user // doesn't have any selected task lists for view then fall back to // some available list. - $GLOBALS['display_tasklists'] = @unserialize($GLOBALS['prefs']->getValue('display_tasklists')); + $GLOBALS['display_tasklists'] = self::_getPrefList('display_tasklists'); if (!$GLOBALS['display_tasklists']) { $GLOBALS['display_tasklists'] = []; } @@ -1762,6 +1764,80 @@ protected static function _getOwner($task) return $owner; } + /** + * Reload Nag state that is cached in the web PHP session. + * + * ActiveSync (and other clients) update the database in their own requests; + * the browser session may still hold stale Horde_Prefs and Horde_Share list + * caches until logout. + */ + public static function refreshWebSessionState() + { + if ($GLOBALS['registry']->getApp() !== 'nag') { + return; + } + + $GLOBALS['prefs']->cleanup(); + $GLOBALS['prefs']->retrieve(); + + if (!empty($GLOBALS['nag_shares'])) { + $GLOBALS['nag_shares']->expireListCache(); + } + } + + /** + * Write preference changes to storage immediately. + * + * ActiveSync requests use a separate PHP session; the web UI may otherwise + * read stale values from the session prefs cache until reload. + */ + public static function persistPrefs() + { + $GLOBALS['prefs']->store(); + } + + /** + * Add a task list to the display_tasklists preference. + * + * @param string $tasklistId Task list share id. + */ + public static function addTasklistToDisplayListsPref($tasklistId) + { + $display = self::_getPrefList('display_tasklists'); + if (in_array($tasklistId, $display, true)) { + return; + } + + $display[] = $tasklistId; + self::_setPrefList('display_tasklists', $display); + $GLOBALS['display_tasklists'] = $display; + } + + /** + * Remove a task list from the display_tasklists preference. + * + * @param string $tasklistId Task list share id. + */ + public static function removeTasklistFromDisplayListsPref($tasklistId) + { + $display = self::_getPrefList('display_tasklists'); + $key = array_search($tasklistId, $display, true); + if ($key === false) { + return; + } + + unset($display[$key]); + $display = array_values($display); + self::_setPrefList('display_tasklists', $display); + if (isset($GLOBALS['display_tasklists']) && is_array($GLOBALS['display_tasklists'])) { + $gkey = array_search($tasklistId, $GLOBALS['display_tasklists'], true); + if ($gkey !== false) { + unset($GLOBALS['display_tasklists'][$gkey]); + $GLOBALS['display_tasklists'] = array_values($GLOBALS['display_tasklists']); + } + } + } + /** * Add a task list to the sync_lists preference. * @@ -1769,25 +1845,93 @@ protected static function _getOwner($task) */ public static function addTasklistToSyncLists($tasklistId) { - $sync = @unserialize($GLOBALS['prefs']->getValue('sync_lists')); - if (!is_array($sync)) { - $sync = []; - } + $sync = self::_getPrefList('sync_lists'); if (in_array($tasklistId, $sync, true)) { return; } $sync[] = $tasklistId; - $GLOBALS['prefs']->setValue('sync_lists', serialize(array_values($sync))); + self::_setPrefList('sync_lists', $sync); + } + + /** + * Remove one task list from per-device ActiveSync caches. + * + * Used when a list is deleted. Only the matching folder/collection entries + * are removed so other task folders keep their synckeys for PING. During + * FolderDelete, ActiveSync also calls deleteFolderFromHierarchy() for the + * hierarchy uid after the backend delete returns. + * + * @param string $tasklistId Task list share id. + * + * @return boolean True if at least one device cache was updated. + * + * @throws Horde_ActiveSync_Exception + */ + public static function removeActiveSyncTaskListFromDeviceCache($tasklistId) + { + if (empty($GLOBALS['conf']['activesync']['enabled']) + || !$GLOBALS['prefs']->getValue('activesync_no_multiplex')) { + return false; + } + + $user = $GLOBALS['registry']->getAuth(); + if (!$user) { + return false; + } + + $backendId = Horde_ActiveSync::CLASS_TASKS . ':' . $tasklistId; + $sm = $GLOBALS['injector']->getInstance('Horde_ActiveSyncState'); + $logger = $GLOBALS['injector']->getInstance('Horde_Log_Logger'); + $sm->setLogger($logger); + $devices = $sm->listDevices($user); + if (!count($devices)) { + return false; + } + + $updated = false; + foreach ($devices as $device) { + $cache = new Horde_ActiveSync_SyncCache($sm, $device['device_id'], $user, $logger); + $deviceUpdated = false; + + foreach ($cache->getFolders() as $clientUid => $folder) { + if (($folder['class'] ?? '') !== Horde_ActiveSync::CLASS_TASKS) { + continue; + } + if (($folder['serverid'] ?? '') === $backendId) { + $cache->deleteFolder($clientUid); + $deviceUpdated = true; + } + } + + foreach ($cache->getCollections(false) as $collectionId => $collection) { + if (($collection['class'] ?? '') !== Horde_ActiveSync::CLASS_TASKS) { + continue; + } + $collBackendId = $collection['serverid'] ?? ''; + if ($collBackendId === $backendId + || $collectionId === $backendId + || $collectionId === $tasklistId) { + $cache->removeCollection($collectionId, true); + $deviceUpdated = true; + } + } + + if ($deviceUpdated) { + $cache->save(); + $updated = true; + } + } + + return $updated; } /** * Drop cached task folder/collection mappings that are no longer synced. * + * Intended for sync_lists preference changes, not for single list deletes. * FolderSync state in storage is kept so the next client FolderSync can - * diff and emit FolderHierarchy:Remove. Pruning stale cache entries causes - * PING on those folders to fail with FolderGone, which prompts clients to - * run FolderSync (status FolderSync required). + * diff and emit FolderHierarchy:Remove. * * @param array|null $allowedShareIds Share ids that may remain cached; * defaults to current getSyncLists(). @@ -1915,18 +2059,35 @@ public static function touchActiveSyncDeviceCaches() */ public static function removeTasklistFromSyncLists($tasklistId) { - $sync = @unserialize($GLOBALS['prefs']->getValue('sync_lists')); - if (!is_array($sync)) { - return; - } - - $key = array_search($tasklistId, $sync); + $sync = self::_getPrefList('sync_lists'); + $key = array_search($tasklistId, $sync, true); if ($key === false) { return; } unset($sync[$key]); - $GLOBALS['prefs']->setValue('sync_lists', serialize(array_values($sync))); + self::_setPrefList('sync_lists', array_values($sync)); + } + + /** + * @param string $pref Preference name. + * + * @return array List of share ids. + */ + protected static function _getPrefList($pref) + { + $list = @unserialize($GLOBALS['prefs']->getValue($pref)); + + return is_array($list) ? array_values($list) : []; + } + + /** + * @param string $pref Preference name. + * @param array $list List of share ids. + */ + protected static function _setPrefList($pref, array $list) + { + $GLOBALS['prefs']->setValue($pref, serialize(array_values($list))); } /** From c878d05b6641fcb6629ae6281ebe0ad77602e78a Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 17:02:32 +0200 Subject: [PATCH 06/11] Enhance addTasklist with synchronization and persistence Refactor addTasklist to include sync option and persist preferences. --- lib/Api.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/Api.php b/lib/Api.php index 9a0779af..c1251094 100644 --- a/lib/Api.php +++ b/lib/Api.php @@ -156,14 +156,14 @@ public function getTasklist($name) */ public function addTasklist($name, $description = '', $color = '', array $params = []) { - $tasklist = Nag::addTasklist(['name' => $name, 'description' => $description, 'color' => $color]); - - $name = $tasklist->getName(); - if (!empty($params['synchronize'])) { - Nag::addTasklistToSyncLists($name); - } + $tasklist = Nag::addTasklist( + ['name' => $name, 'description' => $description, 'color' => $color], + true, + !empty($params['synchronize']) + ); + Nag::persistPrefs(); - return $name; + return $tasklist->getName(); } /** @@ -272,7 +272,8 @@ public function updateAttendee($response, $sender = null) public function deleteTasklist($id) { $tasklist = $GLOBALS['nag_shares']->getShare($id); - return Nag::deleteTasklist($tasklist); + Nag::deleteTasklist($tasklist); + Nag::persistPrefs(); } /** From 1cb9d4e58fe9db570261311890610b998d167a1c Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 17:03:05 +0200 Subject: [PATCH 07/11] Refactor tasklist creation to include sync options --- lib/Form/CreateTaskList.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/Form/CreateTaskList.php b/lib/Form/CreateTaskList.php index 03e66450..465be87d 100644 --- a/lib/Form/CreateTaskList.php +++ b/lib/Form/CreateTaskList.php @@ -46,9 +46,6 @@ public function execute() $info[$key] = $this->_vars->get($key); } - $tasklist = Nag::addTasklist($info); - Nag::addTasklistToSyncLists($tasklist->getName()); - - return $tasklist; + return Nag::addTasklist($info, true, true); } } From 964578c4273b6655aedddb6e5cdb3c5913ef14f6 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 18:56:51 +0200 Subject: [PATCH 08/11] Update Nag.php --- lib/Nag.php | 197 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 168 insertions(+), 29 deletions(-) diff --git a/lib/Nag.php b/lib/Nag.php index f7e7df52..33618b88 100644 --- a/lib/Nag.php +++ b/lib/Nag.php @@ -621,6 +621,9 @@ public static function addTasklist(array $info, $display = true, $sync = false) } if ($sync) { self::addTasklistToSyncLists($tasklist->getName()); + self::notifyActiveSyncOfTaskListChange(); + } elseif ($display) { + self::persistPrefs(); } return $tasklist; @@ -659,6 +662,8 @@ public static function updateTasklist(Horde_Share_Object $tasklist, array $info) } catch (Horde_Share_Exception $e) { throw new Nag_Exception(sprintf(_("Unable to save task list \"%s\": %s"), $info['name'], $e->getMessage())); } + + self::notifyActiveSyncOfTaskListChange(); } /** @@ -691,7 +696,11 @@ public static function deleteTasklist(Horde_Share_Object $tasklist) throw new Nag_Exception($e); } - self::removeActiveSyncTaskListFromDeviceCache($tasklist->getName()); + // Do not remove folder/collection mappings from the device cache here. + // The iPhone may still PING/SYNC that folder until FolderSync delivers + // FolderHierarchy:Remove. notifyActiveSyncOfTaskListChange() only + // invalidates hierarchy; FolderSync diffs foldersync state vs getFolderList(). + self::notifyActiveSyncOfTaskListChange(); } /** @@ -1796,6 +1805,145 @@ public static function persistPrefs() $GLOBALS['prefs']->store(); } + /** + * Persist task list prefs and wake ActiveSync after web-side list changes. + * + * Folder hierarchy updates are delivered on the device's next FolderSync + * (there is no server push for new folders). This writes sync_lists to the + * database immediately and marks device caches so the next SYNC returns + * FolderSync required (status 12). + * + * Stored foldersync state in the database is left intact. Clearing it + * causes a synckey mismatch that makes Horde wipe the entire device folder + * cache and breaks ongoing PING/SYNC. + * + * @return boolean True if at least one device was notified. + * + * @throws Horde_ActiveSync_Exception + */ + public static function notifyActiveSyncOfTaskListChange() + { + if (!self::_isActiveSyncEnabled()) { + return false; + } + + self::persistPrefs(); + + if (!empty($GLOBALS['nag_shares'])) { + $GLOBALS['nag_shares']->expireListCache(); + } + + // Do not prune the device folder cache here. Pruning removed entries + // before FolderSync breaks PING on those folder ids (status 132). Cache + // cleanup for manual sync_lists changes stays in config/prefs.php + // on_change; FolderSync Remove updates cache after the device applies it. + $updated = self::requestActiveSyncFolderHierarchySync(); + + if ($updated + && $GLOBALS['registry']->getApp() === 'nag' + && !empty($GLOBALS['notification'])) { + $GLOBALS['notification']->push( + _("Task list change saved. Your device will update task list folders on the next sync."), + 'horde.message' + ); + } + + return $updated; + } + + /** + * Tell all of a user's devices to run FolderSync on the next request. + * + * Clears only the hierarchy synckey in the device cache (same approach as + * Horde_ActiveSync_State when invalidating foldersync). Folder entries and + * collection synckeys in the cache are kept so PING still resolves folder + * ids and Add/Remove diffs in stored foldersync state still work. + * + * @return boolean True if at least one device cache was updated. + * + * @throws Horde_ActiveSync_Exception + */ + public static function requestActiveSyncFolderHierarchySync() + { + if (!self::_isActiveSyncEnabled() + || !$GLOBALS['prefs']->getValue('activesync_no_multiplex')) { + return false; + } + + $devices = self::_listActiveSyncDevicesForUser(); + if (!count($devices)) { + return false; + } + + $sm = $GLOBALS['injector']->getInstance('Horde_ActiveSyncState'); + $logger = $GLOBALS['injector']->getInstance('Horde_Log_Logger'); + $sm->setLogger($logger); + + $updated = false; + foreach ($devices as $device) { + $cache = new Horde_ActiveSync_SyncCache( + $sm, + $device['device_id'], + $device['device_user'], + $logger + ); + if (!count($cache->getFolders()) && !count($cache->getCollections(false))) { + continue; + } + + $cache->hierarchy = '0'; + $cache->updateTimestamp(); + $cache->save(); + $updated = true; + } + + return $updated; + } + + /** + * @return boolean + */ + protected static function _isActiveSyncEnabled() + { + return !empty($GLOBALS['conf']['activesync']['enabled']); + } + + /** + * List ActiveSync devices for the logged-in user. + * + * Tries both the Horde auth id and the original login id so device rows + * registered under either form are found. + * + * @return array Device rows from Horde_ActiveSync_State::listDevices(). + * + * @throws Horde_ActiveSync_Exception + */ + protected static function _listActiveSyncDevicesForUser() + { + $registry = $GLOBALS['registry']; + $userIds = array_unique(array_filter([ + $registry->getAuth(), + $registry->getAuth('original'), + ])); + if (!count($userIds)) { + return []; + } + + $sm = $GLOBALS['injector']->getInstance('Horde_ActiveSyncState'); + $logger = $GLOBALS['injector']->getInstance('Horde_Log_Logger'); + $sm->setLogger($logger); + + $devices = []; + foreach ($userIds as $userId) { + foreach ($sm->listDevices($userId) as $device) { + $key = $device['device_id'] . "\0" . $device['device_user']; + $devices[$key] = $device; + } + } + + return array_values($devices); + } + /** * Add a task list to the display_tasklists preference. * @@ -1857,10 +2005,10 @@ public static function addTasklistToSyncLists($tasklistId) /** * Remove one task list from per-device ActiveSync caches. * - * Used when a list is deleted. Only the matching folder/collection entries - * are removed so other task folders keep their synckeys for PING. During - * FolderDelete, ActiveSync also calls deleteFolderFromHierarchy() for the - * hierarchy uid after the backend delete returns. + * Not used for web-side deletes (see deleteTasklist()). Intended for + * exceptional cleanup; normal list removal is delivered via FolderSync + * after requestActiveSyncFolderHierarchySync(). During FolderDelete, + * ActiveSync also calls deleteFolderFromHierarchy() for the hierarchy uid. * * @param string $tasklistId Task list share id. * @@ -1870,28 +2018,28 @@ public static function addTasklistToSyncLists($tasklistId) */ public static function removeActiveSyncTaskListFromDeviceCache($tasklistId) { - if (empty($GLOBALS['conf']['activesync']['enabled']) + if (!self::_isActiveSyncEnabled() || !$GLOBALS['prefs']->getValue('activesync_no_multiplex')) { return false; } - $user = $GLOBALS['registry']->getAuth(); - if (!$user) { - return false; - } - $backendId = Horde_ActiveSync::CLASS_TASKS . ':' . $tasklistId; $sm = $GLOBALS['injector']->getInstance('Horde_ActiveSyncState'); $logger = $GLOBALS['injector']->getInstance('Horde_Log_Logger'); $sm->setLogger($logger); - $devices = $sm->listDevices($user); + $devices = self::_listActiveSyncDevicesForUser(); if (!count($devices)) { return false; } $updated = false; foreach ($devices as $device) { - $cache = new Horde_ActiveSync_SyncCache($sm, $device['device_id'], $user, $logger); + $cache = new Horde_ActiveSync_SyncCache( + $sm, + $device['device_id'], + $device['device_user'], + $logger + ); $deviceUpdated = false; foreach ($cache->getFolders() as $clientUid => $folder) { @@ -1942,18 +2090,13 @@ public static function removeActiveSyncTaskListFromDeviceCache($tasklistId) */ public static function pruneActiveSyncTaskCache(array $allowedShareIds = null) { - if (empty($GLOBALS['conf']['activesync']['enabled']) + if (!self::_isActiveSyncEnabled() || !$GLOBALS['prefs']->getValue('activesync_no_multiplex')) { return false; } - $user = $GLOBALS['registry']->getAuth(); - if (!$user) { - return false; - } - if ($allowedShareIds === null) { - $allowedShareIds = array_keys(self::getSyncLists()); + $allowedShareIds = array_values(self::getSyncLists()); } $allowed = []; @@ -1964,7 +2107,7 @@ public static function pruneActiveSyncTaskCache(array $allowedShareIds = null) $sm = $GLOBALS['injector']->getInstance('Horde_ActiveSyncState'); $logger = $GLOBALS['injector']->getInstance('Horde_Log_Logger'); $sm->setLogger($logger); - $devices = $sm->listDevices($user); + $devices = self::_listActiveSyncDevicesForUser(); if (!count($devices)) { return false; } @@ -1972,6 +2115,7 @@ public static function pruneActiveSyncTaskCache(array $allowedShareIds = null) $updated = false; foreach ($devices as $device) { $devId = $device['device_id']; + $user = $device['device_user']; $cache = new Horde_ActiveSync_SyncCache($sm, $devId, $user, $logger); if (!count($cache->getFolders())) { continue; @@ -2021,19 +2165,14 @@ public static function pruneActiveSyncTaskCache(array $allowedShareIds = null) */ public static function touchActiveSyncDeviceCaches() { - if (empty($GLOBALS['conf']['activesync']['enabled'])) { - return false; - } - - $user = $GLOBALS['registry']->getAuth(); - if (!$user) { + if (!self::_isActiveSyncEnabled()) { return false; } $sm = $GLOBALS['injector']->getInstance('Horde_ActiveSyncState'); $logger = $GLOBALS['injector']->getInstance('Horde_Log_Logger'); $sm->setLogger($logger); - $devices = $sm->listDevices($user); + $devices = self::_listActiveSyncDevicesForUser(); if (!count($devices)) { return false; } @@ -2042,7 +2181,7 @@ public static function touchActiveSyncDeviceCaches() $cache = new Horde_ActiveSync_SyncCache( $sm, $device['device_id'], - $user, + $device['device_user'], $logger ); $cache->updateTimestamp(); From 208c1598a8610569fc6911d816d02f15496a8dee Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 19:12:14 +0200 Subject: [PATCH 09/11] Update Nag.php From c855c3d2ad2b9cb4599fbed1f54e3c99cb7cfc38 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 19:23:34 +0200 Subject: [PATCH 10/11] Refactor ActiveSync task list cache handling Refactor ActiveSync task list cache removal logic to keep folder cache entries while dropping collection state. --- lib/Nag.php | 95 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 16 deletions(-) diff --git a/lib/Nag.php b/lib/Nag.php index 33618b88..26fed0b7 100644 --- a/lib/Nag.php +++ b/lib/Nag.php @@ -696,10 +696,9 @@ public static function deleteTasklist(Horde_Share_Object $tasklist) throw new Nag_Exception($e); } - // Do not remove folder/collection mappings from the device cache here. - // The iPhone may still PING/SYNC that folder until FolderSync delivers - // FolderHierarchy:Remove. notifyActiveSyncOfTaskListChange() only - // invalidates hierarchy; FolderSync diffs foldersync state vs getFolderList(). + // Drop collection/PING state only; keep folder cache entries so FolderSync + // can still emit FolderHierarchy:Remove for this list's client folder id. + self::removeActiveSyncTaskListCollectionsFromDeviceCache($tasklist->getName()); self::notifyActiveSyncOfTaskListChange(); } @@ -2003,7 +2002,51 @@ public static function addTasklistToSyncLists($tasklistId) } /** - * Remove one task list from per-device ActiveSync caches. + * Remove per-device SYNC/PING collection entries for one task list. + * + * Folder cache mappings are kept so FolderSync can still deliver + * FolderHierarchy:Remove after a web-side delete. + * + * @param string $tasklistId Task list share id. + * + * @return boolean True if at least one device cache was updated. + * + * @throws Horde_ActiveSync_Exception + */ + public static function removeActiveSyncTaskListCollectionsFromDeviceCache($tasklistId) + { + if (!self::_isActiveSyncEnabled() + || !$GLOBALS['prefs']->getValue('activesync_no_multiplex')) { + return false; + } + + $sm = $GLOBALS['injector']->getInstance('Horde_ActiveSyncState'); + $logger = $GLOBALS['injector']->getInstance('Horde_Log_Logger'); + $sm->setLogger($logger); + $devices = self::_listActiveSyncDevicesForUser(); + if (!count($devices)) { + return false; + } + + $updated = false; + foreach ($devices as $device) { + $cache = new Horde_ActiveSync_SyncCache( + $sm, + $device['device_id'], + $device['device_user'], + $logger + ); + if (self::_purgeActiveSyncTaskListCollections($cache, $tasklistId)) { + $cache->save(); + $updated = true; + } + } + + return $updated; + } + + /** + * Remove one task list from per-device ActiveSync caches (folders and collections). * * Not used for web-side deletes (see deleteTasklist()). Intended for * exceptional cleanup; normal list removal is delivered via FolderSync @@ -2052,17 +2095,8 @@ public static function removeActiveSyncTaskListFromDeviceCache($tasklistId) } } - foreach ($cache->getCollections(false) as $collectionId => $collection) { - if (($collection['class'] ?? '') !== Horde_ActiveSync::CLASS_TASKS) { - continue; - } - $collBackendId = $collection['serverid'] ?? ''; - if ($collBackendId === $backendId - || $collectionId === $backendId - || $collectionId === $tasklistId) { - $cache->removeCollection($collectionId, true); - $deviceUpdated = true; - } + if (self::_purgeActiveSyncTaskListCollections($cache, $tasklistId)) { + $deviceUpdated = true; } if ($deviceUpdated) { @@ -2074,6 +2108,35 @@ public static function removeActiveSyncTaskListFromDeviceCache($tasklistId) return $updated; } + /** + * @param Horde_ActiveSync_SyncCache $cache + * @param string $tasklistId + * + * @return boolean + */ + protected static function _purgeActiveSyncTaskListCollections( + Horde_ActiveSync_SyncCache $cache, + $tasklistId + ) { + $backendId = Horde_ActiveSync::CLASS_TASKS . ':' . $tasklistId; + $updated = false; + + foreach ($cache->getCollections(false) as $collectionId => $collection) { + if (($collection['class'] ?? '') !== Horde_ActiveSync::CLASS_TASKS) { + continue; + } + $collBackendId = $collection['serverid'] ?? ''; + if ($collBackendId === $backendId + || $collectionId === $backendId + || $collectionId === $tasklistId) { + $cache->removeCollection($collectionId, true); + $updated = true; + } + } + + return $updated; + } + /** * Drop cached task folder/collection mappings that are no longer synced. * From 28bea6e90bbe9e9376db95e686e5e6e2dd6bcaa2 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 19:24:02 +0200 Subject: [PATCH 11/11] Remove persistPrefs() calls from tasklist methods Removed calls to persistPrefs() after tasklist operations. --- lib/Api.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/Api.php b/lib/Api.php index c1251094..69393dc3 100644 --- a/lib/Api.php +++ b/lib/Api.php @@ -161,7 +161,6 @@ public function addTasklist($name, $description = '', $color = '', array $params true, !empty($params['synchronize']) ); - Nag::persistPrefs(); return $tasklist->getName(); } @@ -273,7 +272,6 @@ public function deleteTasklist($id) { $tasklist = $GLOBALS['nag_shares']->getShare($id); Nag::deleteTasklist($tasklist); - Nag::persistPrefs(); } /**