From c4c05bd46d48e5485d0c3b00470171cf8dfc801a Mon Sep 17 00:00:00 2001 From: Andrew McMillan Date: Thu, 11 Mar 2010 14:00:19 +1300 Subject: [PATCH] Basic script to sync from another caldav calendar. --- inc/caldav-client-v2.php | 123 ++++++++++++++++++++++----- scripts/sync-pull.php | 174 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 20 deletions(-) create mode 100755 scripts/sync-pull.php diff --git a/inc/caldav-client-v2.php b/inc/caldav-client-v2.php index b3e3b631..c6e2bdc7 100644 --- a/inc/caldav-client-v2.php +++ b/inc/caldav-client-v2.php @@ -151,6 +151,15 @@ class CalDAVClient { $this->headers[] = "Content-type: $type"; } + /** + * Set the calendar_url we will be using for a while. + * + * @param string $url The calendar_url + */ + function SetCalendar( $url ) { + $this->calendar_url = $url; + } + /** * Split response into httpResponse and xmlResponse * @@ -429,6 +438,27 @@ class CalDAVClient { } + /** + * Return the href containing this property + * + * @param string $tagname The tag name of the property to find the href for + * @param integer $which Which instance of the tag should we use + */ + function HrefForProp( $tagname, $i = 0 ) { + if ( isset($this->xmltags[$tagname]) && isset($this->xmltags[$tagname][$i]) ) { + $j = $this->xmltags[$tagname][$i]; + while( $j-- > 0 && $this->xmlnodes[$j]['tag'] != 'DAV::prop' ); + if ( $j > 0 ) { + while( $j-- > 0 && $this->xmlnodes[$j]['tag'] != 'DAV::href' ); + if ( $j > 0 && isset($this->xmlnodes[$j]['value']) ) { + return $this->xmlnodes[$j]['value']; + } + } + } + return null; + } + + /** * Return the href which has a resourcetype of the specified type * @@ -489,11 +519,13 @@ class CalDAVClient { $xml = $this->DoPROPFINDRequest( $url, array('resourcetype', 'current-user-principal', 'owner', 'principal-URL', 'urn:ietf:params:xml:ns:caldav:calendar-home-set'), 1); - $principal_url = $this->HrefForResourcetype('DAV::principal'); + $principal_url = $this->HrefForProp('DAV::principal'); - foreach( array('DAV::current-user-principal', 'DAV::principal-URL', 'DAV::owner') AS $v ) { - if ( !isset($principal_url) ) { - $principal_url = $this->HrefValueInside($v); + if ( !isset($principal_url) ) { + foreach( array('DAV::current-user-principal', 'DAV::principal-URL', 'DAV::owner') AS $href ) { + if ( !isset($principal_url) ) { + $principal_url = $this->HrefValueInside($href); + } } } @@ -545,7 +577,7 @@ class CalDAVClient { if ( isset($this->xmltags['urn:ietf:params:xml:ns:caldav:calendar']) ) { $calendar_urls = array(); foreach( $this->xmltags['urn:ietf:params:xml:ns:caldav:calendar'] AS $k => $v ) { - $calendar_urls[$this->HrefForResourcetype('urn:ietf:params:xml:ns:caldav:calendar', $k)] = 1; + $calendar_urls[$this->HrefForProp('urn:ietf:params:xml:ns:caldav:calendar', $k)] = 1; } foreach( $this->xmltags['DAV::href'] AS $i => $hnode ) { @@ -576,6 +608,59 @@ class CalDAVClient { } + /** + * Get all etags for a calendar + */ + function GetCollectionETags( $url = null ) { + if ( isset($url) ) $this->SetCalendar($url); + + $this->DoPROPFINDRequest( $this->calendar_url, array('getetag'), 1); + + $etags = array(); + if ( isset($this->xmltags['DAV::getetag']) ) { + foreach( $this->xmltags['DAV::getetag'] AS $k => $v ) { + $events[$this->HrefForProp('DAV::getetag', $k)] = $v['value']; + } + } + + return $etags; + } + + + /** + * Get a bunch of events for a calendar with a calendar-multiget report + */ + function CalendarMultiget( $event_hrefs, $url = null ) { + + if ( isset($url) ) $this->SetCalendar($url); + + $hrefs = array(); + foreach( $event_hrefs AS $k => $href ) { + $hrefs .= ''.rawurlencode($url).''; + } + $this->body = << + + +$hrefs + +EOXML; + + $this->requestMethod = "REPORT"; + $this->SetContentType("text/xml"); + $this->DoRequest( $this->calendar_url ); + + $etags = array(); + if ( isset($this->xmltags['DAV::getetag']) ) { + foreach( $this->xmltags['DAV::getetag'] AS $k => $v ) { + $events[$this->HrefForProp('DAV::getetag', $k)] = $v['value']; + } + } + + return $etags; + } + + /** * Given XML for a calendar query, return an array of the events (/todos) in the * response. Each event in the array will have a 'href', 'etag' and '$response_type' @@ -583,16 +668,17 @@ class CalDAVClient { * definition of the calendar data in iCalendar format. * * @param string $filter XML fragment which is the element of a calendar-query - * @param string $relative_url The URL relative to the base_url specified when the calendar was opened. Default ''. - * @param string $report_type Used as a name for the array element containing the calendar data. @deprecated + * @param string $url The URL of the calendar, or null to use the 'current' calendar_url * * @return array An array of the relative URLs, etags, and events from the server. Each element of the array will * be an array with 'href', 'etag' and 'data' elements, corresponding to the URL, the server-supplied * etag (which only varies when the data changes) and the calendar data in iCalendar format. */ - function DoCalendarQuery( $filter, $relative_url = '' ) { + function DoCalendarQuery( $filter, $url = null ) { - $xml = <<SetCalendar($url); + + $this->body = << @@ -602,17 +688,14 @@ class CalDAVClient { EOXML; - $this->DoXMLRequest( 'REPORT', $xml, $relative_url ); - $xml_parser = xml_parser_create_ns('UTF-8'); - $this->xml_tags = array(); - xml_parser_set_option ( $xml_parser, XML_OPTION_SKIP_WHITE, 1 ); - xml_parse_into_struct( $xml_parser, $this->xmlResponse, $this->xml_tags ); - xml_parser_free($xml_parser); + $this->requestMethod = "REPORT"; + $this->SetContentType("text/xml"); + $this->DoRequest( $this->calendar_url ); $report = array(); - foreach( $this->xml_tags as $k => $v ) { + foreach( $this->xmltags as $k => $v ) { switch( $v['tag'] ) { - case 'DAV::RESPONSE': + case 'DAV::response': if ( $v['type'] == 'open' ) { $response = array(); } @@ -620,13 +703,13 @@ EOXML; $report[] = $response; } break; - case 'DAV::HREF': + case 'DAV::href': $response['href'] = basename( $v['value'] ); break; - case 'DAV::GETETAG': + case 'DAV::getetag': $response['etag'] = preg_replace('/^"?([^"]+)"?/', '$1', $v['value']); break; - case 'URN:IETF:PARAMS:XML:NS:CALDAV:CALENDAR-DATA': + case 'urn:ietf:params:xml:ns:caldav:calendar-data': $response['data'] = $v['value']; break; } diff --git a/scripts/sync-pull.php b/scripts/sync-pull.php new file mode 100755 index 00000000..c44dba4f --- /dev/null +++ b/scripts/sync-pull.php @@ -0,0 +1,174 @@ +#!/usr/bin/php +sync_all = false; + +function parse_arguments() { + global $args; + + $opts = getopt( 'u:U:p:a' ); + foreach( $opts AS $k => $v ) { + switch( $k ) { + case 'u': $args->url = $v; break; + case 'U': $args->user = $v; break; + case 'p': $args->pass = $v; break; + case 'a': $args->sync_all = 1; break; + case 'c': $args->local_collection_path = $v; break; + default: $args->{$k} = $v; + } + } +} + +parse_arguments(); + +// E.g. +// sync-pull.php -U andrew@example.net -p 53cret -u https://www.google.com/calendar/dav/andrew@example.net/events -c /andrew/gsync/ +// + +if ( !preg_match( '{/$}', $args->url ) ) $args->url .= '/'; + +$caldav = new CalDAVClient( $args->url, $args->user, $args->pass ); + +// This will find the 'Principal URL' which we can query for user-related +// properties. +$principal_url = $caldav->FindPrincipal($args->url); + +// This will find the 'Calendar Home URL' which will be the folder(s) which +// contain all of the user's calendars +$calendar_home_set = $caldav->FindCalendarHome(); + +$calendar = null; + +// This will go through the calendar_home_set and find all of the users +// calendars on the remote server. +$calendars = $caldav->FindCalendars(); +if ( count($calendars) < 1 ) { + printf( "No calendars found based on '%s'\n", $args->url ); +} + +// Now we have all of the remote calendars, we will look for the URL that +// matches what we were originally supplied. While this seems laborious +// because we already have it, it means we could provide a match in some +// other way (e.g. on displayname) and we could also present a list to +// the user which is built from following the above process. +foreach( $calendars AS $k => $a_calendar ) { + if ( rawurldecode($a_calendar->url) == rawurldecode($args->url) ) $calendar = $a_calendar; +} +if ( !isset($calendar) ) $calendar = $calendars[0]; + +// In reality we could have omitted all of the above parts, If we really do +// know the correct URL at the start. +printf( "Calendar '%s' is at %s\n", $calendar->displayname, $calendar->url ); + +// Generate a consistent filename for our synchronisation cache +$sync_cache_filename = md5($args->url . $args->user . $calendar->url); + +// Do we just need to sync everything across and overwrite all the local stuff? +$sync_all = ( !file_exists($sync_cache_filename) || $args->sync_all); + +if ( ! $sync_all ) { + /** + * Read a structure out of the cache file containing: + * server_getctag - A collection tag (string) + * server_etags - An array of event tags (strings) keyed on filename, from the server + * local_etags - An array of event tags (strings) keyed on filename, from local DAViCal + */ + $cache = unserialize( file_get_contents($sync_cache_filename) ); + + // First compare the ctag for the calendar + if ( isset($cache) && isset($cache->server_ctag) && isset($calendar->getctag) && $calendar->getctag == $cache->server_ctag ) { + printf( 'No changes to calendar "%s"'."\n", $args->url ); + exit(0); + } +} +if ( !isset($cache) || !isset($cache->server_ctag) ) $sync_all = true; + +// Everything now will be at our calendar URL +$caldav->SetCalendar($calendar->url); + +// So it seems we do need to sync. We now need to check each individual event +// which might have changed, so we pull a list of event etags from the server. +$server_etags = $caldav->GetCollectionETags($calendar->url); + +$newcache = (object) array( 'server_ctag' => $calendar->getctag, 'server_etags' => array(), 'local_etags' => array() ); + +if ( $sync_all ) { + // The easy case. Sync them all, delete nothing + $insert_urls = array_flip($server_etags); + $update_urls = array(); + $delete_urls = array(); + foreach( $server_etags AS $href => $etag ) { + $fname = preg_replace('{^.*/}', '', $href); + $newcache->server_etags[$fname] = $etag; + } +} +else { + // Only sync the ones where the etag has changed. Delete any that are no + // longer present at the remote end. + $insert_urls = array(); + $update_urls = array(); + foreach( $server_etags AS $href => $etag ) { + $fname = preg_replace('{^.*/}', '', $href); + $newcache->server_etags[$fname] = $etag; + if ( isset($cache->server_etags[$fname]) ) { + $cache_etag = $cache->server_etags[$fname]; + unset($cache->server_etags[$fname]); + if ( $cache_etag == $etag ) continue; + $update_urls[] = $href; + } + else { + $insert_urls[] = $href; + } + } + $delete_urls = array_flip($cache->server_etags); +} + + +// Fetch the calendar data +$events = $caldav->CalendarMultiget( array_merge( $insert_urls, $update_urls) ); + +/** +* @TODO: We should really check for collisions locally in case the local ETag is +* also different to the one we saved earlier. +*/ +// Update the local system with these events +foreach( $events AS $href => $event ) { + // Do what we need to write $v into the local calendar we are syncing to + // at the + $fname = preg_replace('{^.*/}', '', $href); + $local_fname = $args->local_collection_path . $fname; + simple_write_resource( $local_fname, $event, (isset($insert_urls[$href]) ? 'INSERT' : 'UPDATE') ); +} + +/** +* @TODO: We should not delete locally in the case that the local ETag is different +* to the one we saved earlier. +*/ +// Delete any events which were present in our cache, but are not on the master server +foreach( $delete_urls AS $k => $v ) { + $fname = preg_replace('{^.*/}', '', $href); + $local_fname = $args->local_collection_path . $fname; + $qry = new AwlQuery('DELETE FROM caldav_data WHERE dav_name = :dav_name', array( ':dav_name' => $local_fname ) ); + $qry->Exec('sync_pull',__LINE__,__FILE__); +} + +// Now (re)write the cache file reflecting the current state. +$cache_file = fopen($sync_cache_filename, 'w'); +fwrite( $cache_file, serialize($newcache) ); +fclose($cache_file);