1#!/usr/local/bin/python3.8
2
3import argparse
4import base64
5import configparser
6import os
7import pathlib
8import re
9import subprocess
10import sys
11import textwrap
12import xml.etree.ElementTree as etree
13
14import httplib2
15
16# Optional libraries for OAuth2 authentication
17try:
18    import webbrowser
19
20    from oauth2client.client import HttpAccessTokenRefreshError, OAuth2WebServerFlow
21    from oauth2client.file import Storage
22except ModuleNotFoundError:
23    pass
24
25
26class Config:
27    _map = {}
28
29    def __init__(self, fn):
30        self._map = {
31            'Auth': {
32                'Password': None,
33                'Username': None,
34            },
35            'CustomHeaders': {},
36            'General': {
37                'AuthMethod': 'basic',
38                'Binary': 'calcurse',
39                'Debug': False,
40                'DryRun': True,
41                'HTTPS': True,
42                'Hostname': None,
43                'InsecureSSL': False,
44                'Path': None,
45                'SyncFilter': 'cal,todo',
46                'Verbose': False,
47            },
48            'OAuth2': {
49                'ClientID': None,
50                'ClientSecret': None,
51                'RedirectURI': 'http://127.0.0.1',
52                'Scope': None,
53            },
54        }
55
56        config = configparser.RawConfigParser()
57        config.optionxform = str
58        if verbose:
59            print('Loading configuration from ' + configfn + '...')
60        try:
61            config.read_file(open(fn))
62        except FileNotFoundError:
63            die('Configuration file not found: {}'.format(fn))
64
65        for sec in config.sections():
66            if sec not in self._map:
67                die('Unexpected config section: {}'.format(sec))
68
69            if not self._map[sec]:
70                # Import section with custom key-value pairs.
71                self._map[sec] = dict(config.items(sec))
72                continue
73
74            # Import section with predefined keys.
75            for key, val in config.items(sec):
76                if key not in self._map[sec]:
77                    die('Unexpected config key in section {}: {}'.format(sec, key))
78                if type(self._map[sec][key]) == bool:
79                    self._map[sec][key] = config.getboolean(sec, key)
80                else:
81                    self._map[sec][key] = val
82
83    def section(self, section):
84        return self._map[section]
85
86    def get(self, section, key):
87        return self._map[section][key]
88
89
90def msgfmt(msg, prefix=''):
91    lines = []
92    for line in msg.splitlines():
93        lines += textwrap.wrap(line, 80 - len(prefix))
94    return '\n'.join([prefix + line for line in lines])
95
96
97def warn(msg):
98    print(msgfmt(msg, "warning: "))
99
100
101def die(msg):
102    sys.exit(msgfmt(msg, "error: "))
103
104
105def check_dir(dir):
106    try:
107        pathlib.Path(dir).mkdir(parents=True, exist_ok=True)
108    except FileExistsError:
109        die("{} is not a directory".format(dir))
110
111
112def die_atnode(msg, node):
113    if debug:
114        msg += '\n\n'
115        msg += 'The error occurred while processing the following XML node:\n'
116        msg += etree.tostring(node).decode('utf-8')
117    die(msg)
118
119
120def validate_sync_filter():
121    valid_sync_filter_values = {'event', 'apt', 'recur-event', 'recur-apt', 'todo', 'recur', 'cal'}
122    return set(sync_filter.split(',')) - valid_sync_filter_values
123
124
125def calcurse_wipe():
126    if verbose:
127        print('Removing all local calcurse objects...')
128    if dry_run:
129        return
130
131    command = calcurse + ['-F', '--filter-hash=XXX']
132
133    if debug:
134        print('Running command: {}'.format(command))
135
136    subprocess.call(command)
137
138
139def calcurse_import(icaldata):
140    command = calcurse + [
141        '-i', '-',
142        '--dump-imported',
143        '-q',
144        '--format-apt=%(hash)\\n',
145        '--format-recur-apt=%(hash)\\n',
146        '--format-event=%(hash)\\n',
147        '--format-recur-event=%(hash)\\n',
148        '--format-todo=%(hash)\\n'
149    ]
150
151    if debug:
152        print('Running command: {}'.format(command))
153
154    p = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
155    return p.communicate(icaldata.encode('utf-8'))[0].decode('utf-8').rstrip()
156
157
158def calcurse_export(objhash):
159    command = calcurse + [
160        '-xical',
161        '--export-uid',
162        '--filter-hash=' + objhash
163    ]
164
165    if debug:
166        print('Running command: {}'.format(command))
167
168    p = subprocess.Popen(command, stdout=subprocess.PIPE)
169    return p.communicate()[0].decode('utf-8').rstrip()
170
171
172def calcurse_hashset():
173    command = calcurse + [
174        '-G',
175        '--filter-type', sync_filter,
176        '--format-apt=%(hash)\\n',
177        '--format-recur-apt=%(hash)\\n',
178        '--format-event=%(hash)\\n',
179        '--format-recur-event=%(hash)\\n',
180        '--format-todo=%(hash)\\n'
181    ]
182
183    if debug:
184        print('Running command: {}'.format(command))
185
186    p = subprocess.Popen(command, stdout=subprocess.PIPE)
187    return set(p.communicate()[0].decode('utf-8').rstrip().splitlines())
188
189
190def calcurse_remove(objhash):
191    command = calcurse + ['-F', '--filter-hash=!' + objhash]
192
193    if debug:
194        print('Running command: {}'.format(command))
195
196    subprocess.call(command)
197
198
199def calcurse_version():
200    command = calcurse + ['--version']
201
202    if debug:
203        print('Running command: {}'.format(command))
204
205    p = subprocess.Popen(command, stdout=subprocess.PIPE)
206    m = re.match(r'calcurse ([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9]+)-)?',
207                 p.communicate()[0].decode('utf-8'))
208    if not m:
209        return None
210    return tuple([int(group) for group in m.groups(0)])
211
212
213def get_auth_headers():
214    if not username or not password:
215        return {}
216    user_password = ('{}:{}'.format(username, password)).encode('utf-8')
217    user_password = base64.b64encode(user_password).decode('utf-8')
218    headers = {'Authorization': 'Basic {}'.format(user_password)}
219    return headers
220
221
222def init_auth(client_id, client_secret, scope, redirect_uri, authcode):
223    # Create OAuth2 session
224    oauth2_client = OAuth2WebServerFlow(client_id=client_id,
225                                        client_secret=client_secret,
226                                        scope=scope,
227                                        redirect_uri=redirect_uri)
228
229    # If auth code is missing, tell user run script with auth code
230    if not authcode:
231        # Generate and open URL for user to authorize
232        auth_uri = oauth2_client.step1_get_authorize_url()
233        webbrowser.open(auth_uri)
234
235        prompt = ('\nIf a browser window did not open, go to the URL '
236                  'below and log in to authorize syncing. '
237                  'Once authorized, pass the string after "code=" from '
238                  'the URL in your browser\'s address bar to '
239                  'calcurse-caldav.py using the "--authcode" flag. '
240                  "Example: calcurse-caldav --authcode "
241                  "'your_auth_code_here'\n\n{}\n".format(auth_uri))
242        print(prompt)
243        die("Access token is missing or refresh token is expired.")
244
245    # Create and return Credential object from auth code
246    credentials = oauth2_client.step2_exchange(authcode)
247
248    # Setup storage file and store credentials
249    storage = Storage(oauth_file)
250    credentials.set_store(storage)
251    storage.put(credentials)
252
253    return credentials
254
255
256def run_auth(authcode):
257    # Check if credentials file exists
258    if os.path.isfile(oauth_file):
259
260        # Retrieve token from file
261        storage = Storage(oauth_file)
262        credentials = storage.get()
263
264        # Set file to store it in for future functions
265        credentials.set_store(storage)
266
267        # Refresh the access token if it is expired
268        if credentials.invalid:
269            try:
270                credentials.refresh(httplib2.Http())
271            except HttpAccessTokenRefreshError:
272                # Initialize OAuth2 again if refresh token becomes invalid
273                credentials = init_auth(client_id, client_secret, scope, redirect_uri, authcode)
274    else:
275        # Initialize OAuth2 credentials
276        credentials = init_auth(client_id, client_secret, scope, redirect_uri, authcode)
277
278    return credentials
279
280
281def remote_query(conn, cmd, path, additional_headers, body):
282    headers = custom_headers.copy()
283    headers.update(get_auth_headers())
284    if cmd == 'PUT':
285        headers['Content-Type'] = 'text/calendar; charset=utf-8'
286    else:
287        headers['Content-Type'] = 'application/xml; charset=utf-8'
288    headers.update(additional_headers)
289
290    if debug:
291        print("> {} {}".format(cmd, path))
292        headers_sanitized = headers.copy()
293        if not debug_raw:
294            headers_sanitized.pop('Authorization', None)
295        print("> Headers: " + repr(headers_sanitized))
296        if body:
297            for line in body.splitlines():
298                print("> " + line)
299        print()
300
301    if isinstance(body, str):
302        body = body.encode('utf-8')
303
304    resp, body = conn.request(path, cmd, body=body, headers=headers)
305    body = body.decode('utf-8')
306
307    if not resp:
308        return (None, None)
309
310    if debug:
311        print("< Status: {} ({})".format(resp.status, resp.reason))
312        print("< Headers: " + repr(resp))
313        for line in body.splitlines():
314            print("< " + line)
315        print()
316
317    if resp.status - (resp.status % 100) != 200:
318        die(("The server at {} replied with HTTP status code {} ({}) " +
319             "while trying to access {}.").format(hostname, resp.status,
320                                                  resp.reason, path))
321
322    return (resp, body)
323
324
325def get_etags(conn, hrefs=[]):
326    if len(hrefs) > 0:
327        headers = {}
328        body = ('<?xml version="1.0" encoding="utf-8" ?>'
329                '<C:calendar-multiget xmlns:D="DAV:" '
330                '                     xmlns:C="urn:ietf:params:xml:ns:caldav">'
331                '<D:prop><D:getetag /></D:prop>')
332        for href in hrefs:
333            body += '<D:href>{}</D:href>'.format(href)
334        body += '</C:calendar-multiget>'
335    else:
336        headers = {'Depth': '1'}
337        body = ('<?xml version="1.0" encoding="utf-8" ?>'
338                '<C:calendar-query xmlns:D="DAV:" '
339                '                  xmlns:C="urn:ietf:params:xml:ns:caldav">'
340                '<D:prop><D:getetag /></D:prop>'
341                '<C:filter><C:comp-filter name="VCALENDAR" /></C:filter>'
342                '</C:calendar-query>')
343    headers, body = remote_query(conn, "REPORT", absolute_uri, headers, body)
344    if not headers:
345        return {}
346    root = etree.fromstring(body)
347
348    etagdict = {}
349    for node in root.findall(".//D:response", namespaces=nsmap):
350        etagnode = node.find("./D:propstat/D:prop/D:getetag", namespaces=nsmap)
351        if etagnode is None:
352            die_atnode('Missing ETag.', node)
353        etag = etagnode.text.strip('"')
354
355        hrefnode = node.find("./D:href", namespaces=nsmap)
356        if hrefnode is None:
357            die_atnode('Missing href.', node)
358        href = hrefnode.text
359
360        etagdict[href] = etag
361
362    return etagdict
363
364
365def remote_wipe(conn):
366    if verbose:
367        print('Removing all objects from the CalDAV server...')
368    if dry_run:
369        return
370
371    remote_items = get_etags(conn)
372    for href in remote_items:
373        remove_remote_object(conn, remote_items[href], href)
374
375
376def get_syncdb(fn):
377    if not os.path.exists(fn):
378        return {}
379
380    if verbose:
381        print('Loading synchronization database from ' + fn + '...')
382
383    syncdb = {}
384    with open(fn, 'r') as f:
385        for line in f.readlines():
386            href, etag, objhash = line.rstrip().split(' ')
387            syncdb[href] = (etag, objhash)
388
389    return syncdb
390
391
392def syncdb_add(syncdb, href, etag, objhash):
393    syncdb[href] = (etag, objhash)
394    if debug:
395        print('New sync database entry: {} {} {}'.format(href, etag, objhash))
396
397
398def syncdb_remove(syncdb, href):
399    syncdb.pop(href, None)
400    if debug:
401        print('Removing sync database entry: {}'.format(href))
402
403
404def save_syncdb(fn, syncdb):
405    if verbose:
406        print('Saving synchronization database to ' + fn + '...')
407    if dry_run:
408        return
409
410    with open(fn, 'w') as f:
411        for href, (etag, objhash) in syncdb.items():
412            print("{} {} {}".format(href, etag, objhash), file=f)
413
414
415def push_object(conn, objhash):
416    href = path + objhash + ".ics"
417    body = calcurse_export(objhash)
418    headers, body = remote_query(conn, "PUT", hostname_uri + href, {}, body)
419
420    if not headers:
421        return None
422    headerdict = dict(headers)
423
424    # Retrieve href from server to match server-side format. Retrieve ETag
425    # unless it can be extracted from the PUT response already.
426    ret_href, ret_etag = None, headerdict.get('etag')
427    while not ret_etag or not ret_href:
428        etagdict = get_etags(conn, [href])
429        if not etagdict:
430            continue
431        ret_href, new_etag = next(iter(etagdict.items()))
432        # Favor ETag from PUT response to avoid race condition.
433        if not ret_etag:
434            ret_etag = new_etag
435
436    return (ret_href, ret_etag.strip('"'))
437
438
439def push_objects(objhashes, conn, syncdb, etagdict):
440    # Copy new objects to the server.
441    added = 0
442    for objhash in objhashes:
443        if verbose:
444            print("Pushing new object {} to the server.".format(objhash))
445        if dry_run:
446            continue
447
448        href, etag = push_object(conn, objhash)
449        syncdb_add(syncdb, href, etag, objhash)
450        added += 1
451
452    return added
453
454
455def remove_remote_object(conn, etag, href):
456    headers = {'If-Match': '"' + etag + '"'}
457    remote_query(conn, "DELETE", hostname_uri + href, headers, None)
458
459
460def remove_remote_objects(objhashes, conn, syncdb, etagdict):
461    # Remove locally deleted objects from the server.
462    deleted = 0
463    for objhash in objhashes:
464        queue = []
465        for href, entry in syncdb.items():
466            if entry[1] == objhash:
467                queue.append(href)
468
469        for href in queue:
470            etag = syncdb[href][0]
471
472            if etagdict[href] != etag:
473                warn(('{} was deleted locally but modified in the CalDAV '
474                      'calendar. Keeping the modified version on the server. '
475                      'Run the script again to import the modified '
476                      'object.').format(objhash))
477                syncdb_remove(syncdb, href)
478                continue
479
480            if verbose:
481                print("Removing remote object {} ({}).".format(etag, href))
482            if dry_run:
483                continue
484
485            remove_remote_object(conn, etag, href)
486            syncdb_remove(syncdb, href)
487            deleted += 1
488
489    return deleted
490
491
492def pull_objects(hrefs_missing, hrefs_modified, conn, syncdb, etagdict):
493    if not hrefs_missing and not hrefs_modified:
494        return 0
495
496    # Download and import new objects from the server.
497    body = ('<?xml version="1.0" encoding="utf-8" ?>'
498            '<C:calendar-multiget xmlns:D="DAV:" '
499            '                     xmlns:C="urn:ietf:params:xml:ns:caldav">'
500            '<D:prop><D:getetag /><C:calendar-data /></D:prop>')
501    for href in (hrefs_missing | hrefs_modified):
502        body += '<D:href>{}</D:href>'.format(href)
503    body += '</C:calendar-multiget>'
504    headers, body = remote_query(conn, "REPORT", absolute_uri, {}, body)
505
506    root = etree.fromstring(body)
507
508    added = 0
509
510    for node in root.findall(".//D:response", namespaces=nsmap):
511        hrefnode = node.find("./D:href", namespaces=nsmap)
512        if hrefnode is None:
513            die_atnode('Missing href.', node)
514        href = hrefnode.text
515
516        statusnode = node.find("./D:status", namespaces=nsmap)
517        if statusnode is not None:
518            status = re.match(r'HTTP.*(\d\d\d)', statusnode.text)
519            if status is None:
520                die_atnode('Could not parse status.', node)
521            statuscode = status.group(1)
522            if statuscode == '404':
523                print('Skipping missing item: {}'.format(href))
524                continue
525
526        etagnode = node.find("./D:propstat/D:prop/D:getetag", namespaces=nsmap)
527        if etagnode is None:
528            die_atnode('Missing ETag.', node)
529        etag = etagnode.text.strip('"')
530
531        cdatanode = node.find("./D:propstat/D:prop/C:calendar-data",
532                              namespaces=nsmap)
533        if cdatanode is None:
534            die_atnode('Missing calendar data.', node)
535        cdata = cdatanode.text
536
537        if href in hrefs_modified:
538            if verbose:
539                print("Replacing object {}.".format(etag))
540            if dry_run:
541                continue
542            objhash = syncdb[href][1]
543            calcurse_remove(objhash)
544        else:
545            if verbose:
546                print("Importing new object {}.".format(etag))
547            if dry_run:
548                continue
549
550        objhash = calcurse_import(cdata)
551
552        # TODO: Add support for importing multiple events at once, see GitHub
553        # issue #20 for details.
554        if re.match(r'[0-ga-f]+$', objhash):
555            syncdb_add(syncdb, href, etag, objhash)
556            added += 1
557        else:
558            print("Failed to import object: {} ({})".format(etag, href),
559                  file=sys.stderr)
560
561    return added
562
563
564def remove_local_objects(hrefs, conn, syncdb, etagdict):
565    # Delete objects that no longer exist on the server.
566    deleted = 0
567    for href in hrefs:
568        etag, objhash = syncdb[href]
569
570        if verbose:
571            print("Removing local object {}.".format(objhash))
572        if dry_run:
573            continue
574
575        calcurse_remove(objhash)
576        syncdb_remove(syncdb, href)
577        deleted += 1
578
579    return deleted
580
581
582def run_hook(name):
583    hook_path = hookdir + '/' + name
584    if not os.path.exists(hook_path):
585        return
586    subprocess.call(hook_path, shell=True)
587
588
589# Initialize the XML namespace map.
590nsmap = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav"}
591
592# Initialize default values.
593if os.path.isdir(os.path.expanduser("~/.calcurse")):
594    caldav_path = os.path.expanduser("~/.calcurse/caldav")
595    check_dir(caldav_path)
596
597    configfn = os.path.join(caldav_path, "config")
598    hookdir = os.path.join(caldav_path, "hooks")
599    oauth_file = os.path.join(caldav_path, "oauth2_cred")
600    lockfn = os.path.join(caldav_path, "lock")
601    syncdbfn = os.path.join(caldav_path, "sync.db")
602else:
603    xdg_config_home = os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
604    xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
605    caldav_config = os.path.join(xdg_config_home, "calcurse", "caldav")
606    caldav_data = os.path.join(xdg_data_home, "calcurse", "caldav")
607    check_dir(caldav_config)
608    check_dir(caldav_data)
609
610    configfn = os.path.join(caldav_config, "config")
611    hookdir = os.path.join(caldav_config, "hooks")
612    oauth_file = os.path.join(caldav_config, "oauth2_cred")
613
614    lockfn = os.path.join(caldav_data, "lock")
615    syncdbfn = os.path.join(caldav_data, "sync.db")
616
617# Parse command line arguments.
618parser = argparse.ArgumentParser('calcurse-caldav')
619parser.add_argument('--init', action='store', dest='init', default=None,
620                    choices=['keep-remote', 'keep-local', 'two-way'],
621                    help='initialize the sync database')
622parser.add_argument('--config', action='store', dest='configfn',
623                    default=configfn,
624                    help='path to the calcurse-caldav configuration')
625parser.add_argument('--datadir', action='store', dest='datadir',
626                    default=None,
627                    help='path to the calcurse data directory')
628parser.add_argument('--lockfile', action='store', dest='lockfn',
629                    default=lockfn,
630                    help='path to the calcurse-caldav lock file')
631parser.add_argument('--syncdb', action='store', dest='syncdbfn',
632                    default=syncdbfn,
633                    help='path to the calcurse-caldav sync DB')
634parser.add_argument('--hookdir', action='store', dest='hookdir',
635                    default=hookdir,
636                    help='path to the calcurse-caldav hooks directory')
637parser.add_argument('--authcode', action='store', dest='authcode',
638                    default=None,
639                    help='auth code for OAuth2 authentication')
640parser.add_argument('-v', '--verbose', action='store_true', dest='verbose',
641                    default=False,
642                    help='print status messages to stdout')
643parser.add_argument('--debug', action='store_true', dest='debug',
644                    default=False, help='print debug messages to stdout')
645parser.add_argument('--debug-raw', action='store_true', dest='debug_raw',
646                    default=False, help='do not sanitize debug messages')
647args = parser.parse_args()
648
649init = args.init is not None
650configfn = args.configfn
651lockfn = args.lockfn
652syncdbfn = args.syncdbfn
653datadir = args.datadir
654hookdir = args.hookdir
655authcode = args.authcode
656verbose = args.verbose
657debug = args.debug
658debug_raw = args.debug_raw
659
660# Read environment variables
661password = os.getenv('CALCURSE_CALDAV_PASSWORD')
662
663# Read configuration.
664config = Config(configfn)
665
666authmethod = config.get('General', 'AuthMethod').lower()
667calcurse = [config.get('General', 'Binary')]
668debug = debug or config.get('General', 'Debug')
669dry_run = config.get('General', 'DryRun')
670hostname = config.get('General', 'Hostname')
671https = config.get('General', 'HTTPS')
672insecure_ssl = config.get('General', 'InsecureSSL')
673path = config.get('General', 'Path')
674sync_filter = config.get('General', 'SyncFilter')
675verbose = verbose or config.get('General', 'Verbose')
676
677password = password or config.get('Auth', 'Password')
678username = config.get('Auth', 'Username')
679
680client_id = config.get('OAuth2', 'ClientID')
681client_secret = config.get('OAuth2', 'ClientSecret')
682redirect_uri = config.get('OAuth2', 'RedirectURI')
683scope = config.get('OAuth2', 'Scope')
684
685custom_headers = config.section('CustomHeaders')
686
687# Append data directory to calcurse command.
688if datadir:
689    check_dir(datadir)
690    calcurse += ['-D', datadir]
691
692# Validate sync filter.
693invalid_filter_values = validate_sync_filter()
694if len(invalid_filter_values):
695    die('Invalid value(s) in SyncFilter option: ' + ', '.join(invalid_filter_values))
696
697# Ensure host name and path are defined and initialize *_uri.
698if not hostname:
699    die('Hostname missing in configuration.')
700if not path:
701    die('Path missing in configuration.')
702urlprefix = "https://" if https else "http://"
703path = '/{}/'.format(path.strip('/'))
704hostname_uri = urlprefix + hostname
705absolute_uri = hostname_uri + path
706
707# Show disclaimer when performing a dry run.
708if dry_run:
709    warn(('Dry run; nothing is imported/exported. Add "DryRun = No" to the '
710          '[General] section in the configuration file to enable '
711          'synchronization.'))
712
713# Check whether the specified calcurse binary is executable and compatible.
714ver = calcurse_version()
715if ver is None:
716    die('Invalid calcurse binary. Make sure that the file specified in ' +
717        'the configuration is a valid and up-to-date calcurse binary.')
718elif ver < (4, 0, 0, 96):
719    die('Incompatible calcurse binary detected. Version >=4.1.0 is required ' +
720        'to synchronize with CalDAV servers.')
721
722# Run the pre-sync hook.
723run_hook('pre-sync')
724
725# Create lock file.
726if os.path.exists(lockfn):
727    die('Leftover lock file detected. If there is no other synchronization ' +
728        'instance running, please remove the lock file manually and try ' +
729        'again.')
730open(lockfn, 'w')
731
732try:
733    # Connect to the server.
734    if verbose:
735        print('Connecting to ' + hostname + '...')
736    conn = httplib2.Http()
737    if insecure_ssl:
738        conn.disable_ssl_certificate_validation = True
739
740    if authmethod == 'oauth2':
741        # Authenticate with OAuth2 and authorize HTTP object
742        cred = run_auth(authcode)
743        conn = cred.authorize(conn)
744    elif authmethod == 'basic':
745        # Add credentials to httplib2
746        conn.add_credentials(username, password)
747    else:
748        die('Invalid option for AuthMethod in config file. Use "basic" or "oauth2"')
749
750    if init:
751        # In initialization mode, start with an empty synchronization database.
752        if args.init == 'keep-remote':
753            calcurse_wipe()
754        elif args.init == 'keep-local':
755            remote_wipe(conn)
756        syncdb = {}
757    else:
758        # Read the synchronization database.
759        syncdb = get_syncdb(syncdbfn)
760
761        if not syncdb:
762            die('Sync database not found or empty. Please initialize the ' +
763                'database first.\n\nSupported initialization modes are:\n' +
764                '  --init=keep-remote Remove all local calcurse items\n' +
765                '  --init=keep-local  Remove all remote objects\n' +
766                '  --init=two-way     Copy local items to the server and vice versa')
767
768    # Query the server and compute a lookup table that maps each path to its
769    # current ETag.
770    etagdict = get_etags(conn)
771
772    # Compute object diffs.
773    missing = set()
774    modified = set()
775    for href in set(etagdict.keys()):
776        if href not in syncdb:
777            missing.add(href)
778        elif etagdict[href] != syncdb[href][0]:
779            modified.add(href)
780    orphan = set(syncdb.keys()) - set(etagdict.keys())
781
782    objhashes = calcurse_hashset()
783    new = objhashes - set([entry[1] for entry in syncdb.values()])
784    gone = set([entry[1] for entry in syncdb.values()]) - objhashes
785
786    # Retrieve new objects from the server.
787    local_new = pull_objects(missing, modified, conn, syncdb, etagdict)
788
789    # Delete local items that no longer exist on the server.
790    local_del = remove_local_objects(orphan, conn, syncdb, etagdict)
791
792    # Push new objects to the server.
793    remote_new = push_objects(new, conn, syncdb, etagdict)
794
795    # Remove items from the server if they no longer exist locally.
796    remote_del = remove_remote_objects(gone, conn, syncdb, etagdict)
797
798    # Write the synchronization database.
799    save_syncdb(syncdbfn, syncdb)
800
801    # Clear OAuth2 credentials if used.
802    if authmethod == 'oauth2':
803        conn.clear_credentials()
804
805finally:
806    # Remove lock file.
807    os.remove(lockfn)
808
809# Run the post-sync hook.
810run_hook('post-sync')
811
812# Print a summary to stdout.
813print("{} items imported, {} items removed locally.".
814      format(local_new, local_del))
815print("{} items exported, {} items removed from the server.".
816      format(remote_new, remote_del))
817