# HG changeset patch # User Paul Boddie # Date 1289170604 -3600 # Node ID 0dfd107465873657e12c8759e6bba8b3e4b64228 # Parent a818bec1361017fc3c12ac7621314f1b817c1c16 Introduced tentative support for handling event coverage using units of time other than months. Tidied month calendar presentation and added support for non-month start and end parameters in the macro. diff -r a818bec13610 -r 0dfd10746587 EventAggregatorSupport.py --- a/EventAggregatorSupport.py Sun Oct 31 22:08:00 2010 +0100 +++ b/EventAggregatorSupport.py Sun Nov 07 23:56:44 2010 +0100 @@ -15,6 +15,8 @@ import datetime import time import re +import bisect +import operator try: set @@ -683,24 +685,43 @@ self.details = event_details -def getEvents(request, category_names, calendar_start=None, calendar_end=None): +def getEvents(request, category_names, calendar_start=None, calendar_end=None, resolution="month"): """ Using the 'request', generate a list of events found on pages belonging to the specified 'category_names', using the optional 'calendar_start' and - 'calendar_end' month tuples of the form (year, month) to indicate a window - of interest. + 'calendar_end' values to indicate a window of interest. + + The optional 'resolution' determines the unit of time used in providing the + results: - Return a list of events, a dictionary mapping months to event lists (within - the window of interest), a list of all events within the window of interest, - the earliest month of an event within the window of interest, and the latest - month of an event within the window of interest. + * a list of events + * a dictionary mapping time units to event lists (within the window of + interest) + * a list of all events within the window of interest + * the earliest time value of an event within the window of interest + * the latest time value of an event within the window of interest. """ + # Dates need to comply with the requested resolution. + # Here, None values need to be preserved when converting. + + if resolution == "month": + convert = lambda x: x and x.as_month() + get_values = lambda x, y: x.months_until(y) + else: + convert = lambda x: x and x.as_date() + get_values = lambda x, y: x.days_until(y) + # Re-order the window, if appropriate. if calendar_start is not None and calendar_end is not None and calendar_start > calendar_end: - calendar_start, calendar_end = calendar_end, calendar_start + calendar_start, calendar_end = map(convert, (calendar_end, calendar_start)) + + # Otherwise, just convert the calendar limits. + + else: + calendar_start, calendar_end = map(convert, (calendar_start, calendar_end)) events = [] shown_events = {} @@ -745,31 +766,30 @@ if event_details.has_key("start") and event_details.has_key("end"): - start_month = event_details["start"].as_month() - end_month = event_details["end"].as_month() + start = convert(event_details["start"]) + end = convert(event_details["end"]) - # Compare the months of the dates to the requested calendar - # window, if any. + # Compare the dates to the requested calendar window, if any. - if (calendar_start is None or end_month >= calendar_start) and \ - (calendar_end is None or start_month <= calendar_end): + if (calendar_start is None or end >= calendar_start) and \ + (calendar_end is None or start <= calendar_end): all_shown_events.append(event) - if earliest is None or start_month < earliest: - earliest = start_month - if latest is None or end_month > latest: - latest = end_month + if earliest is None or start < earliest: + earliest = start + if latest is None or end > latest: + latest = end - # Store the event in the month-specific dictionary. + # Store the event in the time-specific dictionary. - first = max(start_month, calendar_start or start_month) - last = min(end_month, calendar_end or end_month) + first = max(start, calendar_start or start) + last = min(end, calendar_end or end) - for event_month in first.months_until(last): - if not shown_events.has_key(event_month): - shown_events[event_month] = [] - shown_events[event_month].append(event) + for event_time_value in get_values(first, last): + if not shown_events.has_key(event_time_value): + shown_events[event_time_value] = [] + shown_events[event_time_value].append(event) return events, shown_events, all_shown_events, earliest, latest @@ -840,30 +860,39 @@ # requested period. If all events were to be shown but none were found show # the current month. + if isinstance(first, Date): + get_current = getCurrentDate + else: + get_current = getCurrentMonth + if first is None: - first = last or getCurrentMonth() + first = last or get_current() if last is None: - last = first or getCurrentMonth() + last = first or get_current() # Permit "expiring" periods (where the start date approaches the end date). return min(first, last), last -# NOTE: Support coverage using times within days. This will involve timespan -# NOTE: objects which can be compared in such a way that set operations will be -# NOTE: able to detect overlapping periods. - -def getCoverage(start, end, events): +def getCoverage(start, end, events, resolution="date"): """ Within the period defined by the 'start' and 'end' dates, determine the - coverage of the days in the period by the given 'events', returning a set of - covered days, along with a list of slots, where each slot contains a tuple - of the form (set of covered days, events). + coverage of the days in the period by the given 'events', returning a + collection of timespans, along with a dictionary mapping locations to + collections of slots, where each slot contains a tuple of the form + (timespans, events). """ all_events = {} - full_coverage = set() + full_coverage = [] + + # Timespans need to be given converted start and end dates/times. + + if resolution == "date": + convert = lambda x: x.as_date() + else: + convert = lambda x: x # Get event details. @@ -876,21 +905,21 @@ # Find the coverage of this period for the event. - event_start = max(event_details["start"], start) - event_end = min(event_details["end"], end) - event_coverage = set(event_start.days_until(event_end)) + event_start = convert(max(event_details["start"], start)) + event_end = convert(min(event_details["end"], end)) + event_coverage = Timespan(event_start, event_end) event_location = event_details.get("location") # Update the overall coverage. - full_coverage.update(event_coverage) + updateCoverage(full_coverage, event_coverage) # Add a new events list for a new location. # Locations can be unspecified, thus None refers to all unlocalised # events. if not all_events.has_key(event_location): - all_events[event_location] = [(event_coverage, [event])] + all_events[event_location] = [([event_coverage], [event])] # Try and fit the event into an events list. @@ -902,19 +931,41 @@ # Where the event does not overlap with the current # element, add it alongside existing events. - if not coverage.intersection(event_coverage): + if not event_coverage in coverage: covered_events.append(event) - slot[i] = coverage.union(event_coverage), covered_events + updateCoverage(coverage, event_coverage) break # Make a new element in the list if the event cannot be # marked alongside existing events. else: - slot.append((event_coverage, [event])) + slot.append(([event_coverage], [event])) return full_coverage, all_events +def updateCoverage(coverage, event_coverage): + bisect.insort_left(coverage, event_coverage) + +def getCoverageScale(coverage): + times = set() + for timespan in coverage: + times.add(timespan.start) + times.add(timespan.end) + times = list(times) + times.sort() + + scale = [] + first = 1 + start = None + for time in times: + if not first: + scale.add(Timespan(start, time)) + else: + first = 0 + start = time + return scale + # Date-related functions. class Period: @@ -944,10 +995,13 @@ 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]) + if not isinstance(other, Temporal): + return NotImplemented + else: + data = self.as_tuple() + other_data = other.as_tuple() + length = min(len(data), len(other_data)) + return cmp(data[:length], other_data[:length]) def until(self, start, end, nextfn, prevfn): @@ -1063,6 +1117,15 @@ def day(self): return self.data[2] + def day_update(self, n=1): + + "Return the month updated by 'n' months." + + delta = datetime.timedelta(n) + dt = datetime.date(*self.as_tuple()[:3]) + dt_new = dt + delta + return Date((dt_new.year, dt_new.month, dt_new.day)) + def next_day(self): "Return the date following this one." @@ -1316,6 +1379,66 @@ return 0 +class Timespan: + + """ + A period of time which can be compared against others to check for overlaps. + """ + + def __init__(self, start, end): + self.start = start + self.end = end + + def __repr__(self): + return "%s(%r, %r)" % (self.__class__.__name__, self.start, self.end) + + def __hash__(self): + return hash((self.start, self.end)) + + def is_before(self, a, b): + if isinstance(a, DateTime) and isinstance(b, DateTime): + return a <= b + else: + return a < b + + def is_after_or_during(self, a, b): + if isinstance(a, DateTime) and isinstance(b, DateTime): + return a > b + else: + return a >= b + + def __contains__(self, other): + if isinstance(other, Timespan): + return self.start <= other.start and self.end >= other.end + else: + return self.start <= other <= self.end + + def __cmp__(self, other): + + """ + Return whether this timespan occupies the same period of time as the + 'other'. + """ + + if isinstance(other, Timespan): + if self.is_before(self.end, other.start): + return -1 + elif self.is_before(other.end, self.start): + return 1 + else: + return 0 + + # Points in time are not considered to represent an upper bound on a + # non-inclusive timespan. + + else: + if self.is_before(self.end, other): + return -1 + elif self.start > other: + return 1 + else: + return 0 + def getCountry(s): "Find a country code in the given string 's'." @@ -1363,6 +1486,13 @@ else: return None +def getCurrentDate(): + + "Return the current date as a (year, month, day) tuple." + + today = datetime.date.today() + return Date((today.year, today.month, today.day)) + def getCurrentMonth(): "Return the current month as a (year, month) tuple." @@ -1412,6 +1542,35 @@ else: return "%s-%s" % (calendar_name, argname) +def getParameterDate(arg): + + "Interpret 'arg', recognising keywords and simple arithmetic operations." + + n = None + + if arg.startswith("current"): + date = getCurrentDate() + if len(arg) > 8: + n = int(arg[7:]) + + elif arg.startswith("yearstart"): + date = Date((getCurrentYear(), 1, 1)) + if len(arg) > 10: + n = int(arg[9:]) + + elif arg.startswith("yearend"): + date = Date((getCurrentYear(), 12, 31)) + if len(arg) > 8: + n = int(arg[7:]) + + else: + date = getDate(arg) + + if n is not None: + date = date.day_update(n) + + return date + def getParameterMonth(arg): "Interpret 'arg', recognising keywords and simple arithmetic operations." @@ -1441,6 +1600,19 @@ return date +def getFormDate(request, calendar_name, argname): + + """ + Return the date from the 'request' for the calendar with the given + 'calendar_name' using the parameter having the given 'argname'. + """ + + arg = getQualifiedParameter(request, calendar_name, argname) + if arg is not None: + return getParameterDate(arg) + else: + return None + def getFormMonth(request, calendar_name, argname): """ @@ -1469,6 +1641,19 @@ else: return None +def getFullDateLabel(request, date): + + """ + Return the full month plus year label using the given 'request' and + 'year_month'. + """ + + _ = request.getText + year, month, day = date.as_tuple()[:3] + day_label = _(getDayLabel(day)) + month_label = _(getMonthLabel(month)) + return "%s %s %s %s" % (day_label, day, month_label, year) + def getFullMonthLabel(request, year_month): """ @@ -1477,7 +1662,7 @@ """ _ = request.getText - year, month = year_month.as_tuple() + year, month = year_month.as_tuple()[:2] month_label = _(getMonthLabel(month)) return "%s %s" % (month_label, year) diff -r a818bec13610 -r 0dfd10746587 css/event-aggregator.css --- a/css/event-aggregator.css Sun Oct 31 22:08:00 2010 +0100 +++ b/css/event-aggregator.css Sun Nov 07 23:56:44 2010 +0100 @@ -254,6 +254,12 @@ border-right: 1px solid #dddddd; } +/* Day view, showing days from a calendar. */ + +.event-calendar-day { + width: 98%; +} + /* List/summary view. */ .event-listings { diff -r a818bec13610 -r 0dfd10746587 macros/EventAggregator.py --- a/macros/EventAggregator.py Sun Oct 31 22:08:00 2010 +0100 +++ b/macros/EventAggregator.py Sun Nov 07 23:56:44 2010 +0100 @@ -131,6 +131,11 @@ self.getQualifiedParameterName("mode"), mode or self.mode ) + def getFullDateLabel(self, date): + page = self.page + request = page.request + return EventAggregatorSupport.getFullDateLabel(request, date) + def getFullMonthLabel(self, year_month): page = self.page request = page.request @@ -381,7 +386,39 @@ # Calendar layout methods. - def writeDayNumbers(self, first_day, number_of_days, month, busy_dates): + def writeMonthTableHeading(self, year_month): + page = self.page + fmt = page.formatter + + output = [] + output.append(fmt.table_row(on=1)) + output.append(fmt.table_cell(on=1, attrs={"class" : "event-month-heading", "colspan" : "21"})) + + output.append(self.writeMonthHeading(year_month)) + + output.append(fmt.table_cell(on=0)) + output.append(fmt.table_row(on=0)) + + return "".join(output) + + def writeWeekdayHeadings(self): + page = self.page + request = page.request + fmt = page.formatter + _ = request.getText + + output = [] + output.append(fmt.table_row(on=1)) + + for weekday in range(0, 7): + output.append(fmt.table_cell(on=1, attrs={"class" : "event-weekday-heading", "colspan" : "3"})) + output.append(fmt.text(_(EventAggregatorSupport.getDayLabel(weekday)))) + output.append(fmt.table_cell(on=0)) + + output.append(fmt.table_row(on=0)) + return "".join(output) + + def writeDayNumbers(self, first_day, number_of_days, month, coverage): page = self.page fmt = page.formatter @@ -402,7 +439,7 @@ # Output normal days. else: - if date in busy_dates: + if date in coverage: output.append(fmt.table_cell(on=1, attrs={"class" : "event-day-heading event-day-busy", "colspan" : "3"})) else: @@ -703,6 +740,25 @@ output.append(fmt.table_row(on=0)) return "".join(output) + # Day layout methods. + + def writeDayHeading(self, date): + page = self.page + request = page.request + fmt = page.formatter + _ = request.getText + full_date_label = self.getFullDateLabel(date) + + output = [] + output.append(fmt.table_row(on=1)) + + output.append(fmt.table_cell(on=1, attrs={"class" : "event-weekday-heading"})) + output.append(fmt.text(full_date_label)) + output.append(fmt.table_cell(on=0)) + + output.append(fmt.table_row(on=0)) + return "".join(output) + # HTML-related functions. def getColour(s): @@ -731,11 +787,16 @@ optional named arguments of the following forms: start=YYYY-MM shows event details starting from the specified month + start=YYYY-MM-DD shows event details starting from the specified day start=current-N shows event details relative to the current month + (or relative to the current day in "day" mode) end=YYYY-MM shows event details ending at the specified month + end=YYYY-MM-DD shows event details ending on the specified day end=current+N shows event details relative to the current month + (or relative to the current day in "day" mode) mode=calendar shows a calendar view of events + mode=day shows a calendar day view of events mode=list shows a list of events by month mode=table shows a table of events @@ -782,11 +843,9 @@ for arg in parsed_args: if arg.startswith("start="): raw_calendar_start = arg[6:] - calendar_start = EventAggregatorSupport.getParameterMonth(raw_calendar_start) elif arg.startswith("end="): raw_calendar_end = arg[4:] - calendar_end = EventAggregatorSupport.getParameterMonth(raw_calendar_end) elif arg.startswith("mode="): mode = arg[5:] @@ -808,16 +867,29 @@ # Find request parameters to override settings. - if calendar_name is not None: - calendar_start = EventAggregatorSupport.getFormMonth(request, calendar_name, "start") or calendar_start - calendar_end = EventAggregatorSupport.getFormMonth(request, calendar_name, "end") or calendar_end - mode = EventAggregatorSupport.getQualifiedParameter(request, calendar_name, "mode", mode or "calendar") - # Get the events. + if mode == "day": + get_date = EventAggregatorSupport.getParameterDate + get_form_date = EventAggregatorSupport.getFormDate + else: + get_date = EventAggregatorSupport.getParameterMonth + get_form_date = EventAggregatorSupport.getFormMonth + + # Determine the limits of the calendar. + + calendar_start = get_date(raw_calendar_start) + calendar_end = get_date(raw_calendar_end) + + if calendar_name is not None: + calendar_start = get_form_date(request, calendar_name, "start") or calendar_start + calendar_end = get_form_date(request, calendar_name, "end") or calendar_end + + # Get the events according to the resolution of the calendar. events, shown_events, all_shown_events, earliest, latest = \ - EventAggregatorSupport.getEvents(request, category_names, calendar_start, calendar_end) + EventAggregatorSupport.getEvents(request, category_names, calendar_start, calendar_end, + mode == "day" and "date" or "month") # Get a concrete period of time. @@ -919,7 +991,7 @@ output.append(fmt.table(on=0)) - # Output a list or calendar. + # Output a list or month calendar. elif mode in ("list", "calendar"): @@ -942,27 +1014,14 @@ output.append(fmt.table(on=1, attrs={"tableclass" : "event-month"})) - output.append(fmt.table_row(on=1)) - output.append(fmt.table_cell(on=1, attrs={"class" : "event-month-heading", "colspan" : "21"})) - # Either write a month heading or produce links for navigable # calendars. - output.append(view.writeMonthHeading(month)) - - output.append(fmt.table_cell(on=0)) - output.append(fmt.table_row(on=0)) + output.append(view.writeMonthTableHeading(month)) # Weekday headings. - output.append(fmt.table_row(on=1)) - - for weekday in range(0, 7): - output.append(fmt.table_cell(on=1, attrs={"class" : "event-weekday-heading", "colspan" : "3"})) - output.append(fmt.text(_(EventAggregatorSupport.getDayLabel(weekday)))) - output.append(fmt.table_cell(on=0)) - - output.append(fmt.table_row(on=0)) + output.append(view.writeWeekdayHeadings()) # Process the days of the month. @@ -1087,6 +1146,25 @@ if mode == "list": output.append(fmt.bullet_list(on=0)) + # Output a day view. + + elif mode == "day": + + # Visit all days in the requested range, or across known events. + + for date in first.days_until(last): + + output.append(fmt.table(on=1, attrs={"tableclass" : "event-calendar-day"})) + + full_coverage, day_slots = EventAggregatorSupport.getCoverage( + date, date, shown_events.get(date, [])) + + output.append(self.writeDayHeading(date)) + + # End of day. + + output.append(fmt.table(on=0)) + # Output view controls. output.append(fmt.div(on=1, css_class="event-controls"))