From 2cf7023b375dfa9bfb9309b598933591c1caecaf Mon Sep 17 00:00:00 2001 From: Lukas Fleischer Date: Mon, 1 Feb 2016 17:42:58 +0100 Subject: calcurse-caldav: Make synchronization more robust In addition to storing ETag-hash tuples in the synchronization database, also store the URI of each object. This makes the synchronization process a bit more robust as we do not need to depend on ETags being globally unique. It also allows us to detect conflicts which occur when an object is modified locally and on the server at the same time. Signed-off-by: Lukas Fleischer --- contrib/caldav/calcurse-caldav.py | 135 +++++++++++++++++++++++--------------- 1 file changed, 83 insertions(+), 52 deletions(-) (limited to 'contrib/caldav') diff --git a/contrib/caldav/calcurse-caldav.py b/contrib/caldav/calcurse-caldav.py index 93193f4..6fd8900 100755 --- a/contrib/caldav/calcurse-caldav.py +++ b/contrib/caldav/calcurse-caldav.py @@ -14,14 +14,19 @@ import textwrap import xml.etree.ElementTree as etree -def die(msg): - newmsg = "" +def msgfmt(msg, prefix=''): + lines = [] for line in msg.splitlines(): - newmsg += textwrap.fill(line, 72) + '\n' - msg = "" - for line in newmsg.splitlines(): - msg += 'error: ' + line + '\n' - sys.exit(msg.rstrip('\n')) + lines += textwrap.wrap(line, 80 - len(prefix)) + return '\n'.join([prefix + line for line in lines]) + + +def warn(msg): + print(msgfmt(msg, "warning: ")) + + +def die(msg): + sys.exit(msgfmt(msg, "error: ")) def die_atnode(msg, node): @@ -129,7 +134,7 @@ def remote_query(conn, cmd, path, additional_headers, body): return (headers, body) -def get_hrefmap(conn, hrefs=[]): +def get_etags(conn, hrefs=[]): if len(hrefs) > 0: body = ('' ' 0: - etag = next(iter(hrefmap.keys())) + etagdict = get_etags(conn, [href]) + if etagdict: + etag = next(iter(etagdict.values())) etag = etag.strip('"') - return etag + return (href, etag) def remove_remote_object(conn, etag, href): @@ -229,10 +234,10 @@ def remove_remote_object(conn, etag, href): remote_query(conn, "DELETE", href, headers, None) -def push_objects(conn, syncdb, hrefmap): +def push_objects(conn, syncdb, etagdict): objhashes = calcurse_hashset() - new = objhashes - set(syncdb.values()) - gone = set(syncdb.values()) - objhashes + new = objhashes - set([entry[1] for entry in syncdb.values()]) + gone = set([entry[1] for entry in syncdb.values()]) - objhashes added = deleted = 0 @@ -243,21 +248,27 @@ def push_objects(conn, syncdb, hrefmap): if dry_run: continue - etag = push_object(conn, objhash) - syncdb[etag] = objhash + href, etag = push_object(conn, objhash) + syncdb[href] = (etag, objhash) added += 1 # Remove locally deleted objects from the server. for objhash in gone: - deletags = [] - for key, value in syncdb.items(): - if value == objhash: - deletags.append(key) - - for etag in deletags: - if etag not in hrefmap: + queue = [] + for href, entry in syncdb.items(): + if entry[1] == objhash: + queue.append(href) + + for href in queue: + etag = syncdb[href][0] + + if etagdict[href] != etag: + warn(('%s was deleted locally but modified in the CalDAV ' + 'calendar. Keeping the modified version on the server. ' + 'Run the script again to import the modified object.') % + (objhash)) + syncdb.pop(href, None) continue - href = hrefmap[etag] if verbose: print("Removing remote object %s (%s)." % (etag, href)) @@ -265,23 +276,29 @@ def push_objects(conn, syncdb, hrefmap): continue remove_remote_object(conn, etag, href) - syncdb.pop(etag, None) + syncdb.pop(href, None) deleted += 1 return (added, deleted) -def pull_objects(conn, syncdb, hrefmap): - missing = set(hrefmap.keys()) - set(syncdb.keys()) - orphan = set(syncdb.keys()) - set(hrefmap.keys()) +def pull_objects(conn, syncdb, etagdict): + missing = set() + modified = set() + for href in set(etagdict.keys()): + if href not in syncdb: + missing.add(href) + elif etagdict[href] != syncdb[href][0]: + modified.add(href) + orphan = set(syncdb.keys()) - set(etagdict.keys()) # Download and import new objects from the server. body = ('' '' '') - for etag in missing: - body += '%s' % (hrefmap[etag]) + for href in (missing | modified): + body += '%s' % (href) body += '' headers, body = remote_query(conn, "REPORT", path, {}, body) @@ -289,29 +306,43 @@ def pull_objects(conn, syncdb, hrefmap): added = deleted = 0 - for node in root.findall(".//D:prop", namespaces=nsmap): - etagnode = node.find("./D:getetag", namespaces=nsmap) + for node in root.findall(".//D:response", namespaces=nsmap): + hrefnode = node.find("./D:href", namespaces=nsmap) + if hrefnode is None: + die_atnode('Missing href.', node) + href = hrefnode.text + + etagnode = node.find("./D:propstat/D:prop/D:getetag", namespaces=nsmap) if etagnode is None: die_atnode('Missing ETag.', node) etag = etagnode.text.strip('"') - cdatanode = node.find("./C:calendar-data", namespaces=nsmap) + cdatanode = node.find("./D:propstat/D:prop/C:calendar-data", + namespaces=nsmap) if cdatanode is None: die_atnode('Missing calendar data.', node) cdata = cdatanode.text - if verbose: - print("Importing new object %s." % (etag)) - if dry_run: - continue + if href in modified: + if verbose: + print("Replacing object %s." % (etag)) + if dry_run: + continue + objhash = syncdb[href][1] + calcurse_remove(objhash) + else: + if verbose: + print("Importing new object %s." % (etag)) + if dry_run: + continue objhash = calcurse_import(cdata) - syncdb[etag] = objhash + syncdb[href] = (etag, objhash) added += 1 # Delete objects that no longer exist on the server. - for etag in orphan: - objhash = syncdb[etag] + for href in orphan: + etag, objhash = syncdb[href] if verbose: print("Removing local object %s." % (objhash)) @@ -319,7 +350,7 @@ def pull_objects(conn, syncdb, hrefmap): continue calcurse_remove(objhash) - syncdb.pop(etag, None) + syncdb.pop(href, None) deleted += 1 return (added, deleted) @@ -468,15 +499,15 @@ try: # Query the server and build a dictionary that maps ETags to paths on the # server. - hrefmap = get_hrefmap(conn) + etagdict = get_etags(conn) # Retrieve new objects from the server, delete local items that no longer # exist on the server. - local_new, local_del = pull_objects(conn, syncdb, hrefmap) + local_new, local_del = pull_objects(conn, syncdb, etagdict) # Push new objects to the server, remove items from the server if they no # longer exist locally. - remote_new, remote_del = push_objects(conn, syncdb, hrefmap) + remote_new, remote_del = push_objects(conn, syncdb, etagdict) # Write the synchronization database. save_syncdb(syncdbfn, syncdb) -- cgit v1.2.3