* CalDAV/CardDAV: sync-collection report for all apps allowing a more efficient sync

This commit is contained in:
Ralf Becker 2012-09-26 14:30:47 +00:00
parent b3ef030984
commit 52675388a3
7 changed files with 187 additions and 53 deletions

View File

@ -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);
}

View File

@ -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));

View File

@ -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
<B:limit>
<B:nresults>10</B:nresults>
</B:limit>
*/
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')),

View File

@ -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__ .

View File

@ -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),

View File

@ -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(

View File

@ -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;
}
}