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