1 # -*- coding: iso-8859-1 -*- 2 """ 3 MoinMoin - EventAggregator library 4 5 @copyright: 2008, 2009 by Paul Boddie <paul@boddie.org.uk> 6 @copyright: 2000-2004 Juergen Hermann <jh@web.de>, 7 2005-2008 MoinMoin:ThomasWaldmann. 8 @license: GNU GPL (v2 or later), see COPYING.txt for details. 9 """ 10 11 from MoinMoin.Page import Page 12 from MoinMoin import search, version 13 from MoinMoin import wikiutil 14 import calendar 15 import datetime 16 import time 17 import re 18 19 __version__ = "0.4" 20 21 # Date labels. 22 23 month_labels = ["January", "February", "March", "April", "May", "June", 24 "July", "August", "September", "October", "November", "December"] 25 weekday_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 26 27 # Regular expressions where MoinMoin does not provide the required support. 28 29 category_regexp = None 30 31 # Page parsing. 32 33 definition_list_regexp = re.compile(ur'(?P<wholeterm>^(?P<optcomment>#*)\s+(?P<term>.*?)::\s)(?P<desc>.*?)$', re.UNICODE | re.MULTILINE) 34 category_membership_regexp = re.compile(ur"^\s*((Category\S+)(\s+Category\S+)*)\s*$", re.MULTILINE | re.UNICODE) 35 36 # Value parsing. 37 38 date_regexp = re.compile(ur'(?P<year>[0-9]{4})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})', re.UNICODE) 39 month_regexp = re.compile(ur'(?P<year>[0-9]{4})-(?P<month>[0-9]{2})', re.UNICODE) 40 verbatim_regexp = re.compile(ur'(?:' 41 ur'<<Verbatim\((?P<verbatim>.*?)\)>>' 42 ur'|' 43 ur'\[\[Verbatim\((?P<verbatim2>.*?)\)\]\]' 44 ur'|' 45 ur'`(?P<monospace>.*?)`' 46 ur'|' 47 ur'{{{(?P<preformatted>.*?)}}}' 48 ur')', re.UNICODE) 49 50 # Utility functions. 51 52 def isMoin15(): 53 return version.release.startswith("1.5.") 54 55 def getCategoryPattern(request): 56 global category_regexp 57 58 try: 59 return request.cfg.cache.page_category_regexact 60 except AttributeError: 61 62 # Use regular expression from MoinMoin 1.7.1 otherwise. 63 64 if category_regexp is None: 65 category_regexp = re.compile(u'^%s$' % ur'(?P<all>Category(?P<key>(?!Template)\S+))', re.UNICODE) 66 return category_regexp 67 68 # Action support functions. 69 70 def getCategories(request): 71 72 """ 73 From the AdvancedSearch macro, return a list of category page names using 74 the given 'request'. 75 """ 76 77 # This will return all pages with "Category" in the title. 78 79 cat_filter = getCategoryPattern(request).search 80 return request.rootpage.getPageList(filter=cat_filter) 81 82 def getCategoryMapping(category_pagenames, request): 83 84 """ 85 For the given 'category_pagenames' return a list of tuples of the form 86 (category name, category page name) using the given 'request'. 87 """ 88 89 cat_pattern = getCategoryPattern(request) 90 mapping = [] 91 for pagename in category_pagenames: 92 name = cat_pattern.match(pagename).group("key") 93 if name != "Category": 94 mapping.append((name, pagename)) 95 mapping.sort() 96 return mapping 97 98 def getPageRevision(page): 99 100 # From Page.edit_info... 101 102 if hasattr(page, "editlog_entry"): 103 line = page.editlog_entry() 104 else: 105 line = page._last_edited(page.request) # MoinMoin 1.5.x and 1.6.x 106 107 timestamp = line.ed_time_usecs 108 mtime = wikiutil.version2timestamp(long(timestamp)) # must be long for py 2.2.x 109 return {"timestamp" : time.gmtime(mtime), "comment" : line.comment} 110 111 def getHTTPTimeString(tmtuple): 112 return "%s, %02d %s %04d %02d:%02d:%02d GMT" % ( 113 weekday_labels[tmtuple.tm_wday], 114 tmtuple.tm_mday, 115 month_labels[tmtuple.tm_mon -1], # zero-based labels 116 tmtuple.tm_year, 117 tmtuple.tm_hour, 118 tmtuple.tm_min, 119 tmtuple.tm_sec 120 ) 121 122 # The main activity functions. 123 124 def getPages(pagename, request): 125 126 "Return the links minus category links for 'pagename' using the 'request'." 127 128 query = search.QueryParser().parse_query('category:%s' % pagename) 129 if isMoin15(): 130 results = search.searchPages(request, query) 131 results.sortByPagename() 132 else: 133 results = search.searchPages(request, query, "page_name") 134 135 cat_pattern = getCategoryPattern(request) 136 pages = [] 137 for page in results.hits: 138 if not cat_pattern.match(page.page_name): 139 pages.append(page) 140 return pages 141 142 def getSimpleWikiText(text): 143 144 """ 145 Return the plain text representation of the given 'text' which may employ 146 certain Wiki syntax features, such as those providing verbatim or monospaced 147 text. 148 """ 149 150 # NOTE: Re-implementing support for verbatim text and linking avoidance. 151 152 return "".join([s for s in verbatim_regexp.split(text) if s is not None]) 153 154 def getEncodedWikiText(text): 155 156 "Encode the given 'text' in a verbatim representation." 157 158 return "<<Verbatim(%s)>>" % text 159 160 def getFormat(page): 161 162 "Get the format used on 'page'." 163 164 if isMoin15(): 165 return "wiki" # page.pi_format 166 else: 167 return page.pi["format"] 168 169 def getEventDetails(page): 170 171 "Return a dictionary of event details from the given 'page'." 172 173 event_details = {} 174 175 if getFormat(page) == "wiki": 176 for match in definition_list_regexp.finditer(page.get_raw_body()): 177 178 # Skip commented-out items. 179 180 if match.group("optcomment"): 181 continue 182 183 # Permit case-insensitive list terms. 184 185 term = match.group("term").lower() 186 desc = match.group("desc") 187 188 # Special value type handling. 189 190 # Dates. 191 192 if term in ("start", "end"): 193 desc = getDate(desc) 194 195 # Lists (whose elements may be quoted). 196 197 elif term in ("topics", "categories"): 198 desc = [getSimpleWikiText(value.strip()) for value in desc.split(",")] 199 200 # Labels which may well be quoted. 201 202 elif term in ("title", "summary", "description"): 203 desc = getSimpleWikiText(desc) 204 205 if desc is not None: 206 event_details[term] = desc 207 208 return event_details 209 210 def setEventDetails(body, event_details): 211 212 """ 213 Set the event details in the given page 'body' using the 'event_details' 214 dictionary, returning the new body text. 215 """ 216 217 new_body_parts = [] 218 end_of_last_match = 0 219 220 for match in definition_list_regexp.finditer(body): 221 222 # Add preceding text to the new body. 223 224 new_body_parts.append(body[end_of_last_match:match.start()]) 225 end_of_last_match = match.end() 226 227 # Get the matching regions, adding the term to the new body. 228 229 new_body_parts.append(match.group("wholeterm")) 230 231 # Permit case-insensitive list terms. 232 233 term = match.group("term").lower() 234 desc = match.group("desc") 235 236 # Special value type handling. 237 238 if event_details.has_key(term): 239 240 # Dates. 241 242 if term in ("start", "end"): 243 desc = desc.replace("YYYY-MM-DD", event_details[term]) 244 245 # Lists (whose elements may be quoted). 246 247 elif term in ("topics", "categories"): 248 desc = ", ".join(getEncodedWikiText(event_details[term])) 249 250 # Labels which may well be quoted. 251 252 elif term in ("title", "summary"): 253 desc = getEncodedWikiText(event_details[term]) 254 255 # Text which need not be quoted, but it will be Wiki text. 256 257 elif term in ("description",): 258 desc = event_details[term] 259 260 new_body_parts.append(desc) 261 262 else: 263 new_body_parts.append(body[end_of_last_match:]) 264 265 return "".join(new_body_parts) 266 267 def setCategoryMembership(body, category_names): 268 269 """ 270 Set the category membership in the given page 'body' using the specified 271 'category_names' and returning the new body text. 272 """ 273 274 match = category_membership_regexp.search(body) 275 if match: 276 return "".join([body[:match.start()], " ".join(category_names), body[match.end():]]) 277 else: 278 return body 279 280 def getEventSummary(event_page, event_details): 281 282 """ 283 Return either the given title or summary of the event described by the given 284 'event_page', according to the given 'event_details', or return the pretty 285 version of the page name. 286 """ 287 288 if event_details.has_key("title"): 289 return event_details["title"] 290 elif event_details.has_key("summary"): 291 return event_details["summary"] 292 else: 293 return getPrettyPageName(event_page) 294 295 def getDate(s): 296 297 "Parse the string 's', extracting and returning a date string." 298 299 m = date_regexp.search(s) 300 if m: 301 return tuple(map(int, m.groups())) 302 else: 303 return None 304 305 def getMonth(s): 306 307 "Parse the string 's', extracting and returning a month string." 308 309 m = month_regexp.search(s) 310 if m: 311 return tuple(map(int, m.groups())) 312 else: 313 return None 314 315 def getCurrentMonth(): 316 317 "Return the current month as a (year, month) tuple." 318 319 today = datetime.date.today() 320 return (today.year, today.month) 321 322 def getCurrentYear(): 323 324 "Return the current year." 325 326 today = datetime.date.today() 327 return today.year 328 329 def monthupdate(date, n): 330 331 "Return 'date' updated by 'n' months." 332 333 if n < 0: 334 fn = prevmonth 335 else: 336 fn = nextmonth 337 338 i = 0 339 while i < abs(n): 340 date = fn(date) 341 i += 1 342 343 return date 344 345 def daterange(first, last, step=1): 346 347 """ 348 Get the range of dates starting at 'first' and ending on 'last', using the 349 specified 'step'. 350 """ 351 352 results = [] 353 354 months_only = len(first) == 2 355 start_year = first[0] 356 end_year = last[0] 357 358 for year in range(start_year, end_year + step, step): 359 if step == 1 and year < end_year: 360 end_month = 12 361 elif step == -1 and year > end_year: 362 end_month = 1 363 else: 364 end_month = last[1] 365 366 if step == 1 and year > start_year: 367 start_month = 1 368 elif step == -1 and year < start_year: 369 start_month = 12 370 else: 371 start_month = first[1] 372 373 for month in range(start_month, end_month + step, step): 374 if months_only: 375 results.append((year, month)) 376 else: 377 if step == 1 and month < end_month: 378 _wd, end_day = calendar.monthrange(year, month) 379 elif step == -1 and month > end_month: 380 end_day = 1 381 else: 382 end_day = last[2] 383 384 if step == 1 and month > start_month: 385 start_day = 1 386 elif step == -1 and month < start_month: 387 _wd, start_day = calendar.monthrange(year, month) 388 else: 389 start_day = first[2] 390 391 for day in range(start_day, end_day + step, step): 392 results.append((year, month, day)) 393 394 return results 395 396 def nextdate(date): 397 398 "Return the date following the given 'date'." 399 400 year, month, day = date 401 _wd, end_day = calendar.monthrange(year, month) 402 if day == end_day: 403 if month == 12: 404 return (year + 1, 1, 1) 405 else: 406 return (year, month + 1, 1) 407 else: 408 return (year, month, day + 1) 409 410 def prevdate(date): 411 412 "Return the date preceding the given 'date'." 413 414 year, month, day = date 415 if day == 1: 416 if month == 1: 417 return (year - 1, 12, 31) 418 else: 419 _wd, end_day = calendar.monthrange(year, month - 1) 420 return (year, month - 1, end_day) 421 else: 422 return (year, month, day - 1) 423 424 def nextmonth(date): 425 426 "Return the (year, month) tuple following 'date'." 427 428 year, month = date 429 if month == 12: 430 return (year + 1, 1) 431 else: 432 return year, month + 1 433 434 def prevmonth(date): 435 436 "Return the (year, month) tuple preceding 'date'." 437 438 year, month = date 439 if month == 1: 440 return (year - 1, 12) 441 else: 442 return year, month - 1 443 444 def span(start, end): 445 446 "Return the difference between 'start' and 'end'." 447 448 return end[0] - start[0], end[1] - start[1] 449 450 def getEvents(request, category_names, calendar_start=None, calendar_end=None): 451 452 """ 453 Using the 'request', generate a list of events found on pages belonging to 454 the specified 'category_names', using the optional 'calendar_start' and 455 'calendar_end' month tuples of the form (year, month) to indicate a window 456 of interest. 457 458 Return a list of events, a dictionary mapping months to event lists (within 459 the window of interest), a list of all events within the window of interest, 460 the earliest month of an event within the window of interest, and the latest 461 month of an event within the window of interest. 462 """ 463 464 # Re-order the window, if appropriate. 465 466 if calendar_start is not None and calendar_end is not None and calendar_start > calendar_end: 467 calendar_start, calendar_end = calendar_end, calendar_start 468 469 events = [] 470 shown_events = {} 471 all_shown_events = [] 472 processed_pages = set() 473 474 earliest = None 475 latest = None 476 477 for category_name in category_names: 478 479 # Get the pages and page names in the category. 480 481 pages_in_category = getPages(category_name, request) 482 483 # Visit each page in the category. 484 485 for page_in_category in pages_in_category: 486 pagename = page_in_category.page_name 487 488 # Only process each page once. 489 490 if pagename in processed_pages: 491 continue 492 else: 493 processed_pages.add(pagename) 494 495 # Get a real page, not a result page. 496 497 real_page_in_category = Page(request, pagename) 498 event_details = getEventDetails(real_page_in_category) 499 500 # Define the event as the page together with its details. 501 502 event = (real_page_in_category, event_details) 503 events.append(event) 504 505 # Test for the suitability of the event. 506 507 if event_details.has_key("start") and event_details.has_key("end"): 508 509 start_month = event_details["start"][:2] 510 end_month = event_details["end"][:2] 511 512 # Compare the months of the dates to the requested calendar 513 # window, if any. 514 515 if (calendar_start is None or end_month >= calendar_start) and \ 516 (calendar_end is None or start_month <= calendar_end): 517 518 all_shown_events.append(event) 519 520 if earliest is None or start_month < earliest: 521 earliest = start_month 522 if latest is None or end_month > latest: 523 latest = end_month 524 525 # Store the event in the month-specific dictionary. 526 527 first = max(start_month, calendar_start or start_month) 528 last = min(end_month, calendar_end or end_month) 529 530 for event_month in daterange(first, last): 531 if not shown_events.has_key(event_month): 532 shown_events[event_month] = [] 533 shown_events[event_month].append(event) 534 535 return events, shown_events, all_shown_events, earliest, latest 536 537 def setEventTimestamps(request, events): 538 539 """ 540 Using 'request', set timestamp details in the details dictionary of each of 541 the 'events': a list of the form (event_page, event_details). 542 543 Retutn the latest timestamp found. 544 """ 545 546 latest = None 547 548 for event_page, event_details in events: 549 550 # Get the initial revision of the page. 551 552 revisions = event_page.getRevList() 553 event_page_initial = Page(request, event_page.page_name, rev=revisions[-1]) 554 555 # Get the created and last modified times. 556 557 initial_revision = getPageRevision(event_page_initial) 558 event_details["created"] = initial_revision["timestamp"] 559 latest_revision = getPageRevision(event_page) 560 event_details["last-modified"] = latest_revision["timestamp"] 561 event_details["sequence"] = len(revisions) - 1 562 event_details["last-comment"] = latest_revision["comment"] 563 564 if latest is None or latest < event_details["last-modified"]: 565 latest = event_details["last-modified"] 566 567 return latest 568 569 def compareEvents(event1, event2): 570 571 """ 572 Compare 'event1' and 'event2' by start and end date, where both parameters 573 are of the following form: 574 575 (event_page, event_details) 576 """ 577 578 event_page1, event_details1 = event1 579 event_page2, event_details2 = event2 580 return cmp( 581 (event_details1["start"], event_details1["end"]), 582 (event_details2["start"], event_details2["end"]) 583 ) 584 585 def getOrderedEvents(events): 586 587 """ 588 Return a list with the given 'events' ordered according to their start and 589 end dates. Each list element must be of the following form: 590 591 (event_page, event_details) 592 """ 593 594 ordered_events = events[:] 595 ordered_events.sort(compareEvents) 596 return ordered_events 597 598 def getConcretePeriod(calendar_start, calendar_end, earliest, latest): 599 600 """ 601 From the requested 'calendar_start' and 'calendar_end', which may be None, 602 indicating that no restriction is imposed on the period for each of the 603 boundaries, use the 'earliest' and 'latest' event months to define a 604 specific period of interest. 605 """ 606 607 # Define the period as starting with any specified start month or the 608 # earliest event known, ending with any specified end month or the latest 609 # event known. 610 611 first = calendar_start or earliest 612 last = calendar_end or latest 613 614 # If there is no range of months to show, perhaps because there are no 615 # events in the requested period, and there was no start or end month 616 # specified, show only the month indicated by the start or end of the 617 # requested period. If all events were to be shown but none were found show 618 # the current month. 619 620 if first is None: 621 first = last or getCurrentMonth() 622 if last is None: 623 last = first or getCurrentMonth() 624 625 # Permit "expiring" periods (where the start date approaches the end date). 626 627 return min(first, last), last 628 629 def getCoverage(start, end, events): 630 631 """ 632 Within the period defined by the 'start' and 'end' dates, determine the 633 coverage of the days in the period by the given 'events', returning a set of 634 covered days, along with a list of slots, where each slot contains a tuple 635 of the form (set of covered days, events). 636 """ 637 638 all_events = [] 639 full_coverage = set() 640 641 # Get event details. 642 643 for event in events: 644 event_page, event_details = event 645 646 # Test for the event in the period. 647 648 if event_details["start"] <= end and event_details["end"] >= start: 649 650 # Find the coverage of this period for the event. 651 652 event_start = max(event_details["start"], start) 653 event_end = min(event_details["end"], end) 654 event_coverage = set(daterange(event_start, event_end)) 655 656 # Update the overall coverage. 657 658 full_coverage.update(event_coverage) 659 660 # Try and fit the event into the events list. 661 662 for i, (coverage, covered_events) in enumerate(all_events): 663 664 # Where the event does not overlap with the current 665 # element, add it alongside existing events. 666 667 if not coverage.intersection(event_coverage): 668 covered_events.append(event) 669 all_events[i] = coverage.union(event_coverage), covered_events 670 break 671 672 # Make a new element in the list if the event cannot be 673 # marked alongside existing events. 674 675 else: 676 all_events.append((event_coverage, [event])) 677 678 return full_coverage, all_events 679 680 # User interface functions. 681 682 def getParameter(request, name): 683 return request.form.get(name, [None])[0] 684 685 def getParameterMonth(arg): 686 n = None 687 688 if arg.startswith("current"): 689 date = getCurrentMonth() 690 if len(arg) > 8: 691 n = int(arg[7:]) 692 693 elif arg.startswith("yearstart"): 694 date = (getCurrentYear(), 1) 695 if len(arg) > 10: 696 n = int(arg[9:]) 697 698 elif arg.startswith("yearend"): 699 date = (getCurrentYear(), 12) 700 if len(arg) > 8: 701 n = int(arg[7:]) 702 703 else: 704 date = getMonth(arg) 705 706 if n is not None: 707 date = monthupdate(date, n) 708 709 return date 710 711 def getFormMonth(request, calendar_name, argname): 712 if calendar_name is None: 713 calendar_prefix = argname 714 else: 715 calendar_prefix = "%s-%s" % (calendar_name, argname) 716 717 arg = getParameter(request, calendar_prefix) 718 if arg is not None: 719 return getParameterMonth(arg) 720 else: 721 return None 722 723 def getFormMonthPair(request, yeararg, montharg): 724 year = getParameter(request, yeararg) 725 month = getParameter(request, montharg) 726 if year and month: 727 return (int(year), int(month)) 728 else: 729 return None 730 731 def getPrettyPageName(page): 732 733 "Return a nicely formatted title/name for the given 'page'." 734 735 if isMoin15(): 736 title = page.split_title(page.request, force=1) 737 else: 738 title = page.split_title(force=1) 739 740 return title.replace("_", " ").replace("/", u" ? ") 741 742 def getMonthLabel(month): 743 744 "Return an unlocalised label for the given 'month'." 745 746 return month_labels[month - 1] # zero-based labels 747 748 def getDayLabel(weekday): 749 750 "Return an unlocalised label for the given 'weekday'." 751 752 return weekday_labels[weekday] 753 754 def linkToPage(request, page, text, query_string=None): 755 756 """ 757 Using 'request', return a link to 'page' with the given link 'text' and 758 optional 'query_string'. 759 """ 760 761 text = wikiutil.escape(text) 762 763 if isMoin15(): 764 url = wikiutil.quoteWikinameURL(page.page_name) 765 if query_string is not None: 766 url = "%s?%s" % (url, query_string) 767 return wikiutil.link_tag(request, url, text, getattr(page, "formatter", None)) 768 else: 769 return page.link_to_raw(request, text, query_string) 770 771 def getPageURL(request, page): 772 773 "Using 'request', return the URL of 'page'." 774 775 if isMoin15(): 776 return request.getQualifiedURL(page.url(request)) 777 else: 778 return request.getQualifiedURL(page.url(request, relative=0)) 779 780 # vim: tabstop=4 expandtab shiftwidth=4