From 52675388a394854536e2374e149adc7c8ca67181 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Wed, 26 Sep 2012 14:30:47 +0000 Subject: [PATCH] * CalDAV/CardDAV: sync-collection report for all apps allowing a more efficient sync --- .../inc/class.addressbook_groupdav.inc.php | 45 +++++---- calendar/inc/class.calendar_bo.inc.php | 4 +- calendar/inc/class.calendar_groupdav.inc.php | 97 ++++++++++++++++++- calendar/inc/class.calendar_ical.inc.php | 2 + calendar/inc/class.calendar_so.inc.php | 11 ++- infolog/inc/class.infolog_groupdav.inc.php | 37 +++---- phpgwapi/inc/class.groupdav_handler.inc.php | 44 +++++++-- 7 files changed, 187 insertions(+), 53 deletions(-) diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index ba886a0cc9..94a2feac74 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -149,18 +149,24 @@ class addressbook_groupdav extends groupdav_handler // rfc 6578 sync-collection report: filter for sync-token is already set in _report_filters if ($options['root']['name'] == 'sync-collection') { - // query sync-token before result, so changed happening while result get queried are not lost - $files['sync-token'] = $this->get_sync_token($path, $user); + // callback to query sync-token, after propfind_callbacks / iterator is run and + // stored max. modification-time in $this->sync_collection_token + $files['sync-token'] = array($this, 'get_sync_collection_token'); + $files['sync-token-params'] = array($path, $user); + + $this->sync_collection_token = null; } if (isset($nresults)) { $files['files'] = $this->propfind_callback($path, $filter, array(0, (int)$nresults)); - if ($options['root']['name'] == 'sync-collection' && isset($files['files']['sync-token'])) + // hack to support limit with sync-collection report: contacts are returned in modified ASC order (oldest first) + // if limit is smaller then full result, return modified-1 as sync-token, so client requests next chunk incl. modified + // (which might contain further entries with identical modification time) + if ($options['root']['name'] == 'sync-collection' && $this->bo->total > $nresults) { - $files['sync-token'] = $this->get_sync_token($path, $user, $files['files']['sync-token']); - unset($files['files']['sync-token']); + --$this->sync_collection_token; } } else @@ -198,6 +204,9 @@ class addressbook_groupdav extends groupdav_handler { $order = 'egw_addressbook.contact_id'; } + // detect sync-collection report + $sync_collection_report = isset($filter[0]) && strpos($filter[0], 'contact_modified>') === 0; + $files = array(); // we query etag and modified, as LDAP does not have the strong sql etag $cols = array('id','uid','etag','modified','n_fn'); @@ -209,7 +218,7 @@ class addressbook_groupdav extends groupdav_handler foreach($contacts as &$contact) { // sync-collection report: deleted entry need to be reported without properties - if ($contact['tid'] == addressbook_bo::DELETED_TYPE && array_key_exists('tid', $filter) && !isset($filter['tid'])) + if ($contact['tid'] == addressbook_bo::DELETED_TYPE) { $files[] = array('path' => $path.urldecode($this->get_path($contact))); continue; @@ -227,14 +236,11 @@ class addressbook_groupdav extends groupdav_handler } $files[] = $this->add_resource($path, $contact, $props); } - } - // hack to support limit with sync-collection report: contacts are returned in modified ASC order (oldest first) - // if limit is smaller then full result, return modified-1 as sync-token, so client requests next chunk incl. modified - // (which might contain further entries with identical modification time) - if ($contact['tid'] == addressbook_bo::DELETED_TYPE && array_key_exists('tid', $filter) && - $start[0] == 0 && $start[1] != groupdav_propfind_iterator::CHUNK_SIZE && $this->bo->total > $start[1]) - { - $files['sync-token'] = $contact['modified']-1; + // sync-collection report --> return modified of last contact as sync-token + if ($sync_collection_report) + { + $this->sync_collection_token = $contact['modified']; + } } // add groups after contacts, but only if enabled and NOT for '/addressbook/' (!isset($filter['owner']) if (in_array('D',$this->home_set_pref) && (!$start || count($contacts) < $start[1]) && isset($filter['owner'])) @@ -243,7 +249,7 @@ class addressbook_groupdav extends groupdav_handler 'list_owner' => isset($filter['owner'])?$filter['owner']:array_keys($this->bo->grants) ); // add sync-token to support sync-collection report - if (isset($filter[0]) && strpos($filter[0], 'contact_modified>') === 0) + if ($sync_collection_report) { list(,$sync_token) = explode('>', $filter[0]); $where[] = 'list_modified>FROM_UNIXTIME('.(int)$sync_token.')'; @@ -277,6 +283,11 @@ class addressbook_groupdav extends groupdav_handler $props[] = HTTP_WebDAV_Server::mkprop(groupdav::CARDDAV,'address-data',$content,true); } $files[] = $this->add_resource($path, $list, $props); + + if ($sync_collection_report && $this->sync_collection_token < $list['list_modified']) + { + $this->sync_collection_token = $list['list_modified']; + } } } } @@ -705,9 +716,9 @@ class addressbook_groupdav extends groupdav_handler public function getctag($path,$user) { static $ctags = array(); // a little per request caching, in case ctag and sync-token is both requested - if (isset($ctags[$path])) return $ctags[$path]; + $user_in = $user; // not showing addressbook of a single user? if (is_null($user) || $user === '' || $path == '/addressbook/') $user = null; @@ -722,7 +733,7 @@ class addressbook_groupdav extends groupdav_handler { $lists_ctag = $this->bo->lists_ctag($user); } - //error_log(__METHOD__."('$path', ".array2string($user).") ctag=$ctag=".date('Y-m-d H:i:s',$ctag).", lists_ctag=".($lists_ctag ? $lists_ctag.'='.date('Y-m-d H:i:s',$lists_ctag) : '').' returning '.max($ctag,$lists_ctag)); + //error_log(__METHOD__."('$path', ".array2string($user_in).") --> user=".array2string($user)." --> ctag=$ctag=".date('Y-m-d H:i:s',$ctag).", lists_ctag=".($lists_ctag ? $lists_ctag.'='.date('Y-m-d H:i:s',$lists_ctag) : '').' returning '.max($ctag,$lists_ctag)); return $ctags[$path] = max($ctag,$lists_ctag); } diff --git a/calendar/inc/class.calendar_bo.inc.php b/calendar/inc/class.calendar_bo.inc.php index 21f6d853ca..34055f1fc5 100644 --- a/calendar/inc/class.calendar_bo.inc.php +++ b/calendar/inc/class.calendar_bo.inc.php @@ -386,7 +386,7 @@ class calendar_bo * end date enddate of the search/list, defaults to start + one day * users int|array integer user-id or array of user-id's to use, defaults to the current user * cat_id int|array category-id or array of cat-id's (incl. all sub-categories), default 0 = all - * filter string all (not rejected), accepted, unknown, tentative, rejected or hideprivate + * filter string all (not rejected), accepted, unknown, tentative, rejected, hideprivate or everything (incl. rejected, deleted) * query string pattern so search for, if unset or empty all matching entries are returned (no search) * Please Note: a search never returns repeating events more then once AND does not honor start+end date !!! * daywise boolean on True it returns an array with YYYYMMDD strings as keys and an array with events @@ -469,7 +469,7 @@ class calendar_bo // socal::search() returns rejected group-invitations, as only the user not also the group is rejected // as we cant remove them efficiantly in SQL, we kick them out here, but only if just one user is displayed $users_in = (array)$params_in['users']; - $remove_rejected_by_user = !in_array($filter,array('all','rejected')) && + $remove_rejected_by_user = !in_array($filter,array('all','rejected','everything')) && count($users_in) == 1 && $users_in[0] > 0 ? $users_in[0] : null; //error_log(__METHOD__.'('.array2string($params_in).", $sql_filter) params[users]=".array2string($params['users']).' --> remove_rejected_by_user='.array2string($remove_rejected_by_user)); diff --git a/calendar/inc/class.calendar_groupdav.inc.php b/calendar/inc/class.calendar_groupdav.inc.php index bde6b305c6..3777718407 100644 --- a/calendar/inc/class.calendar_groupdav.inc.php +++ b/calendar/inc/class.calendar_groupdav.inc.php @@ -181,7 +181,7 @@ class calendar_groupdav extends groupdav_handler } // process REPORT filters or multiget href's - if (($id || $options['root']['name'] != 'propfind') && !$this->_report_filters($options,$filter,$id)) + if (($id || $options['root']['name'] != 'propfind') && !$this->_report_filters($options, $filter, $id, $nresults)) { // return empty collection, as iCal under iOS 5 had problems with returning "404 Not found" status // when trying to request not supported components, eg. VTODO on a calendar collection @@ -194,9 +194,34 @@ class calendar_groupdav extends groupdav_handler error_log(__METHOD__."($path,,,$user,$id) filter=".array2string($filter)); } - // return iterator, calling ourself to return result in chunks - $files['files'] = new groupdav_propfind_iterator($this,$path,$filter,$files['files']); + // rfc 6578 sync-collection report: filter for sync-token is already set in _report_filters + if ($options['root']['name'] == 'sync-collection') + { + // callback to query sync-token, after propfind_callbacks / iterator is run and + // stored max. modification-time in $this->sync_collection_token + $files['sync-token'] = array($this, 'get_sync_collection_token'); + $files['sync-token-params'] = array($path, $user); + $this->sync_collection_token = null; + } + + if (isset($nresults)) + { + $files['files'] = $this->propfind_callback($path, $filter, array(0, (int)$nresults)); + + // hack to support limit with sync-collection report: events are returned in modified ASC order (oldest first) + // if limit is smaller then full result, return modified-1 as sync-token, so client requests next chunk incl. modified + // (which might contain further entries with identical modification time) + if ($options['root']['name'] == 'sync-collection' && $this->bo->total > $nresults) + { + --$this->sync_collection_token; + } + } + else + { + // return iterator, calling ourself to return result in chunks + $files['files'] = new groupdav_propfind_iterator($this,$path,$filter,$files['files']); + } return true; } @@ -225,8 +250,16 @@ class calendar_groupdav extends groupdav_handler $events =& $this->bo->search($filter); if ($events) { + $sync_collection = strpos($filter['query'][0],'cal_modified>') === 0 && $filter['filter'] == 'everything'; + foreach($events as $event) { + // sync-collection report: deleted entries need to be reported without properties, same for rejected or deleted invitations + if ($sync_collection && ($event['deleted'] || in_array($event['participants'][$filter['users']][0], array('R','X')))) + { + $files[] = array('path' => $path.urldecode($this->get_path($event))); + continue; + } $etag = $this->get_etag($event, $schedule_tag); //header('X-EGROUPWARE-EVENT-'.$event['id'].': '.$event['title'].': '.date('Y-m-d H:i:s',$event['start']).' - '.date('Y-m-d H:i:s',$event['end'])); $props = array( @@ -261,6 +294,11 @@ class calendar_groupdav extends groupdav_handler }*/ $files[] = $this->add_resource($path, $event, $props); } + // sync-collection report --> return modified of last contact as sync-token + if ($sync_collection_report) + { + $this->sync_collection_token = $event['modified']; + } } if ($this->debug) { @@ -276,9 +314,10 @@ class calendar_groupdav extends groupdav_handler * @param array $options * @param array &$cal_filters * @param string $id - * @return boolean true if filter could be processed, false for requesting not here supported VTODO items + * @param int &$nresult on return limit for number or results or unchanged/null + * @return boolean true if filter could be processed */ - function _report_filters($options,&$cal_filters,$id) + function _report_filters($options, &$cal_filters, $id, &$nresults) { if ($options['filters']) { @@ -348,6 +387,49 @@ class calendar_groupdav extends groupdav_handler } } + // parse limit from $options['other'] + /* Example limit + + 10 + + */ + foreach((array)$options['other'] as $option) + { + switch($option['name']) + { + case 'nresults': + $nresults = (int)$option['data']; + //error_log(__METHOD__."(...) options[other]=".array2string($options['other'])." --> nresults=$nresults"); + break; + case 'limit': + break; + case 'href': + break; // from addressbook-multiget, handled below + // rfc 6578 sync-report + case 'sync-token': + if (!empty($option['data'])) + { + $parts = explode('/', $option['data']); + $sync_token = array_pop($parts); + $cal_filters['query'][] = 'cal_modified>'.(int)$sync_token; + $cal_filters['filter'] = 'everything'; // to return deleted entries too + $cal_filters['order'] = 'cal_modified ASC'; // return oldest modifications first + // no standard time-range! + unset($cal_filters['start']); + unset($cal_filters['end']); + } + break; + case 'sync-level': + if ($option['data'] != '1') + { + $this->groupdav->log(__METHOD__."(...) only sync-level {$option['data']} requested, but only 1 supported! options[other]=".array2string($options['other'])); + } + break; + default: + $this->groupdav->log(__METHOD__."(...) unknown xml tag '{$option['name']}': options[other]=".array2string($options['other'])); + break; + } + } // multiget or propfind on a given id //error_log(__FILE__ . __METHOD__ . "multiget of propfind:"); if ($options['root']['name'] == 'calendar-multiget' || $id) @@ -1215,6 +1297,7 @@ class calendar_groupdav extends groupdav_handler } $props['supported-calendar-component-set'] = HTTP_WebDAV_Server::mkprop(groupdav::CALDAV, 'supported-calendar-component-set',$supported_components); + // supported reports $props['supported-report-set'] = array( 'calendar-query' => HTTP_WebDAV_Server::mkprop('supported-report',array( HTTP_WebDAV_Server::mkprop('report',array( @@ -1225,6 +1308,10 @@ class calendar_groupdav extends groupdav_handler 'free-busy-query' => HTTP_WebDAV_Server::mkprop('supported-report',array( HTTP_WebDAV_Server::mkprop('report',array( HTTP_WebDAV_Server::mkprop(groupdav::CALDAV,'free-busy-query',''))))), + // rfc 6578 sync-collection report + 'sync-collection' => HTTP_WebDAV_Server::mkprop('supported-report',array( + HTTP_WebDAV_Server::mkprop('report',array( + HTTP_WebDAV_Server::mkprop('sync-collection',''))))), ); $props['supported-calendar-data'] = HTTP_WebDAV_Server::mkprop(groupdav::CALDAV,'supported-calendar-data',array( HTTP_WebDAV_Server::mkprop(groupdav::CALDAV,'calendar-data', array('content-type' => 'text/calendar', 'version'=> '2.0')), diff --git a/calendar/inc/class.calendar_ical.inc.php b/calendar/inc/class.calendar_ical.inc.php index de44434094..9b168c7543 100644 --- a/calendar/inc/class.calendar_ical.inc.php +++ b/calendar/inc/class.calendar_ical.inc.php @@ -431,6 +431,8 @@ class calendar_ical extends calendar_boupdate if (!($info = $this->resource_info($uid))) continue; + if ($status == 'X') continue; // dont include deleted participants + if ($this->log) { error_log(__FILE__.'['.__LINE__.'] '.__METHOD__ . diff --git a/calendar/inc/class.calendar_so.inc.php b/calendar/inc/class.calendar_so.inc.php index 2e0a8a099b..afc400920f 100644 --- a/calendar/inc/class.calendar_so.inc.php +++ b/calendar/inc/class.calendar_so.inc.php @@ -350,7 +350,7 @@ class calendar_so * @param int $end enddate of the search/list (servertime) * @param int|array $users user-id or array of user-id's, !$users means all entries regardless of users * @param int|array $cat_id=0 mixed category-id or array of cat-id's (incl. all sub-categories), default 0 = all - * @param string $filter='all' string filter-name: all (not rejected), accepted, unknown, tentative, rejected or hideprivate (handled elsewhere!) + * @param string $filter='all' string filter-name: all (not rejected), accepted, unknown, tentative, rejected or everything (incl. rejected, deleted) * @param int|boolean $offset=False offset for a limited query or False (default) * @param int $num_rows=0 number of rows to return if offset set, default 0 = use default in user prefs * @param array $params=array() @@ -458,12 +458,14 @@ class calendar_so // this is only used, when we cannot use UNIONS if (!$useUnionQuery) $where[] = '('.implode(' OR ',$to_or).')'; - if($filter != 'deleted') + if($filter != 'deleted' && $filter != 'everything') { $where[] = 'cal_deleted IS NULL'; } switch($filter) { + case 'everything': // no filter at all + break; case 'showonlypublic': $where['cal_public'] = 1; $where[] = "$this->user_table.cal_status NOT IN ('R','X')"; break; @@ -516,7 +518,7 @@ class calendar_so if (!preg_match('/^[a-z_ ,c]+$/i',$params['order'])) $params['order'] = 'cal_start'; // gard against SQL injection - if ($remove_rejected_by_user) + if ($remove_rejected_by_user && $filter != 'everything') { $rejected_by_user_join = "LEFT JOIN $this->user_table rejected_by_user". " ON $this->cal_table.cal_id=rejected_by_user.cal_id". @@ -674,7 +676,8 @@ class calendar_so // now ready all users with the given cal_id AND (cal_recur_date=0 or the fitting recur-date) // This will always read the first entry of each recuring event too, we eliminate it later $recur_dates[] = 0; - $utcal_id_view = " (SELECT * FROM ".$this->user_table." WHERE cal_id IN (".implode(',',$ids).") AND cal_status!='X') utcalid "; + $utcal_id_view = " (SELECT * FROM ".$this->user_table." WHERE cal_id IN (".implode(',',$ids).")". + ($filter != 'everything' ? " AND cal_status!='X'" : '').") utcalid "; //$utrecurdate_view = " (select * from ".$this->user_table." where cal_recur_date in (".implode(',',array_unique($recur_dates)).")) utrecurdates "; foreach($this->db->select($utcal_id_view,'*',array( //'cal_id' => array_unique($ids), diff --git a/infolog/inc/class.infolog_groupdav.inc.php b/infolog/inc/class.infolog_groupdav.inc.php index f7bedaaefe..72ae2940c4 100644 --- a/infolog/inc/class.infolog_groupdav.inc.php +++ b/infolog/inc/class.infolog_groupdav.inc.php @@ -182,19 +182,17 @@ class infolog_groupdav extends groupdav_handler // rfc 6578 sync-collection report: filter for sync-token is already set in _report_filters if ($options['root']['name'] == 'sync-collection') { - // query sync-token before result, so changed happening while result get queried are not lost - $files['sync-token'] = $this->get_sync_token($path, $user); + // callback to query sync-token, after propfind_callbacks / iterator is run and + // stored max. modification-time in $this->sync_collection_token + $files['sync-token'] = array($this, 'get_sync_collection_token'); + $files['sync-token-params'] = array($path, $user); + + $this->sync_collection_token = null; } if (isset($nresults)) { $files['files'] = $this->propfind_callback($path, $filter, array(0, (int)$nresults)); - - if ($options['root']['name'] == 'sync-collection' && isset($files['files']['sync-token'])) - { - $files['sync-token'] = $this->get_sync_token($path, $user, $files['files']['sync-token']); - unset($files['files']['sync-token']); - } } else { @@ -232,7 +230,6 @@ class infolog_groupdav extends groupdav_handler if ($matches[2]) $sort = $matches[2]; unset($filter['order']); } - $query = array( 'order' => $order, 'sort' => $sort, @@ -265,7 +262,7 @@ class infolog_groupdav extends groupdav_handler foreach($tasks as $task) { // sync-collection report: deleted entry need to be reported without properties - if ($task['info_status'] == 'deleted' && strpos($task_filter, '+deleted') !== false) + if ($task['info_status'] == 'deleted') { $files[] = array('path' => $path.urldecode($this->get_path($task))); continue; @@ -284,13 +281,19 @@ class infolog_groupdav extends groupdav_handler $files[] = $this->add_resource($path, $task, $props); } } - // hack to support limit with sync-collection report: tasks are returned in modified ASC order (oldest first) - // if limit is smaller then full result, return modified-1 as sync-token, so client requests next chunk incl. modified - // (which might contain further entries with identical modification time) - if (strpos($task_filter, '+deleted') !== false && - $start[0] == 0 && $start[1] != groupdav_propfind_iterator::CHUNK_SIZE && $query['total'] > $start[1]) + // sync-collection report --> return modified of last contact as sync-token + $sync_collection_report = strpos($task_filter, '+deleted') !== false; + if ($sync_collection_report) { - $files['sync-token'] = $task['info_datemodified']-1; + $this->sync_collection_token = $info['date_modified']; + + // hack to support limit with sync-collection report: tasks are returned in modified ASC order (oldest first) + // if limit is smaller then full result, return modified-1 as sync-token, so client requests next chunk incl. modified + // (which might contain further entries with identical modification time) + if ($start[0] == 0 && $start[1] != groupdav_propfind_iterator::CHUNK_SIZE && $query['total'] > $start[1]) + { + --$this->sync_collection_token; + } } if ($this->debug) error_log(__METHOD__."($path) took ".(microtime(true) - $starttime).' to return '.count($files).' items'); return $files; @@ -739,7 +742,7 @@ class infolog_groupdav extends groupdav_handler HTTP_WebDAV_Server::mkprop(groupdav::CALDAV,'calendar-query',''))))), 'calendar-multiget' => HTTP_WebDAV_Server::mkprop('supported-report',array( HTTP_WebDAV_Server::mkprop('report',array( - HTTP_WebDAV_Server::mkprop(groupdav::CARDDAV,'calendar-multiget',''))))), + HTTP_WebDAV_Server::mkprop(groupdav::CALDAV,'calendar-multiget',''))))), // rfc 6578 sync-collection report 'sync-collection' => HTTP_WebDAV_Server::mkprop('supported-report',array( HTTP_WebDAV_Server::mkprop('report',array( diff --git a/phpgwapi/inc/class.groupdav_handler.inc.php b/phpgwapi/inc/class.groupdav_handler.inc.php index 9c653a99b2..43fbe6e87b 100644 --- a/phpgwapi/inc/class.groupdav_handler.inc.php +++ b/phpgwapi/inc/class.groupdav_handler.inc.php @@ -16,6 +16,8 @@ * * Permanent error_log() calls should use $this->groupdav->log($str) instead, to be send to PHP error_log() * and our request-log (prefixed with "### " after request and response, like exceptions). + * + * @ToDo: If precondition for PUT, see https://tools.ietf.org/html/rfc6578#section-5 */ abstract class groupdav_handler { @@ -359,8 +361,10 @@ abstract class groupdav_handler 'davkit' => 'davkit', // Apple iCal 10.6 'coredav' => 'coredav', // Apple iCal 10.7 'calendarstore' => 'calendarstore', // Apple iCal 5.0.1 under OS X 10.7.2 + 'calendaragent/' => 'calendaragent', // Apple iCal OS X 10.8*: Mac OS X/10.8.2 (12C54) CalendarAgent/55 'dataaccess' => 'dataaccess', // Apple addressbook iPhone 'cfnetwork' => 'cfnetwork', // Apple Addressbook 10.6/7 + 'addressbook/' => 'cfnetwork', // Apple Addressbook OS X 10.8*: Mac OS X/10.8.2 (12C54) AddressBook/1167 'bionicmessage.net' => 'funambol', // funambol GroupDAV connector from bionicmessage.net 'zideone' => 'zideone', // zideone outlook plugin 'lightning' => 'lightning', // Lighting (incl. SOGo connector for addressbook) @@ -580,6 +584,25 @@ abstract class groupdav_handler return $full_uri ? $uri : $path; } + /** + * sync-token to be filled by propfind_callback and returned by get_sync_token method + */ + protected $sync_collection_token; + + /** + * Query sync-token from a just run sync-collection report + * + * Modified time is taken from value filled by propfind_callback in sync_collection_token. + * + * @param string $path + * @param int $user parameter necessary to call getctag, if no $token specified + * @return string + */ + public function get_sync_collection_token($path, $user=null) + { + return $this->get_sync_token($path, $user, $this->sync_collection_token); + } + /** * Query sync-token * @@ -588,19 +611,24 @@ abstract class groupdav_handler * * Therefor we are never returning current time, but 1sec less! * + * Modified time is either taken from value filled by propfind_callback in $this->sync_token or + * by call to getctag(); + * * @param string $path - * @param int $user - * @param int $modified=null default getctag + * @param int $user parameter necessary to call getctag, if no $token specified + * @param int $token=null modification time, default call getctag($path, $user) to fetch it * @return string */ - public function get_sync_token($path, $user, $modified=null) + public function get_sync_token($path, $user, $token=null) { - if (!isset($modified)) $modified = $this->getctag($path, $user); + if (!isset($token)) $token = $this->getctag($path, $user); - // never return current time, as more modifications might happen --> decrement it by 1sec - if ($modified == time()) --$modified; - - return $this->base_uri().$path.$modified; + // never return current time, as more modifications might happen due to second granularity --> return 1sec less + if ($token >= (int)$GLOBALS['egw_info']['flags']['page_start_time']) + { + $token = (int)$GLOBALS['egw_info']['flags']['page_start_time'] - 1; + } + return $this->base_uri().$path.$token; } }