Merge branch 'master' of github.com:DAViCal/davical into github
[davical.git] / scripts / sync-remote-caldav.php
1 #!/usr/bin/php
2 <?php
3
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');
16
17 include('caldav-client-v2.php');
18
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 */
40
41
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
45
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
48
49 $args->cache_directory = '.sync-cache';
50
51 function parse_arguments() {
52 global $args;
53
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 }
70
71 function usage() {
72 echo <<<EOUSAGE
73 Usage:
74 sync-remote-caldav.php -u <url> -U <user> -p <password> -c <path> [...options]
75
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.
83
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
90
91 EOUSAGE;
92
93 exit(0);
94 }
95
96 parse_arguments();
97
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();
102
103
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 );
107 }
108
109 if ( !preg_match('{/$}', $args->url) ) $args->url .= '/';
110
111 $caldav = new CalDAVClient( $args->url, $args->user, $args->pass );
112
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];
139
140 // In reality we could have omitted all of the above parts, If we really do
141 // know the correct URL at the start.
142
143 // Everything now will be at our calendar URL
144 $caldav->SetCalendar($args->url);
145
146 $calendar = $caldav->GetCalendarDetails();
147
148 printf( "Remote calendar '%s' is at %s\n", $calendar->displayname, $calendar->url );
149
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
153 }
154 $sync_cache_filename = $args->cache_directory .'/'. md5($args->user . $calendar->url);
155
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;
162
163
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) );
173
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 }
179
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();
183
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 }
190 }
191 if ( !isset($cache) || !isset($cache->server_ctag) ) $sync_all = true;
192
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();
200
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;
207 }
208
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 );
215
216
217
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 }
247
248
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);
253
254 printf( "Fetched %d possible changes.\n", count($events) );
255
256 if ( !preg_match( '{/$}', $remote_event_prefix) ) $remote_event_prefix .= '/';
257 }
258
259
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.
264 *
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 }
324
325 if ( !$sync_all ) {
326 foreach( $cache->local_etags AS $href => $etag ) {
327 $fname = preg_replace('{^.*/}', '', $href);
328
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 }
337 }
338
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) );
341
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 }
352
353
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 }
364
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 }
370 }
371
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 }
380
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 }
407
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;
411 }
412
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);
418
419 print_r($newcache);
420
421 printf( "Completed.\n" );
422