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' + ); } } }, diff --git a/lib/Api.php b/lib/Api.php index 22011474..69393dc3 100644 --- a/lib/Api.php +++ b/lib/Api.php @@ -156,16 +156,13 @@ 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'])) { - $sync = @unserialize($GLOBALS['prefs']->getValue('sync_lists')); - $sync[] = $name; - $GLOBALS['prefs']->setValue('sync_lists', serialize($sync)); - } + $tasklist = Nag::addTasklist( + ['name' => $name, 'description' => $description, 'color' => $color], + true, + !empty($params['synchronize']) + ); - return $name; + return $tasklist->getName(); } /** @@ -274,7 +271,7 @@ public function updateAttendee($response, $sender = null) public function deleteTasklist($id) { $tasklist = $GLOBALS['nag_shares']->getShare($id); - return Nag::deleteTasklist($tasklist); + Nag::deleteTasklist($tasklist); } /** diff --git a/lib/Form/CreateTaskList.php b/lib/Form/CreateTaskList.php index 3b98e54b..465be87d 100644 --- a/lib/Form/CreateTaskList.php +++ b/lib/Form/CreateTaskList.php @@ -46,6 +46,6 @@ public function execute() $info[$key] = $this->_vars->get($key); } - return Nag::addTasklist($info); + return Nag::addTasklist($info, true, true); } } diff --git a/lib/Nag.php b/lib/Nag.php index f80546c7..26fed0b7 100644 --- a/lib/Nag.php +++ b/lib/Nag.php @@ -586,15 +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 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. */ - public static function addTasklist(array $info, $display = true) + public static function addTasklist(array $info, $display = true, $sync = false) { try { $tasklist = $GLOBALS['nag_shares']->newShare( @@ -620,8 +617,13 @@ 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()); + self::notifyActiveSyncOfTaskListChange(); + } elseif ($display) { + self::persistPrefs(); } return $tasklist; @@ -660,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(); } /** @@ -678,6 +682,9 @@ 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. $storage = &$GLOBALS['injector']->getInstance('Nag_Factory_Driver')->create($tasklist->getName()); $result = $storage->deleteAll(); @@ -688,6 +695,11 @@ public static function deleteTasklist(Horde_Share_Object $tasklist) } catch (Horde_Share_Exception $e) { throw new Nag_Exception($e); } + + // 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(); } /** @@ -991,10 +1003,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'] = []; } @@ -1758,6 +1772,526 @@ 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(); + } + + /** + * 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. + * + * @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. + * + * @param string $tasklistId Task list share id. + */ + public static function addTasklistToSyncLists($tasklistId) + { + $sync = self::_getPrefList('sync_lists'); + if (in_array($tasklistId, $sync, true)) { + return; + } + + $sync[] = $tasklistId; + self::_setPrefList('sync_lists', $sync); + } + + /** + * 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 + * after requestActiveSyncFolderHierarchySync(). During FolderDelete, + * ActiveSync also calls deleteFolderFromHierarchy() for the hierarchy uid. + * + * @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 (!self::_isActiveSyncEnabled() + || !$GLOBALS['prefs']->getValue('activesync_no_multiplex')) { + 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 = 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 + ); + $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; + } + } + + if (self::_purgeActiveSyncTaskListCollections($cache, $tasklistId)) { + $deviceUpdated = true; + } + + if ($deviceUpdated) { + $cache->save(); + $updated = true; + } + } + + 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. + * + * 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. + * + * @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 (!self::_isActiveSyncEnabled() + || !$GLOBALS['prefs']->getValue('activesync_no_multiplex')) { + return false; + } + + if ($allowedShareIds === null) { + $allowedShareIds = array_values(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 = self::_listActiveSyncDevicesForUser(); + if (!count($devices)) { + return false; + } + + $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; + } + + $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 (!self::_isActiveSyncEnabled()) { + 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; + } + + foreach ($devices as $device) { + $cache = new Horde_ActiveSync_SyncCache( + $sm, + $device['device_id'], + $device['device_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 = self::_getPrefList('sync_lists'); + $key = array_search($tasklistId, $sync, true); + if ($key === false) { + return; + } + + unset($sync[$key]); + 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))); + } + /** * Returns the tasklists that should be used for syncing. *