# HG changeset patch # User Paul Boddie # Date 1268530371 -3600 # Node ID 6087dcd15153623cbf144790f3da6fccc4d04560 # Parent cd390410f5266a202ffe0bbf33c7f24478ef2b13 Added support for times and time zones to date representations so that parsed events from pages provide such information, and this can then be included in iCalendar summaries; such summaries can now include both date and date-time information. Introduced a mechanism employing pytz to discover appropriate time zones from location information where a country code is specified. Added link, location and topic support to the event creation action. Updated the date class hierarchy, introducing a Temporal class and modifying the 'until' method to accept a start point, changing related methods to provide a converted start object, typically the instance on which the method is called, so that the result contains instances of only one type. diff -r cd390410f526 -r 6087dcd15153 EventAggregatorSupport.py --- a/EventAggregatorSupport.py Sat Mar 13 16:00:03 2010 +0100 +++ b/EventAggregatorSupport.py Sun Mar 14 02:32:51 2010 +0100 @@ -21,6 +21,11 @@ except NameError: from sets import Set as set +try: + import pytz +except ImportError: + pytz = None + __version__ = "0.6" # Date labels. @@ -40,8 +45,19 @@ # Value parsing. -date_regexp = re.compile(ur'(?P[0-9]{4})-(?P[0-9]{2})-(?P[0-9]{2})', re.UNICODE) -month_regexp = re.compile(ur'(?P[0-9]{4})-(?P[0-9]{2})', re.UNICODE) +country_code_regexp = re.compile(ur'(?:^|\s)(?P[A-Z]{2})(?:$|\s)', re.UNICODE) + +month_regexp_str = ur'(?P[0-9]{4})-(?P[0-9]{2})' +date_regexp_str = ur'(?P[0-9]{4})-(?P[0-9]{2})-(?P[0-9]{2})' +time_regexp_str = ur'(?P[0-2][0-9]):(?P[0-5][0-9])(?::(?P[0-6][0-9]))?' +timezone_regexp_str = ur'(?P[A-Z]{3,}|[a-zA-Z]+/[-_a-zA-Z]+)' +datetime_regexp_str = date_regexp_str + ur'(?:\s+' + time_regexp_str + ur'(?:\s+' + timezone_regexp_str + ur')?)?' + +month_regexp = re.compile(month_regexp_str, re.UNICODE) +date_regexp = re.compile(date_regexp_str, re.UNICODE) +time_regexp = re.compile(time_regexp_str, re.UNICODE) +datetime_regexp = re.compile(datetime_regexp_str, re.UNICODE) + verbatim_regexp = re.compile(ur'(?:' ur'<.*?)\)>>' ur'|' @@ -70,6 +86,12 @@ category_regexp = re.compile(u'^%s$' % ur'(?PCategory(?P(?!Template)\S+))', re.UNICODE) return category_regexp +def int_or_none(x): + if x is None: + return x + else: + return int(x) + # Textual representations. def getHTTPTimeString(tmtuple): @@ -300,7 +322,7 @@ # Labels which may well be quoted. - elif term in ("title", "summary", "description"): + elif term in ("title", "summary", "description", "location"): desc = getSimpleWikiText(desc) if desc is not None: @@ -309,11 +331,22 @@ # details. if details.has_key(term): + + # Perform additional event configuration. + + self.events[-1].fixTimeZone() + + # Make a new event. + details = {} self.events.append(Event(self, details)) details[term] = desc + # Perform additional event configuration. + + self.events[-1].fixTimeZone() + return self.events def setEvents(self, events): @@ -398,16 +431,16 @@ # Lists (whose elements may be quoted). elif term in ("topics", "categories"): - desc = ", ".join(getEncodedWikiText(event_details[term])) + desc = ", ".join([getEncodedWikiText(item) for item in event_details[term]]) - # Labels which may well be quoted. + # Labels which must be quoted. elif term in ("title", "summary"): desc = getEncodedWikiText(event_details[term]) # Text which need not be quoted, but it will be Wiki text. - elif term in ("description", "link"): + elif term in ("description", "link", "location"): desc = event_details[term] replaced_terms.add(term) @@ -462,6 +495,16 @@ self.page = page self.details = details + def fixTimeZone(self): + + # Combine location and time zone information. + + location = self.details.get("location") + + if location: + self.details["start"].apply_location(location) + self.details["end"].apply_location(location) + def __cmp__(self, other): """ @@ -756,28 +799,41 @@ def months(self): return self.data[0] * 12 + self.data[1] -class Month: +class Temporal: - "A simple year-month representation." + "A simple temporal representation, common to dates and times." def __init__(self, data): - self.data = tuple(data) + self.data = list(data) def __repr__(self): return "%s(%r)" % (self.__class__.__name__, self.data) - def __str__(self): - return "%04d-%02d" % self.as_tuple()[:2] - def __hash__(self): return hash(self.as_tuple()) def as_tuple(self): - return self.data + return tuple(self.data) + + def __cmp__(self, other): + data = self.as_tuple() + other_data = other.as_tuple() + length = min(len(data), len(other_data)) + return cmp(self.data[:length], other.data[:length]) + +class Month(Temporal): + + "A simple year-month representation." + + def __str__(self): + return "%04d-%02d" % self.as_tuple()[:2] def as_date(self, day): return Date(self.as_tuple() + (day,)) + def as_month(self): + return self + def year(self): return self.data[0] @@ -791,14 +847,14 @@ days, as a tuple. """ - year, month = self.data + year, month = self.as_tuple()[:2] return calendar.monthrange(year, month) def month_update(self, n=1): "Return the month updated by 'n' months." - year, month = self.data + year, month = self.as_tuple()[:2] return Month((year + (month - 1 + n) / 12, (month - 1 + n) % 12 + 1)) def next_month(self): @@ -822,24 +878,31 @@ return Period([(x - y) for x, y in zip(self.data, start.data)]) - def __cmp__(self, other): - return cmp(self.data, other.data) + def until(self, start, end, nextfn, prevfn): + + """ + Return a collection of units of time by starting from the given 'start' + and stepping across intervening units until 'end' is reached, using the + given 'nextfn' and 'prevfn' to step from one unit to the next. + """ - def until(self, end, nextfn, prevfn): - month = self - months = [month] - if month < end: - while month < end: - month = nextfn(month) - months.append(month) - elif month > end: - while month > end: - month = prevfn(month) - months.append(month) - return months + current = start + units = [current] + if current < end: + while current < end: + current = nextfn(current) + units.append(current) + elif current > end: + while current > end: + current = prevfn(current) + units.append(current) + return units def months_until(self, end): - return self.until(end, Month.next_month, Month.previous_month) + + "Return the collection of months from this month until 'end'." + + return self.until(self.as_month(), end.as_month(), Month.next_month, Month.previous_month) class Date(Month): @@ -848,6 +911,9 @@ def __str__(self): return "%04d-%02d-%02d" % self.as_tuple()[:3] + def as_date(self): + return self + def as_month(self): return Month(self.data[:2]) @@ -858,7 +924,7 @@ "Return the date following this one." - year, month, day = self.data + year, month, day = self.as_tuple()[:3] _wd, end_day = calendar.monthrange(year, month) if day == end_day: if month == 12: @@ -872,7 +938,7 @@ "Return the date preceding this one." - year, month, day = self.data + year, month, day = self.as_tuple()[:3] if day == 1: if month == 1: return Date((year - 1, 12, 31)) @@ -883,15 +949,114 @@ return Date((year, month, day - 1)) def days_until(self, end): - return self.until(end, Date.next_day, Date.previous_day) + + "Return the collection of days from this date until 'end'." + + return self.until(self.as_date(), end.as_date(), Date.next_day, Date.previous_day) + +class DateTime(Date): + + "A simple date plus time representation." + + def __init__(self, data): + Date.__init__(self, data) + self.utc_offset = None + + def __str__(self): + if self.has_time(): + data = self.as_tuple() + time_str = " %02d:%02d" % data[3:5] + if data[5] is not None: + time_str += ":%02d" % data[5] + if data[6] is not None: + time_str += " %s" % data[6] + else: + time_str = "" + + return Date.__str__(self) + time_str + + def as_date(self): + return Date(self.data[:3]) + + def has_time(self): + return self.data[3] is not None and self.data[4] is not None + + def seconds(self): + return self.data[5] + + def time_zone(self): + return self.data[6] + + def set_time_zone(self, value, utc_offset=None): + self.data[6] = value + self.utc_offset = utc_offset + + def padded(self): + + "Return a datetime with missing fields defined as being zero." + + data = map(lambda x: x or 0, self.data[:6]) + self.data[6:] + return DateTime(data) + + def apply_location(self, location): + + """ + Apply 'location' information, setting the time zone if none is already + set. + """ + + if not self.time_zone(): + + # Only try and set a time zone if pytz is present and able to + # suggest one. + + if pytz is not None: + + # Find a country code in the location. + + match = country_code_regexp.search(location) + + if match: + + # Attempt to discover zones for that country. + + try: + zones = pytz.country_timezones(match.group("code")) + + # Unambiguous choice of zone. + + if len(zones) == 1: + self.set_time_zone(zones[0], pytz.timezone(zones[0]).utcoffset(None)) + + # Many potential zones. + + elif len(zones) > 1: + for zone in zones: + continent, city = zone.split("/") + + # If the specific city is mentioned, choose the + # zone. + + if location.find(city) != -1: + self.set_time_zone(zone, pytz.timezone(zone).utcoffset(None)) + break + else: + self.set_time_zone(zones[0], pytz.timezone(zones[0]).utcoffset(None)) + + except KeyError: + pass def getDate(s): - "Parse the string 's', extracting and returning a date object." + "Parse the string 's', extracting and returning a datetime object." - m = date_regexp.search(s) + m = datetime_regexp.search(s) if m: - return Date(map(int, m.groups())) + groups = list(m.groups()) + + # Convert all but the zone to integer or None. + + return DateTime(map(int_or_none, groups[:-1]) + groups[-1:]) else: return None @@ -934,13 +1099,33 @@ # User interface functions. def getParameter(request, name, default=None): + + """ + Using the given 'request', return the value of the parameter with the given + 'name', returning the optional 'default' (or None) if no value was supplied + in the 'request'. + """ + return request.form.get(name, [default])[0] def getQualifiedParameter(request, calendar_name, argname, default=None): + + """ + Using the given 'request', 'calendar_name' and 'argname', retrieve the + value of the qualified parameter, returning the optional 'default' (or None) + if no value was supplied in the 'request'. + """ + argname = getQualifiedParameterName(calendar_name, argname) return getParameter(request, argname, default) def getQualifiedParameterName(calendar_name, argname): + + """ + Return the qualified parameter name using the given 'calendar_name' and + 'argname'. + """ + if calendar_name is None: return argname else: diff -r cd390410f526 -r 6087dcd15153 actions/EventAggregatorNewEvent.py --- a/actions/EventAggregatorNewEvent.py Sat Mar 13 16:00:03 2010 +0100 +++ b/actions/EventAggregatorNewEvent.py Sun Mar 14 02:32:51 2010 +0100 @@ -65,6 +65,18 @@ elif selected: category_list.append('' % category_pagename) + # Prepare the topics list. + + topics = form.get("topics", []) + + if form.get("add-topic"): + topics.append("") + else: + for i in range(0, len(topics)): + if form.get("remove-topic-%d" % i): + del topics[i] + break + # Initialise month lists. start_month_list = [] @@ -104,6 +116,13 @@ "title_default" : form.get("title", [""])[0], "description_label" : _("Event description"), "description_default" : form.get("description", [""])[0], + "location_label" : _("Event location"), + "location_default" : form.get("location", [""])[0], + "link_label" : _("Event URL"), + "link_default" : form.get("link", [""])[0], + "topics_label" : _("Topics"), + "add_topic_label" : _("Add topic"), + "remove_topic_label" : _("Remove topic"), "template_label" : _("Event template"), "template_default" : form.get("template", [""])[0] or template_default, "parent_label" : _("Parent page"), @@ -147,8 +166,45 @@ + + + + + + + + + + + + + ''' % d + # Topics. + + for i, topic in enumerate(topics): + d["topic"] = topic + d["topic_number"] = i + html += ''' + + + + + + + ''' % d + + html += ''' + + + + + + ''' % d + + # Advanced options. + if show_advanced: html += ''' @@ -245,6 +301,11 @@ category_pagenames = form.get("category", []) description = form.get("description", [None])[0] + location = form.get("location", [None])[0] + link = form.get("link", [None])[0] + topics = form.get("topics", []) + + # Validate certain fields. try: title = form["title"][0] @@ -278,7 +339,8 @@ event_details = { "start" : start_date, "end" : end_date, "title" : title, "summary" : title, - "description" : description + "description" : description, "location" : location, "link" : link, + "topics" : [topic for topic in topics if topic] } # Copy the template. diff -r cd390410f526 -r 6087dcd15153 actions/EventAggregatorSummary.py --- a/actions/EventAggregatorSummary.py Sat Mar 13 16:00:03 2010 +0100 +++ b/actions/EventAggregatorSummary.py Sun Mar 14 02:32:51 2010 +0100 @@ -246,8 +246,30 @@ request.write("DTSTAMP:%04d%02d%02dT%02d%02d%02dZ\r\n" % event_details["created"][:6]) request.write("LAST-MODIFIED:%04d%02d%02dT%02d%02d%02dZ\r\n" % event_details["last-modified"][:6]) request.write("SEQUENCE:%d\r\n" % event_details["sequence"]) - request.write("DTSTART;VALUE=DATE:%04d%02d%02d\r\n" % event_details["start"].as_tuple()) - request.write("DTEND;VALUE=DATE:%04d%02d%02d\r\n" % event_details["end"].next_day().as_tuple()) + + start = event_details["start"] + end = event_details["end"] + + if start.has_time(): + request.write("DTSTART") + zone = start.time_zone() + if zone: + request.write(";TZID=%s" % zone) + + request.write(";VALUE=DATE-TIME:%04d%02d%02dT%02d%02d%02d\r\n" % start.padded().as_tuple()[:-1]) + else: + request.write("DTSTART;VALUE=DATE:%04d%02d%02d\r\n" % start.as_date().as_tuple()) + + if end.has_time(): + request.write("DTEND") + zone = end.time_zone() + if zone: + request.write(";TZID=%s" % zone) + + request.write(";VALUE=DATE-TIME:%04d%02d%02dT%02d%02d%02d\r\n" % end.padded().as_tuple()[:-1]) + else: + request.write("DTEND;VALUE=DATE:%04d%02d%02d\r\n" % end.next_day().as_date().as_tuple()) + request.write("SUMMARY:%s\r\n" % getQuotedText(event_summary)) # Optional details.