--- /dev/null
+#
+#
+# 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()