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');
6 }
7 else if ( @file_exists('../awl/inc/AWLUtilities.php') ) {
8   set_include_path('inc:htdocs:../awl/inc:.');
9 }
10 else {
11   set_include_path('../inc:../htdocs:/usr/share/awl/inc');
12 }
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.:
21 *
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/
23 *
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
29 *
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.
32 *
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?)
37 *
38 * Other improvements would be to not use command-line parameters, but a configuration file.
39 */
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;
67     }
68   }
69 }
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);
94 }
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);
116 //
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();
120 //
121 // $calendar = null;
122 //
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 // }
129 //
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 ) {
165   /**
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
171   */
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;
178   }
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;
188     }
189   }
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 );
225     }
226   }
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 );
239       }
240       else {
241         $insert_urls[$href] = 1;
242         printf( 'Need to pull to insert "%s"'."\n", $href );
243       }
244     }
245     $local_delete_urls = $cache->server_etags;
246   }
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 .= '/';
260 /**
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
270 */
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;
283     }
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;
290       }
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;
296       }
297       printf( "Local change to '%s' will overwrite remote.\n", $fname );
298     }
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;
305       }
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 );
309       }
310       else {
311         printf( "Remote change to '%s' will overwrite local.\n", $fname );
312         continue;
313       }
314     }
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 );
319     }
320     else {
321       printf( "Would push '%s' but not syncing out.\n", $local->dav_name );
322     }
323   }
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 );
334       }
335     }
336   }
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]);
351   }
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);
363   }
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;
369   }
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]);
379   }
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() );
399       }
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!';
403       }
404     }
405     $newcache->server_etags[$href] = $new_etag;
406   }
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" );