diff --git a/scripts/nc-cal-sync/calendar_synchronizer/cal_helper.py b/scripts/nc-cal-sync/calendar_synchronizer/cal_helper.py new file mode 100644 index 0000000..5b27446 --- /dev/null +++ b/scripts/nc-cal-sync/calendar_synchronizer/cal_helper.py @@ -0,0 +1,46 @@ +import datetime as dt +import re, logging + +from .sync import Event + +class IcalHelper: + + _WEEKDAY_MAP = { + 'Mon': 0, + 'Tue': 1, + 'Wed': 2, + 'Thu': 3, + 'Fri': 4, + 'Sat': 5, + 'Sun': 6 + } + + def __init__(self): + self._l = logging.getLogger(__name__) + self.dDay = dt.timedelta(days=1) + self.firstDay = dt.datetime(year=dt.datetime.now().year, month=1, day=1) + self.reTime = re.compile(r'(\d{1,2}):(\d{2})') + + def getStart(self, event: Event, holidays): + self._getFirstWeekdayInYear(event.day) + self._getFirstOccurence(event, holidays) + pass + + def _getFirstWeekdayInYear(self, weekday): + candidate = self.firstDay + while candidate.weekday() != self._WEEKDAY_MAP[weekday]: + candidate += self.dDay + + self._l.log(5, 'First %s in year is %s', weekday, candidate) + + return candidate + + def _getSortedHolidays(self, holidays): + return sorted(holidays['holidays'], key=lambda h: h['from']) + + def _getSortedFeasts(self, holidays): + return sorted(holidays['feasts']) + + def _getFirstOccurence(self, event: Event, holidays): + firstWeekday = self._getFirstWeekdayInYear(event.day) + firstWeekday.tzinfo = dt.timezone.tzname('Europe/Berlin') diff --git a/scripts/nc-cal-sync/calendar_synchronizer/sync.py b/scripts/nc-cal-sync/calendar_synchronizer/sync.py index 809fc1c..437a2eb 100644 --- a/scripts/nc-cal-sync/calendar_synchronizer/sync.py +++ b/scripts/nc-cal-sync/calendar_synchronizer/sync.py @@ -1,22 +1,281 @@ -import logging, json +import logging, yaml, pprint, json, re +import hashlib +import caldav +import datetime as dt from . import login _l = logging.getLogger(__name__) def buildSubparser(subparser): - subparser.add_argument('--schedule', default='../../data/schedule.json') - subparser.add_argument('--holidays', default='../../data/holidays.json') + subparser.add_argument('--schedule', default='../../data/schedule.yaml') + subparser.add_argument('--holidays', default='../../data/holidays.yaml') + subparser.add_argument('-n', '--dry', action='store_true') + subparser.add_argument('--recreate-all', action='store_true') def run(args): - _l.info('Loading data from hard disc') + loginData, schedule, holidays = _loadRawData(args) + packedSchedule = _unpackSchedules(schedule) + fixedSchedule = _addHolidayExceptions(packedSchedule, holidays) + _synchonizeCalendars(args, fixedSchedule, loginData, holidays) + +def _loadRawData(args): + _l.info('Loading data from hard disk') loginData = login.loadLoginData() with open(args.schedule, 'r') as f: - schedule = json.load(f) + schedule = yaml.safe_load(f.read()) + + _l.log(5, 'Schedule data:\n%s', pprint.pformat(schedule, indent=4, width=100)) with open(args.holidays, 'r') as f: - holidays = json.load(f) - - _l.info('Data was read from hard disc') + holidays = yaml.safe_load(f.read()) + _l.log(5, 'Holidays data:\n%s', pprint.pformat(holidays, indent=4, width=100)) + + _l.info('Data was read from hard disk') + + return loginData, schedule, holidays + +class Event: + def __init__(self, day, start, duration, title): + self.day = day + self.start = start + self.duration = duration + self.title = title + self.age = None + self.external = False + self.description = '' + self.exceptions = [] + + def __repr__(self): + wAge = f' ({self.age})' if self.age is not None else '' + return f'Ev({self.title}{wAge}) [{self.day}, {self.start}, {self.duration}]' + + def getHash(self, holidays): + def fixHolidays(holidays): + def fixDate(d): + return d.strftime('%Y-%m-%d') + + def fixFeast(f): + return fixDate(f) + + def fixHoliday(h): + return { + 'from': fixDate(h['from']), + 'to': fixDate(h['to']), + } + + return { + **holidays, + 'feasts': [fixFeast(f) for f in holidays['feasts']], + 'holidays': [fixHoliday(h) for h in holidays['holidays']], + } + + data = { + 'day': self.day, + 'start': self.start, + 'duration': self.duration, + 'title': self.title, + 'age': self.age, + 'external': self.external, + 'description': self.description, + 'exceptions': self.exceptions, + "holidays": fixHolidays(holidays), + } + # pprint.pprint(data, indent=4, width=100) + hasher = hashlib.sha1() + hasher.update(json.dumps(data, sort_keys=True).encode('utf-8')) + return hasher.hexdigest() + + dDay = dt.timedelta(days=1) + + def addToCalendar(self, calendar: caldav.Calendar, holidays): + from . import cal_helper + + def getFirstTimeInYear(): + now = dt.datetime.now() + d = dt.datetime(year=now.year, month=1, day=1) + pass + + helper = cal_helper.IcalHelper() + helper.getStart(self, holidays) + + icalData = { + 'uid': self.getHash(holidays), + 'DTSTART': dt.datetime.now(), + 'DTEND': dt.datetime.now() + dt.timedelta(minutes=self.duration), + 'SUMMARY': self.title + } + # calendar.add_event(**icalData) + +def _unpackSchedules(schedule): + def packSingleCalendar(cal): + def parseSingleEvent(ev): + e = Event( + day=ev['day'], + start=ev['start'], + duration=ev['duration'], + title=ev['title'] + ) + + if 'age' in ev: + e.age = ev['age'] + if ev.get('extern', False): + e.external = True + if 'desc' in ev: + e.description = ev['desc'] + if 'exceptions' in ev: + raise Exception('Not yet implemented to have exceptions') + + return e + + _l.log(5, 'Unpacking calendar %s', cal) + ret = { **cal } + + if 'schedule' in ret: + ret['schedule'] = [ + parseSingleEvent(e) for e in ret['schedule'] + ] + + _l.log(5, 'Unpacked calendar %s', ret) + return ret + + ret = {} + + for calName in schedule['calendars']: + if schedule['calendars'][calName].get('ignore', False): + _l.info('Ignoring calendar %s', calName) + continue + + ret[calName] = packSingleCalendar(schedule['calendars'][calName]) + + _l.log(5, 'Unpacked schedule:\n%s', pprint.pformat(ret, indent=4, width=100)) + return ret + +def _addHolidayExceptions(schedule, holidays): + return schedule + +class CalendarSynchonizer: + def __init__(self, args, calId, calName, loginData): + self.args = args + self.calId = calId + self.calName = calName + self.loginData = loginData + self._calDav = None + + def _getUrl(self): + return f'{self.loginData.base}/remote.php/dav/calendars' + + def _getCalDav(self): + + if self._calDav is None: + self._calDav = caldav.DAVClient( + url=self._getUrl(), + username=self.loginData.loginName, + password=self.loginData.appPassword + ) + return self._calDav + + def _getCalendar(self): + cd = self._getCalDav() + return cd.principal().calendar(cal_id=self.calId) + + def synchonize(self, calendars, holidays): + downstreamEvents = self._getDownstreamEvents(calendars[self.calName]['schedule'], holidays) + _l.debug('Downstream events:\n%s', pprint.pformat(downstreamEvents, indent=4, width=100)) + + upstreamEvents = self._getUpstreamEvents() + _l.debug('Upstream events:\n%s', pprint.pformat(upstreamEvents, indent=4, width=100)) + + newEvents = self._getNewEvents(upstreamEvents, downstreamEvents) + _l.log(5, 'New events:\n%s', pprint.pformat(newEvents, indent=4, width=100)) + deletedEvents = self._getDeletedEvents(upstreamEvents, downstreamEvents) + _l.log(5, 'Events marked for deletion:\n%s', pprint.pformat(deletedEvents, indent=4, width=100)) + + self._deleteEvents(deletedEvents) + self._addEvents(newEvents, holidays) + + def _getUpstreamEvents(self): + _l.debug('Fetching upstream calendar entries') + cd = self._getCalDav() + + # principal = cd.principal() + # _l.log(5, 'Principal %s', principal) + + # calendar = principal.calendar(cal_id=self.calId) + # _l.log(5, 'Calendar %s', calendar) + + calendar = self._getCalendar() + + # children = calendar.children() + # _l.debug('Getting children for calendar %s:\n%s', self.calId, children) + + events = calendar.events() + + ret = {} + + for e in events: + uid = str(e.icalendar_component['uid']) + _l.log(5, 'Event with uid %s was found.', uid) + ret[uid] = e + + return ret + + def _getDownstreamEvents(self, schedule, holidays): + _l.debug('Preparing local calendar events') + + ret = {} + + for e in schedule: + uid = e.getHash(holidays) + _l.log(5, 'Event with uid %s was found.', uid) + ret[uid] = e + + return ret + + def _getNewEvents(self, upstreamEvents, downstreamEvents): + if self.args.recreate_all: + return downstreamEvents.values() + + upstreamUids = set(upstreamEvents.keys()) + downstreamUids = set(downstreamEvents.keys()) + newUids = downstreamUids - upstreamUids + + return [downstreamEvents[uid] for uid in newUids] + + def _getDeletedEvents(self, upstreamEvents, downstreamEvents): + if self.args.recreate_all: + return upstreamEvents.values() + + upstreamUids = set(upstreamEvents.keys()) + downstreamUids = set(downstreamEvents.keys()) + toDeleteUids = upstreamUids - downstreamUids + + return [upstreamEvents[uid] for uid in toDeleteUids] + + def _addEvents(self, newEvents, holidays): + cal = self._getCalendar() + + for e in newEvents: + _l.debug('Adding event %s', e) + if self.args.dry: + _l.info('Skipping add event %s (dry mode)', e) + else: + # e.save() + e.addToCalendar(cal, holidays) + + def _deleteEvents(self, oldEvents): + for e in oldEvents: + _l.debug('Deleting event %s', e) + if self.args.dry: + _l.info('Skipping delete event %s (dry mode)', e) + else: + e.delete() + +def _synchonizeCalendars(args, calendars, loginData, holidays): + for calName in calendars: + calendar = calendars[calName] + _l.info('Synching calendar %s', calName) + calendarSynchonizer = CalendarSynchonizer(args, calendar['id'], calName, loginData) + calendarSynchonizer.synchonize(calendars, holidays)