Merge branch 'master' of github.com:DAViCal/davical into github
[davical.git] / scripts / sync-remote-caldav.php
bloba3e77aae8930f97c97d3791360d05b2af112d488
1 #!/usr/bin/php
2 <?php
4 if ( @file_exists('../../awl/inc/AWLUtilities.php') ) {
5 set_include_path('../inc:../htdocs:../../awl/inc');
7 else if ( @file_exists('../awl/inc/AWLUtilities.php') ) {
8 set_include_path('inc:htdocs:../awl/inc:.');
10 else {
11 set_include_path('../inc:../htdocs:/usr/share/awl/inc');
13 include('always.php');
14 require_once('AwlQuery.php');
15 require_once('caldav-PUT-functions.php');
17 include('caldav-client-v2.php');
19 /**
20 * Call with something like e.g.:
22 * scripts/sync-remote-caldav.php -U andrew@example.net -p 53cret -u https://www.google.com/calendar/dav/andrew@example.net/events -c /andrew/gsync/
24 * Optionally also:
25 * Add '-a' to sync everything, rather than checking if getctag has changed. (DON'T USE THIS)
26 * Add '-w remote' to make the remote end win arguments when there is a change to the same event in both places.
27 * Add '-i' to only sync inwards, from the remote server into DAViCal
28 * Add '-o' to only sync outwards, from DAViCal to the remote server
30 * Note that this script is ugly (though it works, at least with Google) and should really be rewritten
31 * with better structuring. As it is it's more like one long stream of consciousness novel.
33 * One bug that would be better solved through restructuring is that if you supply -a and have changed an
34 * event locally, it will be overwritten by the remote server's copy while we then overwrite the remote
35 * server with our version! These will then end up swapping each time thereafter in all likelihood...
36 * Recommendation: don't use '-a', except possibly for the very first sync (but why then, even?)
38 * Other improvements would be to not use command-line parameters, but a configuration file.
42 $args = (object) null;
43 $args->sync_all = false; // Back to basics and sync everything into one mess
44 $args->local_changes_win = true; // If true, and something has changed at both places, our local update will overwrite the remote
46 $args->sync_in = false; // If true, remote changes will be applied locally
47 $args->sync_out = false; // If true, local changes will be applied remotely
49 $args->cache_directory = '.sync-cache';
51 function parse_arguments() {
52 global $args;
54 $opts = getopt( 'u:U:p:c:w:ioa' );
55 foreach( $opts AS $k => $v ) {
56 switch( $k ) {
57 case 'u': $args->url = $v; break;
58 case 'U': $args->user = $v; break;
59 case 'p': $args->pass = $v; break;
60 case 'a': $args->sync_all = true; break;
61 case 'c': $args->local_collection_path = $v; break;
62 case 'w': $args->local_changes_win = (strtolower($v) != 'remote' ); break;
63 case 'i': $args->sync_in = true; break;
64 case 'o': $args->sync_out = true; break;
65 case 'h': usage(); break;
66 default: $args->{$k} = $v;
71 function usage() {
72 echo <<<EOUSAGE
73 Usage:
74 sync-remote-caldav.php -u <url> -U <user> -p <password> -c <path> [...options]
76 Required Options:
77 -u <remote_url> The URL of the caldav collection on the remote server.
78 -U <remote_user> The username on the remote server to connect as
79 -p <remote_pass> The password for the remote server
80 -c <local_path> The path to the local collection, e.g. /username/home/ note that
81 any part of the local URL up to and including 'caldav.php' should
82 be omitted.
84 Other Options:
85 -w remote If set to 'remote' and changes are seen in both calendars, the remote
86 server will 'win' the argument. Any other value and the default will
87 apply in that the changes on the local server will prevail.
88 -i Sync inwards only.
89 -o Sync outwards only
91 EOUSAGE;
93 exit(0);
96 parse_arguments();
98 if ( !isset($args->url) ) usage();
99 if ( !isset($args->user) ) usage();
100 if ( !isset($args->pass) ) usage();
101 if ( !isset($args->local_collection_path) ) usage();
104 if ( !preg_match('{/$}', $args->local_collection_path) ) $args->local_collection_path .= '/';
105 if ( !preg_match('{^/[^/]+/[^/]+/$}', $args->local_collection_path) ) {
106 printf( "The local URL of '%s' looks wrong. It should be formed as '/username/collection/'\n", $args->local_collection_path );
109 if ( !preg_match('{/$}', $args->url) ) $args->url .= '/';
111 $caldav = new CalDAVClient( $args->url, $args->user, $args->pass );
113 // // This will find the 'Principal URL' which we can query for user-related
114 // // properties.
115 // $principal_url = $caldav->FindPrincipal($args->url);
117 // // This will find the 'Calendar Home URL' which will be the folder(s) which
118 // // contain all of the user's calendars
119 // $calendar_home_set = $caldav->FindCalendarHome();
121 // $calendar = null;
123 // // This will go through the calendar_home_set and find all of the users
124 // // calendars on the remote server.
125 // $calendars = $caldav->FindCalendars();
126 // if ( count($calendars) < 1 ) {
127 // printf( "No calendars found based on '%s'\n", $args->url );
128 // }
130 // // Now we have all of the remote calendars, we will look for the URL that
131 // // matches what we were originally supplied. While this seems laborious
132 // // because we already have it, it means we could provide a match in some
133 // // other way (e.g. on displayname) and we could also present a list to
134 // // the user which is built from following the above process.
135 // foreach( $calendars AS $k => $a_calendar ) {
136 // if ( $a_calendar->url == $args->url ) $calendar = $a_calendar;
137 // }
138 // if ( !isset($calendar) ) $calendar = $calendars[0];
140 // In reality we could have omitted all of the above parts, If we really do
141 // know the correct URL at the start.
143 // Everything now will be at our calendar URL
144 $caldav->SetCalendar($args->url);
146 $calendar = $caldav->GetCalendarDetails();
148 printf( "Remote calendar '%s' is at %s\n", $calendar->displayname, $calendar->url );
150 // Generate a consistent filename for our synchronisation cache
151 if ( ! file_exists($args->cache_directory) && ! is_dir($args->cache_directory) ) {
152 mkdir($args->cache_directory, 0750 ); // Not incredibly sensitive file contents - URLs and ETags
154 $sync_cache_filename = $args->cache_directory .'/'. md5($args->user . $calendar->url);
156 // Do we just need to sync everything across and overwrite all the local stuff?
157 $sync_all = ( !file_exists($sync_cache_filename) || $args->sync_all);
158 $sync_in = false;
159 $sync_out = false;
160 if ( $args->sync_in || !$args->sync_out ) $sync_in = true;
161 if ( $args->sync_out || !$args->sync_in ) $sync_out = true;
164 if ( ! $sync_all ) {
166 * Read a structure out of the cache file containing:
167 * server_getctag - A collection tag (string) from the remote server
168 * local_getctag - A collection tag (string) from the local DB
169 * server_etags - An array of event tags (strings) keyed on filename, from the server
170 * local_etags - An array of event tags (strings) keyed on filename, from local DAViCal
172 $cache = unserialize( file_get_contents($sync_cache_filename) );
174 // First compare the ctag for the calendar
175 if ( isset($cache) && isset($cache->server_ctag) && isset($calendar->getctag) && $calendar->getctag == $cache->server_ctag ) {
176 printf( 'No changes to remote calendar "%s" at "%s"'."\n", $calendar->displayname, $calendar->url );
177 $sync_in = false;
180 $qry = new AwlQuery('SELECT collection_id, dav_displayname AS displayname, dav_etag AS getctag FROM collection WHERE dav_name = :collection_dav_name', array(':collection_dav_name' => $args->local_collection_path) );
181 if ( $qry->Exec('sync-pull',__LINE__,__FILE__) && $qry->rows() > 0 ) {
182 $local_calendar = $qry->Fetch();
184 // First compare the ctag for the calendar
185 if ( isset($cache) && isset($cache->local_ctag) && isset($local_calendar->getctag) && $local_calendar->getctag == $cache->local_ctag ) {
186 printf( 'No changes to local calendar "%s" at "%s"'."\n", $local_calendar->displayname, $args->local_collection_path );
187 $sync_out = false;
191 if ( !isset($cache) || !isset($cache->server_ctag) ) $sync_all = true;
193 $remote_event_prefix = preg_replace('{^https?://[^/]+/}', '/', $calendar->url);
194 $insert_urls = array();
195 $update_urls = array();
196 $local_delete_urls = array();
197 $server_delete_urls = array();
198 $push_urls = array();
199 $push_events = array();
201 $newcache = (object) array( 'server_ctag' => $calendar->getctag,
202 'local_ctag' => (isset($local_calendar->getctag) ? $local_calendar->getctag : null),
203 'server_etags' => array(), 'local_etags' => array() );
204 if ( isset($cache) ) {
205 if ( !$sync_in && isset($cache->server_etags) ) $newcache->server_etags = $cache->server_etags;
206 if ( !$sync_out && isset($cache->local_etags) ) $newcache->local_etags = $cache->local_etags;
209 if ( $sync_in ) {
210 // So it seems we do need to sync. We now need to check each individual event
211 // which might have changed, so we pull a list of event etags from the server.
212 $server_etags = $caldav->GetCollectionETags();
213 // printf( "\nGetCollectionEtags Response:\n%s\n", $caldav->GetXmlResponse() );
214 // print_r( $server_etags );
218 if ( $sync_all ) {
219 // The easy case. Sync them all, delete nothing
220 $insert_urls = $server_etags;
221 foreach( $server_etags AS $href => $etag ) {
222 $fname = preg_replace('{^.*/}', '', $href);
223 $newcache->server_etags[$fname] = $etag;
224 printf( 'Need to pull "%s"'."\n", $href );
227 else {
228 // Only sync the ones where the etag has changed. Delete any that are no
229 // longer present at the remote end.
230 foreach( $server_etags AS $href => $etag ) {
231 $fname = preg_replace('{^.*/}', '', $href);
232 $newcache->server_etags[$fname] = $etag;
233 if ( isset($cache->server_etags[$fname]) ) {
234 $cache_etag = $cache->server_etags[$fname];
235 unset($cache->server_etags[$fname]);
236 if ( $cache_etag == $etag ) continue;
237 $update_urls[$href] = 1;
238 printf( 'Need to pull to update "%s"'."\n", $href );
240 else {
241 $insert_urls[$href] = 1;
242 printf( 'Need to pull to insert "%s"'."\n", $href );
245 $local_delete_urls = $cache->server_etags;
249 // Fetch the calendar data
250 $events = $caldav->CalendarMultiget( array_merge( array_keys($insert_urls), array_keys($update_urls)) );
251 // printf( "\nCalendarMultiget Request:\n%s\n Response:\n%s\n", $caldav->GetXmlRequest(), $caldav->GetXmlResponse() );
252 // print_r($events);
254 printf( "Fetched %d possible changes.\n", count($events) );
256 if ( !preg_match( '{/$}', $remote_event_prefix) ) $remote_event_prefix .= '/';
261 * This is a fairly tricky bit. We find local changes and check to see if they
262 * are collisions. We actually have to check the data for a collision, since the
263 * real data may in fact be identical, e.g. because of the -a option or something.
265 * Once we have verified that the target objects actually *are* different, then:
266 * Change vs No change => The change is propagated to the other server
267 * DELETE vs UPDATE/INSERT => DELETE always loses
268 * UPDATE vs UPDATE => pick the winner according to arbitrary setting (see top of file)
269 * INSERT vs INSERT => pick the winner according to arbitrary setting (see top of file) v. unlikely
271 // Read the local ETag from DAViCal.
272 $qry = new AwlQuery( 'SELECT dav_name, dav_etag, caldav_data FROM caldav_data WHERE collection_id = (SELECT collection_id FROM collection WHERE dav_name = :collection_dav_name)',
273 array(':collection_dav_name' => $args->local_collection_path) );
274 if ( $qry->Exec('sync-pull',__LINE__,__FILE__) && $qry->rows() > 0 ) {
275 $local_etags = array();
276 while( $local = $qry->Fetch() ) {
277 $fname = preg_replace('{^.*/}', '', $local->dav_name);
278 $newcache->local_etags[$fname] = $local->dav_etag;
279 if ( !$sync_all && isset($cache->local_etags[$fname]) ) {
280 $cache_etag = $cache->local_etags[$fname];
281 unset($cache->local_etags[$fname]);
282 if ( $cache_etag == $local->dav_etag ) continue;
284 if ( isset($insert_urls[$remote_event_prefix.$fname]) ) {
285 if ( $local->caldav_data == $events[$remote_event_prefix.$fname] ) {
286 // Not actually changed. Ignore it at *both* ends!
287 printf( "Not inserting '%s' (same at both ends).\n", $fname );
288 unset($insert_urls[$remote_event_prefix.$fname]);
289 continue;
291 unset($insert_urls[$remote_event_prefix.$fname]);
292 if ( ! $args->local_changes_win ) {
293 printf( "Remote change to '%s' will overwrite local.\n", $fname );
294 $update_urls[$remote_event_prefix.$fname] = 1;
295 continue;
297 printf( "Local change to '%s' will overwrite remote.\n", $fname );
299 else if ( isset($update_urls[$remote_event_prefix.$fname]) ) {
300 if ( $local->caldav_data == $events[$remote_event_prefix.$fname] ) {
301 // Not actually changed. Ignore it at *both* ends!
302 printf( "Not updating '%s' (same at both ends).\n", $fname );
303 unset($update_urls[$remote_event_prefix.$fname]);
304 continue;
306 if ( $args->local_changes_win ) {
307 unset($update_urls[$remote_event_prefix.$fname]);
308 printf( "Local change to '%s' will overwrite remote.\n", $fname );
310 else {
311 printf( "Remote change to '%s' will overwrite local.\n", $fname );
312 continue;
315 if ( $sync_out ) {
316 $push_urls[$fname] = (isset($cache->server_etags[$remote_event_prefix.$fname]) ? $cache->server_etags[$remote_event_prefix.$fname] : '*');
317 $push_events[$fname] = $local->caldav_data;
318 printf( "Need to push '%s'\n", $local->dav_name );
320 else {
321 printf( "Would push '%s' but not syncing out.\n", $local->dav_name );
325 if ( !$sync_all ) {
326 foreach( $cache->local_etags AS $href => $etag ) {
327 $fname = preg_replace('{^.*/}', '', $href);
329 if ( !isset($insert_urls[$remote_event_prefix.$fname])
330 && !isset($update_urls[$remote_event_prefix.$fname])
331 && isset($cache->server_etags[$fname]) ) {
332 $server_delete_urls[$fname] = $cache->server_etags[$remote_event_prefix.$fname];
333 printf( "Need to delete remote '%s'.\n", $fname );
339 printf( "Push: Found %d local changes to push & %d local deletions to push.\n", count($push_urls), count($server_delete_urls) );
340 printf( "Pull: Found %d creates, %d updates and %d deletions to apply locally.\n", count($insert_urls), count($update_urls), count($local_delete_urls) );
342 if ( $sync_in ) {
343 printf( "Sync in\n" );
344 // Delete any local events which have been removed from the remote server
345 foreach( $local_delete_urls AS $href => $v ) {
346 $fname = preg_replace('{^.*/}', '', $href);
347 $local_fname = $args->local_collection_path . $fname;
348 $qry = new AwlQuery('DELETE FROM caldav_data WHERE caldav_type!=\'VTODO\' and dav_name = :dav_name', array( ':dav_name' => $local_fname ) );
349 $qry->Exec('sync_pull',__LINE__,__FILE__);
350 unset($newcache->local_etags[$fname]);
354 unset($c->dbg['querystring']);
355 // Update the local system with events that are new or updated on the remote server
356 foreach( $events AS $href => $event ) {
357 // Do what we need to write $v into the local calendar we are syncing to
358 // at the
359 $fname = preg_replace('{^.*/}', '', $href);
360 $local_fname = $args->local_collection_path . $fname;
361 simple_write_resource( $local_fname, $event, (isset($insert_urls[$href]) ? 'INSERT' : 'UPDATE') );
362 $newcache->local_etags[$fname] = md5($event);
365 $qry = new AwlQuery('SELECT collection_id, dav_displayname AS displayname, dav_etag AS getctag FROM collection WHERE dav_name = :collection_dav_name', array(':collection_dav_name' => $args->local_collection_path) );
366 if ( $qry->Exec('sync-pull',__LINE__,__FILE__) && $qry->rows() > 0 ) {
367 $local_calendar = $qry->Fetch();
368 if ( isset($local_calendar->getctag) ) $newcache->local_ctag = $local_calendar->getctag;
372 if ( $sync_out ) {
373 printf( "Sync out\n" );
374 // Delete any remote events which have been removed from the local server
375 foreach( $server_delete_urls AS $href => $etag ) {
376 $caldav->DoDELETERequest( $args->url . $href, $etag );
377 printf( "\nDELETE Response:\n%s\n", $caldav->GetResponseHeaders() );
378 unset($newcache->server_etags[$fname]);
381 // Push locally updated events to the remote server
382 foreach( $push_urls AS $href => $etag ) {
383 $new_etag = $caldav->DoPUTRequest( $args->url . $href, $push_events[$href], $etag );
384 printf( "\nPUT:\n%s\nResponse:\n%s\n", $caldav->GetHttpRequest(), $caldav->GetResponseHeaders() );
385 if ( !isset($new_etag) || $new_etag == '' ) {
386 if ( preg_match( '{^Location:\s+.*/([^/]+)$}im', $caldav->GetResponseHeaders(), $matches ) ) {
387 /** How annoying. It seems the other server renamed the event on PUT so we move the local copy to match their name */
388 $new_href = preg_replace( '{\r?\n.*$}s', '', $matches[1]);
389 $qry = new AwlQuery('UPDATE caldav_data SET dav_name = :new_dav_name WHERE dav_name = :old_dav_name',
390 array( ':new_dav_name' => $args->local_collection_path . $new_href,
391 ':old_dav_name' => $args->local_collection_path . $href ) );
392 $qry->Exec('sync_pull',__LINE__,__FILE__);
393 $new_cache->local_etags[$new_href] = $new_cache->local_etags[$href];
394 unset($new_cache->local_etags[$href]);
395 $href = $new_href;
396 $caldav->DoHEADRequest( $args->url . $href );
397 if ( preg_match( '{^Etag:\s+"([^"]*)"\s*$}im', $caldav->httpResponseHeaders, $matches ) ) $new_etag = $matches[1];
398 printf( "\nHEAD:\n%s\nResponse:\n%s\n", $caldav->GetHttpRequest(), $caldav->GetResponseHeaders() );
400 if ( !isset($new_etag) || $new_etag == '' ) {
401 printf( "Unable to retrieve ETag for new event on remote server. Forcing bad ctag.");
402 $force_ctag = 'Naughty server!';
405 $newcache->server_etags[$href] = $new_etag;
408 $calendar = $caldav->GetCalendarDetails();
409 if ( isset($force_ctag) ) $newcache->server_ctag = $force_ctag;
410 else if ( isset($calendar->getctag) ) $newcache->server_ctag = $calendar->getctag;
413 // Now (re)write the cache file reflecting the current state.
414 printf( "Rewriting cache file.\n" );
415 $cache_file = fopen($sync_cache_filename, 'w');
416 fwrite( $cache_file, serialize($newcache) );
417 fclose($cache_file);
419 print_r($newcache);
421 printf( "Completed.\n" );