import os
import pickle
import collections
+import argparse
+import time
import logging
logger = logging.getLogger('gooswapper')
logger.setLevel(logging.INFO)
l=list(acct.fetch([(itemid,changekey)]))
return list(acct.fetch([(itemid,changekey)]))[0]
+def get_gcal_event_by_eventid(gcal_acct,eventId,gcal_id="primary"):
+ return gcal_acct.events().get(calendarId=gcal_id,eventId=eventId).execute()
+
def get_gcal_recur_instance(gcal_acct,gcal_master,start,gcal_id="primary"):
+ if gcal_master is None:
+ logger.warning("Cannot get recurrences from event with null gcal id")
+ return None
ans = gcal_acct.events().instances(calendarId=gcal_id,
eventId=gcal_master,
originalStart=start.isoformat(),
password = getpass.getpass(prompt="Password for user %s: " % username)
return exchangelib.ServiceAccount(username,password)
-def ex_login(emailaddr,ad_cache_path=None):
+def ex_login(username,emailaddr,ad_cache_path=None):
global exchange_credential
autodiscover = True
if exchange_credential is None:
- exchange_credential = get_ex_cred()
+ exchange_credential = get_ex_cred(username)
if ad_cache_path is not None:
try:
with open(ad_cache_path,"rb") as f:
def rr_daystr_from_int(i):
return exchangelib.recurrence._weekday_to_str(i).upper()[:2]
+#for monthly patterns, we want the week (or -1 for last) combined with each
+#day specified
+def rr_daystr_monthly(p):
+ if p.week_number == 5:
+ wn = "-1"
+ else:
+ wn = str(p.week_number)
+ return ",".join([wn + rr_daystr_from_int(x) for x in p.weekdays])
+
def rrule_from_ex(event,gcal_tz):
if event.type != "RecurringMaster":
logger.error("Cannot make recurrence from not-recurring event")
(event.recurrence.pattern.interval,
",".join([rr_daystr_from_int(x) for x in event.recurrence.pattern.weekdays]),
rr_daystr_from_int(event.recurrence.pattern.first_day_of_week) )
+ elif isinstance(event.recurrence.pattern,
+ exchangelib.recurrence.RelativeMonthlyPattern):
+ rr = "RRULE:FREQ=MONTHLY;INTERVAL=%d;BYDAY=%s" % \
+ (event.recurrence.pattern.interval,
+ rr_daystr_monthly(event.recurrence.pattern))
+ elif isinstance(event.recurrence.pattern,
+ exchangelib.recurrence.AbsoluteMonthlyPattern):
+ rr = "RRULE:FREQ=MONTHLY;INTERVAL=%d;BYMONTHDAY=%d" % \
+ (event.recurrence.pattern.interval,
+ event.recurrence.pattern.day_of_month)
+ elif isinstance(event.recurrence.pattern,
+ exchangelib.recurrence.AbsoluteYearlyPattern):
+ rr = "RRULE:FREQ=YEARLY;BYMONTH=%d;BYMONTHDAY=%d" % \
+ (event.recurrence.pattern.month,
+ event.recurrence.pattern.day_of_month)
+ elif isinstance(event.recurrence.pattern,
+ exchangelib.recurrence.RelativeYearlyPattern):
+ rr = "RRULE:FREQ=YEARLY;BYMONTH=%d;BYDAY=%s" % \
+ (event.recurrence.pattern.month,
+ rr_daystr_monthly(event.recurrence.pattern))
else:
logger.error("Recurrence %s not supported" % event.recurrence)
return None
if isinstance(event.recurrence.boundary,
exchangelib.recurrence.EndDatePattern):
rr += ";UNTIL={0:%Y}{0:%m}{0:%d}".format(event.recurrence.boundary.end)
+ elif isinstance(event.recurrence.boundary,
+ exchangelib.recurrence.NoEndPattern):
+ pass #no end date to set
else:
logger.error("Recurrence %s not supported" % event.recurrence)
return None
def del_ex_to_gcal(ex_acct, gcal_acct, events, deleted, gcal_id="primary"):
for ev_id in deleted:
if events[ev_id].gcal_link is not None:
- gcal_acct.events().delete(calendarId=gcal_id,
- eventId=events[ev_id].gcal_link,
- sendUpdates="none").execute()
+ gevent = get_gcal_event_by_eventid(gcal_acct,
+ events[ev_id].gcal_link,
+ gcal_id)
+ if gevent["status"] != "cancelled":
+ gcal_acct.events().delete(calendarId=gcal_id,
+ eventId=events[ev_id].gcal_link,
+ sendUpdates="none").execute()
def update_ex_to_gcal(ex_acct,
gcal_acct,gcal_tz,
gcal_id="primary"):
for ev_id in changed:
event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
+ if event.gcal_link is None:
+ logger.warning("Cannot apply update where event has no gcal link")
+ continue
gevent = build_gcal_event_from_ex(event,gcal_tz)
if event.type=="RecurringMaster":
rr = rrule_from_ex(event,gcal_tz)
else:
logger.warning("Unable to set recurrence for %s" % event.item_id)
continue #don't make the gcal event
- gevent = gcal_acct.events().update(calendarId=gcal_id,
+ try: #may fail if we don't own the event
+ gevent = gcal_acct.events().update(calendarId=gcal_id,
eventId=event.gcal_link,
body=gevent,
sendUpdates="none").execute()
+ except googleapiclient.errors.HttpError as err:
+ if err.resp.status == 403:
+ pass
-def match_ex_to_gcal(ex_acct,gcal_acct,gcal_tz,events,gcal_id="primary"):
+def match_ex_to_gcal(ex_acct,gcal_acct,gcal_tz,events,gcal_id="primary",ignore_link=True):
recur = 0
matched = 0
skipped = 0
+ toadd = []
for ev_id in events:
event = get_ex_event_by_itemid(ex_acct.calendar,ev_id)
- if event.is_recurring:
- recur += 1
- continue
- elif event.gcal_link is not None:
+ if event.gcal_link is not None and ignore_link is False:
skipped += 1
continue
+ missing=True
matches = gcal_acct.events().list(calendarId=gcal_id,
timeMin=event.start.isoformat(),
timeMax=event.end.isoformat()).execute()
for ge in matches['items']:
- if ge['summary'].strip()==event.subject.strip():
+ if ( ge.get("summary") is None and event.subject is None ) or \
+ ( ge.get("summary") is not None and event.subject is not None \
+ and ge['summary'].strip()==event.subject.strip()):
logger.info("Matching '%s' starting at %s" % (event.subject,
event.start.isoformat()))
event.gcal_link = ge['id']
if err.resp.status == 403:
pass
matched += 1
+ missing = False
break
+ if missing == True:
+ toadd.append(ev_id)
logger.info("Matched %d events, skipped %d with existing link, and %d recurring ones" % (matched,skipped,recur))
+ return toadd
-def get_gcal_cred():
+def get_gcal_cred(args):
#each such file can only store a single credential
storage = oauth2client.file.Storage(gcal_authpath)
gcal_credential = storage.get()
if gcal_credential is None or gcal_credential.invalid:
gcal_credential = oauth2client.tools.run_flow(flow,
storage,
- oauth2client.tools.argparser.parse_args())
+ args)
return gcal_credential
-def gcal_login():
- gcal_credential = get_gcal_cred()
+def gcal_login(args):
+ gcal_credential = get_gcal_cred(args)
# Object to handle http requests; could add proxy details
http = httplib2.Http()
http = gcal_credential.authorize(http)
return exchangelib.EWSTimeZone.timezone(gcal['timeZone'])
def main():
- try:
- with open(cachepath,"rb") as f:
- cache = pickle.load(f)
- except FileNotFoundError:
- cache = None
+ ap=argparse.ArgumentParser(description="Gooswapper calendar sync",
+ parents=[oauth2client.tools.argparser])
+ ap.add_argument("exchuser",help="Exchange user e.g. 'SANGER\mv3'")
+ ap.add_argument("exchemail",
+ help="Exchange calendar email e.g. ISGGroup@sanger.ac.uk")
+ ap.add_argument("-g","--gcalid",help="google Calendar ID")
+ ap.add_argument("-l","--loop",help="keep running indefinitely",
+ action="store_true")
+ args = ap.parse_args()
+ if args.gcalid is None:
+ gcal_id = "primary"
+ else:
+ gcal_id = args.gcalid
- ex_account = ex_login("mv3@sanger.ac.uk",".gooswapper_exch_conf.dat")
- current = get_ex_events(ex_account.calendar)
+ #log in to the accounts
+ ex_account = ex_login(args.exchuser,args.exchemail,
+ ".gooswapper_exch_conf.dat")
+ gcal_account = gcal_login(args)
+ gcal_tz = get_gcal_timezone(gcal_account,gcal_id)
- gcal_account = gcal_login()
- gcal_tz = get_gcal_timezone(gcal_account)
-
- if cache is not None:
- added,deleted,changed = ex_event_changes(cache,current)
- add_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,added)
- #delete op needs the "cache" set, as that has the link ids in
- #for events that are now deleted
- del_ex_to_gcal(ex_account,gcal_account,cache,deleted)
- update_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,changed)
- else:
- match_ex_to_gcal(ex_account,gcal_account,gcal_tz,current)
+ #Main loop (broken at the end if login is false)
+ while True:
+ try:
+ with open(cachepath,"rb") as f:
+ cache = pickle.load(f)
+ except FileNotFoundError:
+ cache = None
+
+ current = get_ex_events(ex_account.calendar)
+
+ if cache is not None:
+ added,deleted,changed = ex_event_changes(cache,current)
+ add_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,
+ added,gcal_id)
+ #delete op needs the "cache" set, as that has the link ids in
+ #for events that are now deleted
+ del_ex_to_gcal(ex_account,gcal_account,cache,deleted,gcal_id)
+ update_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,
+ changed,gcal_id)
+ else:
+ toadd = match_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,
+ gcal_id)
+ add_ex_to_gcal(ex_account,gcal_account,gcal_tz,current,
+ toadd,gcal_id)
- with open(cachepath,"wb") as f:
- pickle.dump(current,f)
+ with open(cachepath,"wb") as f:
+ pickle.dump(current,f)
+
+ #If not looping, break here (after 1 run)
+ if args.loop==False:
+ break
+ #otherwise, wait 10 minutes, then go round again
+ time.sleep(600)
if __name__ == "__main__":
main()