From: NeilBrown Date: Mon, 30 Dec 2013 02:19:22 +0000 (+1100) Subject: ical.py: ical RRULE parsing. X-Git-Url: http://git.neil.brown.name/?a=commitdiff_plain;h=3903b2d87a9e6a5f3c0f16418fbdcca64f54a883;p=plato.git ical.py: ical RRULE parsing. This is still preliminary code, not really usable as a library. The main purpose is to produce a list of date/times from an ical recurrence rule. --- diff --git a/lib/ical.py b/lib/ical.py new file mode 100644 index 0000000..c4afe36 --- /dev/null +++ b/lib/ical.py @@ -0,0 +1,740 @@ +# +# +# Ical manipulation +# Particularly date recurrance rules. +# But full VEVENTs really to provide full picture. +# +# A VEVENT has: +# DTSTART When event starts +# DTEND When event ends, if it has duration +# DURATION alternate form +# RRULE recurrance rule +# EXDATE dates that would be in the list, but are excluded +# +# +# RRULE can contain: +# COUNT - number of events +# UNTIL - last date to consider (not if COUNT is given) +# INTERVAL - number of units between events +# FREQ - YEARLY MONTHLY WEEKLY DAILY HOURLY MINUTELY SECONDLY +# The unit +# +# BYMONTH BYWEEKNO BYYEARDAY BYMONTHDAY BYDAY BYHOUR BYMINUTE BYSECOND +# give specific times in the unit. comma sep list +# BYDAY can be +5TU meaning first TUesday. -1TU is last +# BYMONTHDAY can be -1 for last day +# BYSETPOS 1 or more indexs into the current set of dates. +# WKST chose the day to start weeks (default MO) +# week 1 contains at least 4 days +# +# An RRULE produces a list of moments: date or date+time +# We start with the DTSTART which accompanies the RRULE and add +# moments based on FREQ (must be present) and INTERVAL (defaults to 1). +# Then for each BY* rule (in order given) we either discard +# moments that don't match, or split each into smaller-units. +# If a BY* is followed by BYSETPOS we filter the split through that list. +# We never keep more than COUNT or any date after UNTIL. +# Each 'by*' produces an object with an 'interval' and the +# ability to 'expand' or 'filter' a date +# If the current 'freq' if longer than the by* interval, we expand +# and update the 'freq' for subsequent 'by*'. +# If the current 'freq' is smaller or equal to the by* interval +# we keep or discard depending on the filter +# +# If there is a COUNT, we need to expand from DTSTART. If there isn't +# a COUNT we can expand from any date after rounding to FREQ*INTERVAL +# +# +# ARGH - this ical rrule stuff always confuses me. I need to get it +# straight. +# We start with a moment (DTSTART), a FREQ, and an interval (which is +# a multiplier of freq to some extent). +# The elements of DTSTART which match or a longer than the FREQ determine +# the first instance. The FREQ and INTERVAL determine subsequent instances. +# The elements of DSTTART with smaller granularity determine the moment +# within each instance. +# +# Each BY* can do one of two things. +# If its FREQ is the same or larger than the current FREQ, it acts as +# a filter and discards instances which don't match. If its FREQ is shorter +# than the current FREQ, then each interval is divided according to the new +# freq and the BY* acts as a filter on those. +# +# BYSETPOS is combined with the previous BY* and further filters the set of instances. +# +# So we start with a moment and generate a list of moments of the given +# frequency and interval. We also have a list of BY* objects. +# We pass a moment to a BY* and get back a (possibly empty) list of moments +# which must then be passed to the next BY*. These may be before the given +# moment so we need to check everything against DTSTART. + +import time,copy + +by={} + +month_days = [31,28,31,30,31,30,31,31,30,31,30,31] +daynames=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'] +shortdays=['SU','MO','TU','WE','TH','FR','SA'] + +def daysin(mon, yr): + if mon != 2: + return month_days[mon-1] + if yr % 4 > 0: + return 28 + if yr % 400 > 0: + return 29 + return 28 + +class moment: + def __init__(self, tm): + self.yr = tm.tm_year + self.mon = tm.tm_mon + self.day = tm.tm_mday + self.hr = tm.tm_hour + self.min = tm.tm_min + self.sec = tm.tm_sec + self.wday = (tm.tm_wday+1) % 7 + self.yday = tm.tm_yday + self.zone = None + + def norm_sec(self): + if self.sec >= 0 and self.sec < 60: + return + while self.sec >= 60: + self.sec -= 60 + self.min += 1 + while self.sec < 0: + self.sec += 60 + self.min -= 1 + self.norm_min() + + def norm_min(self): + if self.min >= 0 and self.min < 60: + return + while self.min >= 60: + self.min -= 60 + self.hr += 1 + while self.min < 0: + self.min += 60 + self.hr -= 1 + self.norm_hr() + + def norm_hr(self): + if self.hr >= 0 and self.hr < 24: + return + while self.hr >= 24: + self.hr -= 24 + self.day += 1 + self.wday += 1 + self.yday += 1 + while self.hr < 0: + self.hr += 24 + self.day -= 1 + self.wday -= 1 + self.yday -= 1 + self.norm_day() + + def norm_day(self): + while self.wday < 0: + self.wday += 7 + while self.wday > 6: + self.wday -= 7 + + if self.day >= 1 and self.day <= daysin(self.mon, self.yr): + return + while self.day < 1: + self.mon -= 1 + self.norm_mon() + self.day += daysin(self.mon, self.yr) + while self.day > daysin(self.mon, self.yr): + self.day -= daysin(self.mon, self.yr) + self.mon += 1 + self.norm_mon() + + def norm_mon(self): + while self.mon < 1: + self.mon += 12 + self.yr -= 1 + while self.mon > 12: + self.mon -= 12 + self.yr += 1 + + def set_yday(self): + yday = self.day + for m in range(1, self.mon): + yday += daysin(m, self.yr) + self.yday = yday + + def set_wday(self): + self.set_yday() + yr = self.yr - 1 + wday = yr + yr/4 - yr/400 + self.yday + 4 + self.wday = wday % 7 + + def fmt(self): + if self.hr >= 0: + return "%4d/%02d/%02d %02d:%02d:%02d %s" % (self.yr,self.mon,self.day,self.hr,self.min,self.sec, daynames[self.wday]) + else: + return "%4d/%02d/%02d %s" % (self.yr,self.mon,self.day, daynames[self.wday]) + + def tonum(self): + if self.hr < 0: + return 60 * (60 * (24 * (self.day + 31 * (self.mon + 12 * self.yr)))) + else: + return self.sec + 60 * (self.min + 60 * (self.hr + 24 * (self.day + 31 * (self.mon + 12 * self.yr)))) + + def before(self, other): + return self.tonum() < other.tonum() + +class date_seq: + def __init__(self, start, freq, interval = 1): + self.now = copy.copy(start) + self.interval = interval + self.freq = freq + + def next(self): + if self.freq == 'SECONDLY': + self.now.sec += self.interval + self.now.norm_sec() + elif self.freq == 'MINUTELY': + self.now.min += self.interval + self.now.norm_min() + elif self.freq == 'HOURLY': + self.now.hr += self.interval + self.now.norm_hr() + elif self.freq == 'DAILY': + self.now.day += self.interval + self.now.norm_day() + elif self.freq == 'WEEKLY': + self.now.day += 7 * self.interval + self.now.norm_day() + elif self.freq == 'MONTHLY': + self.now.mon += self.interval + self.now.norm_mon() + elif self.freq == 'YEARLY': + self.now.yr += self.interval + else: + return None + self.now.set_wday() + return self.now + +# Each BY* is filter which will discards moments, or generates sub-moments. +# e.g. BYMONTH will discard non-matching MONTHLY, WEEKLY etc moments, and +# will convert a YEARLY moment to some MONTHLY moments +class bysimple: + def __init__(self, list, min, max): + self.vals = [] + for v in list: + try: + val = int(v) + if val >= min and val <= max: + self.vals.append(val) + except: + pass + def expand(self, moment, xtype, wkst): + rv = [] + for v in self.vals: + mm = copy.copy(moment) + if self.set(mm, v): + rv.append(mm) + return rv + + def filter(self, moment): + return self.val(moment) in self.vals + +class bymonth(bysimple): + def __init__(self, list): + bysimple.__init__(self, list, 1, 12) + def val(self, moment): + return moment.mon + def set(self, moment, v): + if moment.day > daysin(v, moment.yr): + return False + moment.mon = v + moment.set_wday() + return True +by['BYMONTH'] = bymonth + +class bysecond(bysimple): + def __init__(self, list): + bysimple.__init__(self, list, 0, 59) + def val(self, moment): + return moment.sec + def set(self, moment, v): + moment.sec = v + return True +by['BYSECOND'] = bysecond + +class byminute(bysimple): + def __init__(self, list): + bysimple.__init__(self, list, 0, 59) + def val(self, moment): + return moment.min + def set(self, moment, v): + moment.min = v + return True +by['BYMINUTE'] = byminute + +class byhour(bysimple): + def __init__(self, list): + bysimple.__init__(self, list, 0, 59) + def val(self, moment): + return moment.hr + def set(self, moment, v): + moment.hr = v + return True +by['BYHOUR'] = byhour + +class byday: + """ SU-SA or 1TU or -1TU + Numbers only allowed if xtype is MONTHLY or YEARLY + """ + def __init__(self, list): + self.list = {} + for d in shortdays: + self.list[d] = [] + for d in list: + if len(d) < 2 or d[-2:] not in shortdays: + continue + n = d[:-2] + if n == '': + self.list[d[-2:]].append(0) + else: + self.list[d[-2:]].append(int(n)) + def filter(self, moment): + l = self.list[shortdays[moment.wday]] + if len(l) == 0: + return False + if 0 in l: + return True + + # ignore number prefixes for filtering + return False + + def expand(self, moment, xtype, wkst): + rv = [] + start = copy.copy(moment) + if xtype == 'YEARLY': + # every relevant day in this year + start.mon = 1 + start.day = 1 + days = daysin(2, start.yr)-28+365 + elif xtype == 'MONTHLY': + # every relevany day in this month + start.day = 1 + days = daysin(start.mon, start.yr) + else: + # every relevant day in this week + while start.wday != wkst: + start.day -= 1 + start.wday -= 1 + start.norm_day() + days = 7 + start.set_wday() + for d in range(days): + pwk = d/7 + 1 + mwk = (days-d)/7 + 1 + dl = self.list[shortdays[start.wday]] + if 0 in dl or pwk in dl or -mwk in dl: + rv.append(copy.copy(start)) + start.day += 1 + start.norm_day() + start.set_wday() + return rv +by['BYDAY'] = byday + +class bymonthday: + def __init__(self, list): + self.from_start = [] + self.from_end = [] + for v in list: + try: + val = int(v) + if val >= 1 and val <= 31: + self.from_start.append(val) + if -val >= 1 and -val <= 31: + self.from_end.append(val) + except: + pass + def filter(self, moment): + days = daysin(moment.mon, moment.yr) + return mm.day in self.from_start or (mm.day - days - 1) in self.from_end + + def expand(self, moment, xtype, wkst): + rv = [] + days = daysin(moment.mon, moment.yr) + for v in self.from_start: + if v <= days: + mm = copy.copy(moment) + mm.day = v + mm.set_wday() + rv.append(mm) + for v1 in self.from_end: + v = days + 1 + v1 + if v >= 1 and v not in self.from_start: + mm = copy.copy(moment) + mm.day = v + mm.set_wday() + rv.append(mm) + return rv + +by['BYMONTHDAY'] = bymonthday + +class byyearday: + def __init__(self, list): + self.from_start = [] + self.from_end = [] + for v in list: + try: + val = int(v) + if val >= 1 and val <= 366: + self.from_start.append(val) + if -val >= 1 and -val <= 366: + self.from_end.append(val) + except: + pass + def expand(self, moment, xtype, wkst): + rv = [] + days = daysin(2, moment.yr)-28+365 + for v in self.from_start: + if v <= days: + mm = copy.copy(moment) + for m in range(1,13): + d = daysin(m, moment.yr) + if v <= d: + break + v -= d + mm.day = v + mm.mon = m + mm.set_wday() + rv.append(mm) + for v1 in self.from_end: + v = days + 1 + v1 + if v >= 1 and v not in self.from_start: + mm = copy.copy(moment) + for m in range(1,13): + d = daysin(m, moment.yr) + if v <= d: + break + v -= d + mm.day = v + mm.mon = m + mm.set_wday() + rv.append(mm) + return rv + + def filter(self, moment): + days = daysin(2, moment.yr)-28+365 + return mm.yday in self.from_start or (mm.yday - days - 1) in self.from_end + +by['BYYEARDAY'] = byyearday + +class byweekno: + ''' + WEEKNO 0 isn't allowed, and negatives aren't defined in the RFC. + We allow WEEKNO=0 and -1 to be last week with at least 4 days + ''' + def __init__(self, list): + self.weeks = [] + for v in list: + try: + val = int(v) + if val >= -53 and val <= 53: + self.weeks.append(val) + except: + pass + def expand(self, moment, xtype, wkst): + rv = [] + days = daysin(2, moment.yr)-28+365 + start = copy.copy(moment) + start.mon = 1 + start.day = -2 + start.norm_day() + start.set_wday() + while start.wday != wkst: + start.day += 1 + start.norm_day() + start.set_wday() + end = copy.copy(moment) + end.mon = 12 + end.day = 28 + end.set_wday() + while end.wday != wkst: + end.day -= 1 + end.set_wday() + # 'start' starts week 1, + # 'end' starts week -1 + for wn in self.weeks: + if wn >= 0: + st = copy.copy(start) + st.day += (wn-1)*7 + st.norm_day() + st.set_wday() + else: + st = copy.copy(end) + st.day -= (-1-wn)*7 + st.norm_day() + st.set_wday() + for d in range(7): + if st.yr == moment.yr: + rv.append(copy.copy(st)) + st.day += 1 + st.norm_day() + st.set_wday() + return rv +by['BYWEEKNO'] = byweekno + +class rrule: + def __init__(self, str, tz): + self.step = 1 + self.bylist = {} + self.count = None + self.end = None + self.interval = None + self.byset = None + self.wkst = shortdays.index('MO') + for w in str.split(';'): + x = w.split('=',1) + field = x[0].upper() + if field == "FREQ": + self.set_freq(x[1]) + if field == "INTERVAL": + self.set_step(x[1]) + if field == "COUNT": + self.set_count(x[1]) + if field == "UNTIL": + self.set_end(x[1]) + if field == "BYSETPOS": + self.add_set(x[1]) + if field == "WKST": + self.wkst = shortdays.index(x[1].upper()) + if field in by: + self.add_by(field, by[field](x[1].split(','))) + + def set_freq(self, f): + if f.upper() in intervals: + self.interval = f + def set_step(self, st): + self.step = int(st) + def set_count(self, c): + self.count = int(c) + def set_end(self, e): + self.end = ical_date(e, tz) + def add_by(self, field, byx): + self.bylist[field] = byx + def add_set(self, set): + self.byset = map(int, set.split(',')) + + +def sort_and_trim(list, start = None, select = None): + list.sort(key = lambda x:x.tonum()) + n = 1 + while n < len(list): + if list[n-1].tonum() == list[n].tonum(): + del(list[n]) + else: + n += 1 + if select: + l = [] + for n in select: + if n > 0 and n <= len(list): + l.append(list[n-1]) + if n < 0 and n >= -len(list): + l.append(list[n]) + list = l + while start and list and list[0].tonum() < start.tonum(): + list.pop(0) + return list + +def sub_dates(l, togo): + nums = map(lambda x:x.tonum(), togo) + n = 0 + while n < len(l): + if l[n].tonum() in nums: + del(l[n]) + else: + n += 1 + +''' +From RFC5545 + + If multiple BYxxx rule parts are specified, then after evaluating + the specified FREQ and INTERVAL rule parts, the BYxxx rule parts + are applied to the current set of evaluated occurrences in the + following order: BYMONTH, BYWEEKNO, BYYEARDAY, BYMONTHDAY, BYDAY, + BYHOUR, BYMINUTE, BYSECOND and BYSETPOS; then COUNT and UNTIL are + evaluated. + +and + + +----------+--------+--------+-------+-------+------+-------+------+ + | |SECONDLY|MINUTELY|HOURLY |DAILY |WEEKLY|MONTHLY|YEARLY| + +----------+--------+--------+-------+-------+------+-------+------+ + |BYMONTH |Limit |Limit |Limit |Limit |Limit |Limit |Expand| + +----------+--------+--------+-------+-------+------+-------+------+ + |BYWEEKNO |N/A |N/A |N/A |N/A |N/A |N/A |Expand| + +----------+--------+--------+-------+-------+------+-------+------+ + |BYYEARDAY |Limit |Limit |Limit |N/A |N/A |N/A |Expand| + +----------+--------+--------+-------+-------+------+-------+------+ + |BYMONTHDAY|Limit |Limit |Limit |Limit |N/A |Expand |Expand| + +----------+--------+--------+-------+-------+------+-------+------+ + |BYDAY |Limit |Limit |Limit |Limit |Expand|Note 1 |Note 2| + +----------+--------+--------+-------+-------+------+-------+------+ + |BYHOUR |Limit |Limit |Limit |Expand |Expand|Expand |Expand| + +----------+--------+--------+-------+-------+------+-------+------+ + |BYMINUTE |Limit |Limit |Expand |Expand |Expand|Expand |Expand| + +----------+--------+--------+-------+-------+------+-------+------+ + |BYSECOND |Limit |Expand |Expand |Expand |Expand|Expand |Expand| + +----------+--------+--------+-------+-------+------+-------+------+ + |BYSETPOS |Limit |Limit |Limit |Limit |Limit |Limit |Limit | + +----------+--------+--------+-------+-------+------+-------+------+ + + Note 1: Limit if BYMONTHDAY is present; otherwise, special expand + for MONTHLY. + + Note 2: Limit if BYYEARDAY or BYMONTHDAY is present; otherwise, + special expand for WEEKLY if BYWEEKNO present; otherwise, + special expand for MONTHLY if BYMONTH present; otherwise, + special expand for YEARLY. +''' +byorder=['BYMONTH','BYWEEKNO','BYYEARDAY','BYMONTHDAY','BYDAY','BYHOUR','BYMINUTE','BYSECOND'] +intervals = ['SECONDLY','MINUTELY','HOURLY','DAILY','WEEKLY','MONTHLY','YEARLY'] +byaction = { + 'BYMONTH' : [ -1, -1, -1, -1, -1, -1, 1], + 'BYWEEKNO': [ 0, 0, 0, 0, 0, 0, 1], + 'BYYEARDAY':[ -1, -1, -1, 0, 0, 0, 1], + 'BYMONTHDAY':[-1, -1, -1, -1, 0, 1, 1], + 'BYDAY': [ -1, -1, -1, -1, 1, 2, 3], + 'BYHOUR': [ -1, -1, -1, 1, 1, 1, 1], + 'BYMINUTE': [ -1, -1, 1, 1, 1, 1, 1], + 'BYSECOND': [ -1, 1, 1, 1, 1, 1, 1], +} + +def make_dates(start, rr): + ret = [] + last = start + s = date_seq(start, rr.interval, rr.step) + while (rr.count != None and len(ret) < rr.count) or (rr.end != None and last.before(rr.end)): + n1 = [ copy.copy(last) ] + for bn in byorder: + if bn not in rr.bylist: + continue + b = rr.bylist[bn] + n2 = [] + act = byaction[bn][intervals.index(rr.interval)] + xtype = None + if act == 2: + if 'BYMONTHDAY' in rr.bylist: + act = -1 + else: + xtype = 'MONTHLY' + elif act == 3: + if 'BYYEARDAY' in rr.bylist or 'BYMONTHDAY' in rr.bylist: + act = -1 + elif 'BYWEEKNO' in rr.bylist: + act = -1 + elif 'BYMONTH' in rr.bylist: + xtype = 'MONTHLY' + else: + xtype = 'YEARLY' + if act < 0: + for n in n1: + if b.filter(n): + n2.append(n) + elif act > 0: + for n in n1: + n2.extend(b.expand(n, xtype, rr.wkst)) + else: + raise ValueError('%s not permitted with %s' % (bn, rr.interval)) + n1 = n2 + if n1: + n1 = sort_and_trim(n1, start, rr.byset) + if n1: + last = n1[-1] + ret.extend(n1) + last = s.next() + if rr.count: + ret = ret[:rr.count] + if rr.end: + n1 = [] + for n in ret: + if n.before(rr.end): + n1 += [n] + ret = n1 + return ret + +''' +FIXME should handle + start/stop +and + start/period +where 'period' is + P [nW][nD][T[nH][nM][nS]] +''' +def ical_date(s, tz): + m = moment(time.localtime()) + if len(s) < 8 or not s[0:8].isdigit(): + raise ValueError('Bad date/time %s' % s) + m.yr = int(s[0:4]) + m.mon = int(s[4:6]) + m.day = int(s[6:8]) + if len(s) == 8: + m.hr = -1 + return m + if (not (len(s) == 15 or (len(s) == 16 and s[15] == 'Z')) + or s[8] != 'T' or not s[9:15].isdigit()): + raise ValueError('Bad data/time %s' % s) + m.hr = int(s[9:11]) + m.min = int(s[11:13]) + m.sec = int(s[13:15]) + if m.sec == 60: + # leap second... + m.sec = 59 + if len(s) == 16: + m.zone = "UTC" + else: + m.zone = tz + m.set_wday() + return m + +def timelist(str, tz): + rv = [] + for d in str.split(','): + rv.append(ical_date(d, tz)) + return rv + +import sys +start = None +rr = None +rdate = None +xdate = None +for arg in sys.argv[1:]: + l = arg.split(':', 1) + head = l[0].split(';') + tz = None + if len(head) > 1 and head[1][:5] == "TZID=": + tz = head[1][5:] + tag = head[0].upper() + if tag == 'DTSTART': + start = ical_date(l[1], tz) + elif tag == 'RRULE': + rr = rrule(l[1], tz) + elif tag == 'RDATE': + rdate = timelist(l[1], tz) + elif tag == 'EXDATE': + xdate = timelist(l[1], tz) + else: + raise ValueError('Bad tag: %s' % tag) + +m = [] +if start and rr: + m = make_dates(start, rr) +if rdate: + m.extend(rdate) + m = sort_and_trim(m) + +if xdate: + sub_dates(m, xdate) + +for m1 in m: + print m1.fmt()