1 # -*- coding: iso-8859-1 -*- 2 """ 3 MoinMoin - EventAggregator object types 4 5 @copyright: 2008, 2009, 2010, 2011, 2012, 2013, 2014 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 DateSupport import DateTime 12 from GeneralSupport import to_list 13 from LocationSupport import getMapReference, getMapReferenceFromDecimal 14 from MoinSupport import * 15 import vCalendar 16 17 from codecs import getreader 18 from email.utils import parsedate 19 import re 20 21 try: 22 from cStringIO import StringIO 23 except ImportError: 24 from StringIO import StringIO 25 26 try: 27 set 28 except NameError: 29 from sets import Set as set 30 31 # Page parsing. 32 33 definition_list_regexp = re.compile(ur'(?P<wholeterm>^(?P<optcomment>#*)\s+(?P<term>.*?):: )(?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 # Event parsing from page texts. 37 38 def parseEventsInPage(text, page, fragment=None): 39 40 """ 41 Parse events in the given 'text' from the given 'page'. 42 """ 43 44 # Calendar-format pages are parsed directly by the iCalendar parser. 45 46 if page.getFormat() == "calendar": 47 return parseEventsInCalendar(text) 48 49 # Wiki-format pages are parsed region-by-region using the special markup. 50 51 elif page.getFormat() == "wiki": 52 53 # Where a page contains events, potentially in regions, identify the page 54 # regions and obtain the events within them. 55 56 events = [] 57 for format, attributes, region in getFragments(text, True): 58 if format == "calendar": 59 events += parseEventsInCalendar(region) 60 else: 61 events += parseEvents(region, page, attributes.get("fragment") or fragment) 62 return events 63 64 # Unsupported format pages return no events. 65 66 else: 67 return [] 68 69 def parseEventsInCalendar(text): 70 71 # Make a Unicode-capable StringIO. 72 73 f = getreader("utf-8")(StringIO(text.encode("utf-8"))) 74 calendar = EventCalendar("", vCalendar.parse(f), {}) 75 return calendar.getEvents() 76 77 def parseEvents(text, event_page, fragment=None): 78 79 """ 80 Parse events in the given 'text', returning a list of event objects for the 81 given 'event_page'. An optional 'fragment' can be specified to indicate a 82 specific region of the event page. 83 84 If the optional 'fragment' identifier is provided, the first heading may 85 also be used to provide an event summary/title. 86 """ 87 88 template_details = {} 89 if fragment: 90 template_details["fragment"] = fragment 91 92 details = {} 93 details.update(template_details) 94 raw_details = {} 95 96 # Obtain a heading, if requested. 97 98 if fragment: 99 for level, title, (start, end) in getHeadings(text): 100 raw_details["title"] = text[start:end] 101 details["title"] = getSimpleWikiText(title.strip()) 102 break 103 104 # Start populating events. 105 106 events = [Event(event_page, details, raw_details)] 107 108 # Get any default raw details to modify. 109 110 raw_details = events[-1].getRawDetails() 111 112 for match in definition_list_regexp.finditer(text): 113 114 # Skip commented-out items. 115 116 if match.group("optcomment"): 117 continue 118 119 # Permit case-insensitive list terms. 120 121 term = match.group("term").lower() 122 raw_desc = match.group("desc") 123 124 # Special value type handling. 125 126 # Dates. 127 128 if term in Event.date_terms: 129 desc = getDateTime(raw_desc) 130 131 # Lists (whose elements may be quoted). 132 133 elif term in Event.list_terms: 134 desc = map(getSimpleWikiText, to_list(raw_desc, ",")) 135 136 # Position details. 137 138 elif term == "geo": 139 try: 140 desc = map(getMapReference, to_list(raw_desc, None)) 141 if len(desc) != 2: 142 continue 143 except (KeyError, ValueError): 144 continue 145 146 # Labels which may well be quoted. 147 148 elif term in Event.title_terms: 149 desc = getSimpleWikiText(raw_desc.strip()) 150 151 # Plain Wiki text terms. 152 153 elif term in Event.other_terms: 154 desc = raw_desc.strip() 155 156 else: 157 desc = raw_desc 158 159 if desc is not None: 160 161 # Handle apparent duplicates by creating a new set of 162 # details. 163 164 if details.has_key(term): 165 166 # Make a new event. 167 168 details = {} 169 details.update(template_details) 170 raw_details = {} 171 events.append(Event(event_page, details, raw_details)) 172 raw_details = events[-1].getRawDetails() 173 174 details[term] = desc 175 raw_details[term] = raw_desc 176 177 return events 178 179 # Event resources providing collections of events. 180 181 class EventResource: 182 183 "A resource providing event information." 184 185 def __init__(self, url): 186 self.url = url 187 188 def getPageURL(self): 189 190 "Return the URL of this page." 191 192 return self.url 193 194 def getFormat(self): 195 196 "Get the format used by this resource." 197 198 return "plain" 199 200 def getMetadata(self): 201 202 """ 203 Return a dictionary containing items describing the page's "created" 204 time, "last-modified" time, "sequence" (or revision number) and the 205 "last-comment" made about the last edit. 206 """ 207 208 return {} 209 210 def getEvents(self): 211 212 "Return a list of events from this resource." 213 214 return [] 215 216 def linkToPage(self, request, text, query_string=None, anchor=None): 217 218 """ 219 Using 'request', return a link to this page with the given link 'text' 220 and optional 'query_string' and 'anchor'. 221 """ 222 223 return linkToResource(self.url, request, text, query_string, anchor) 224 225 # Formatting-related functions. 226 227 def formatText(self, text, fmt): 228 229 """ 230 Format the given 'text' using the specified formatter 'fmt'. 231 """ 232 233 # Assume plain text which is then formatted appropriately. 234 235 return fmt.text(text) 236 237 class EventCalendar(EventResource): 238 239 "An iCalendar resource." 240 241 def __init__(self, url, calendar, metadata): 242 EventResource.__init__(self, url) 243 self.calendar = calendar 244 self.metadata = metadata 245 self.events = None 246 247 if not self.metadata.has_key("created") and self.metadata.has_key("date"): 248 self.metadata["created"] = DateTime(parsedate(self.metadata["date"])[:7]) 249 250 if self.metadata.has_key("last-modified") and not isinstance(self.metadata["last-modified"], DateTime): 251 self.metadata["last-modified"] = DateTime(parsedate(self.metadata["last-modified"])[:7]) 252 253 def getMetadata(self): 254 255 """ 256 Return a dictionary containing items describing the page's "created" 257 time, "last-modified" time, "sequence" (or revision number) and the 258 "last-comment" made about the last edit. 259 """ 260 261 return self.metadata 262 263 def getEvents(self): 264 265 "Return a list of events from this resource." 266 267 if self.events is None: 268 self.events = [] 269 270 _calendar, _empty, calendar = self.calendar 271 272 for objtype, attrs, obj in calendar: 273 274 # Read events. 275 276 if objtype == "VEVENT": 277 details = {} 278 279 for property, attrs, value in obj: 280 281 # Convert dates. 282 283 if property in ("DTSTART", "DTEND", "CREATED", "DTSTAMP", "LAST-MODIFIED"): 284 if property in ("DTSTART", "DTEND"): 285 property = property[2:] 286 if attrs.get("VALUE") == "DATE": 287 value = getDateFromCalendar(value) 288 if value and property == "END": 289 value = value.previous_day() 290 else: 291 value = getDateTimeFromCalendar(value) 292 293 # Convert numeric data. 294 295 elif property == "SEQUENCE": 296 value = int(value) 297 298 # Convert lists. 299 300 elif property == "CATEGORIES": 301 if not isinstance(value, list): 302 value = to_list(value, ",") 303 304 # Convert positions (using decimal values). 305 306 elif property == "GEO": 307 try: 308 value = map(getMapReferenceFromDecimal, to_list(value, ";")) 309 if len(value) != 2: 310 continue 311 except (KeyError, ValueError): 312 continue 313 314 # Accept other textual data as it is. 315 316 elif property in ("LOCATION", "SUMMARY", "URL"): 317 value = value or None 318 319 # Ignore other properties. 320 321 else: 322 continue 323 324 property = property.lower() 325 details[property] = value 326 327 self.events.append(CalendarEvent(self, details)) 328 329 return self.events 330 331 class EventPage: 332 333 "An event page acting as an event resource." 334 335 def __init__(self, page): 336 self.page = page 337 self.events = None 338 self.body = None 339 self.categories = None 340 self.metadata = None 341 342 def copyPage(self, page): 343 344 "Copy the body of the given 'page'." 345 346 self.body = page.getBody() 347 348 def getPageURL(self): 349 350 "Return the URL of this page." 351 352 return getPageURL(self.page) 353 354 def getFormat(self): 355 356 "Get the format used on this page." 357 358 return getFormat(self.page) 359 360 def getMetadata(self): 361 362 """ 363 Return a dictionary containing items describing the page's "created" 364 time, "last-modified" time, "sequence" (or revision number) and the 365 "last-comment" made about the last edit. 366 """ 367 368 if self.metadata is None: 369 self.metadata = getMetadata(self.page) 370 return self.metadata 371 372 def getRevisions(self): 373 374 "Return a list of page revisions." 375 376 return self.page.getRevList() 377 378 def getPageRevision(self): 379 380 "Return the revision details dictionary for this page." 381 382 return getPageRevision(self.page) 383 384 def getPageName(self): 385 386 "Return the page name." 387 388 return self.page.page_name 389 390 def getPrettyPageName(self): 391 392 "Return a nicely formatted title/name for this page." 393 394 return getPrettyPageName(self.page) 395 396 def getBody(self): 397 398 "Get the current page body." 399 400 if self.body is None: 401 self.body = self.page.get_raw_body() 402 return self.body 403 404 def getEvents(self): 405 406 "Return a list of events from this page." 407 408 if self.events is None: 409 self.events = parseEventsInPage(self.page.data, self) 410 411 return self.events 412 413 def setEvents(self, events): 414 415 "Set the given 'events' on this page." 416 417 self.events = events 418 419 def getCategoryMembership(self): 420 421 "Get the category names from this page." 422 423 if self.categories is None: 424 body = self.getBody() 425 match = category_membership_regexp.search(body) 426 self.categories = match and [x for x in match.groups() if x] or [] 427 428 return self.categories 429 430 def setCategoryMembership(self, category_names): 431 432 """ 433 Set the category membership for the page using the specified 434 'category_names'. 435 """ 436 437 self.categories = category_names 438 439 def flushEventDetails(self): 440 441 "Flush the current event details to this page's body text." 442 443 new_body_parts = [] 444 end_of_last_match = 0 445 body = self.getBody() 446 447 events = iter(self.getEvents()) 448 449 event = events.next() 450 event_details = event.getDetails() 451 replaced_terms = set() 452 453 for match in definition_list_regexp.finditer(body): 454 455 # Permit case-insensitive list terms. 456 457 term = match.group("term").lower() 458 desc = match.group("desc") 459 460 # Check that the term has not already been substituted. If so, 461 # get the next event. 462 463 if term in replaced_terms: 464 try: 465 event = events.next() 466 467 # No more events. 468 469 except StopIteration: 470 break 471 472 event_details = event.getDetails() 473 replaced_terms = set() 474 475 # Add preceding text to the new body. 476 477 new_body_parts.append(body[end_of_last_match:match.start()]) 478 479 # Get the matching regions, adding the term to the new body. 480 481 new_body_parts.append(match.group("wholeterm")) 482 483 # Special value type handling. 484 485 if event_details.has_key(term): 486 487 # Dates. 488 489 if term in event.date_terms: 490 desc = desc.replace("YYYY-MM-DD", str(event_details[term])) 491 492 # Lists (whose elements may be quoted). 493 494 elif term in event.list_terms: 495 desc = ", ".join([getEncodedWikiText(item) for item in event_details[term]]) 496 497 # Labels which must be quoted. 498 499 elif term in event.title_terms: 500 desc = getEncodedWikiText(event_details[term]) 501 502 # Position details. 503 504 elif term == "geo": 505 desc = " ".join(map(str, event_details[term])) 506 507 # Text which need not be quoted, but it will be Wiki text. 508 509 elif term in event.other_terms: 510 desc = event_details[term] 511 512 replaced_terms.add(term) 513 514 # Add the replaced value. 515 516 new_body_parts.append(desc) 517 518 # Remember where in the page has been processed. 519 520 end_of_last_match = match.end() 521 522 # Write the rest of the page. 523 524 new_body_parts.append(body[end_of_last_match:]) 525 526 self.body = "".join(new_body_parts) 527 528 def flushCategoryMembership(self): 529 530 "Flush the category membership to the page body." 531 532 body = self.getBody() 533 category_names = self.getCategoryMembership() 534 match = category_membership_regexp.search(body) 535 536 if match: 537 self.body = "".join([body[:match.start()], " ".join(category_names), body[match.end():]]) 538 539 def saveChanges(self): 540 541 "Save changes to the event." 542 543 self.flushEventDetails() 544 self.flushCategoryMembership() 545 self.page.saveText(self.getBody(), 0) 546 547 def linkToPage(self, request, text, query_string=None, anchor=None): 548 549 """ 550 Using 'request', return a link to this page with the given link 'text' 551 and optional 'query_string' and 'anchor'. 552 """ 553 554 return linkToPage(request, self.page, text, query_string, anchor) 555 556 # Formatting-related functions. 557 558 def getParserClass(self, format): 559 560 """ 561 Return a parser class for the given 'format', returning a plain text 562 parser if no parser can be found for the specified 'format'. 563 """ 564 565 return getParserClass(self.page.request, format) 566 567 def formatText(self, text, fmt): 568 569 """ 570 Format the given 'text' using the specified formatter 'fmt'. 571 """ 572 573 fmt.page = page = self.page 574 request = page.request 575 576 if self.getFormat() == "calendar": 577 parser_cls = RawParser 578 else: 579 parser_cls = self.getParserClass(self.getFormat()) 580 581 return formatText(text, request, fmt, parser_cls) 582 583 # Event details. 584 585 class Event(ActsAsTimespan): 586 587 "A description of an event." 588 589 title_terms = "title", "summary" 590 date_terms = "start", "end" 591 list_terms = "topics", "categories" 592 other_terms = "description", "location", "link" 593 geo_terms = "geo", 594 all_terms = title_terms + date_terms + list_terms + other_terms + geo_terms 595 596 def __init__(self, page, details, raw_details=None): 597 self.page = page 598 self.details = details 599 self.raw_details = raw_details or {} 600 601 # Permit omission of the end of the event by duplicating the start. 602 603 if self.details.has_key("start") and not self.details.get("end"): 604 end = self.details["start"] 605 606 # Make any end time refer to the day instead. 607 608 if isinstance(end, DateTime): 609 end = end.as_date() 610 611 self.details["end"] = end 612 613 def __repr__(self): 614 return "<Event %r %r>" % (self.getSummary(), self.as_limits()) 615 616 def __hash__(self): 617 618 """ 619 Return a dictionary hash, avoiding mistaken equality of events in some 620 situations (notably membership tests) by including the URL as well as 621 the summary. 622 """ 623 624 return hash(self.getSummary() + self.getEventURL()) 625 626 def getPage(self): 627 628 "Return the page describing this event." 629 630 return self.page 631 632 def setPage(self, page): 633 634 "Set the 'page' describing this event." 635 636 self.page = page 637 638 def getEventURL(self): 639 640 "Return the URL of this event." 641 642 fragment = self.details.get("fragment") 643 return self.page.getPageURL() + (fragment and "#" + fragment or "") 644 645 def linkToEvent(self, request, text, query_string=None): 646 647 """ 648 Using 'request', return a link to this event with the given link 'text' 649 and optional 'query_string'. 650 """ 651 652 return self.page.linkToPage(request, text, query_string, self.details.get("fragment")) 653 654 def getMetadata(self): 655 656 """ 657 Return a dictionary containing items describing the event's "created" 658 time, "last-modified" time, "sequence" (or revision number) and the 659 "last-comment" made about the last edit. 660 """ 661 662 # Delegate this to the page. 663 664 return self.page.getMetadata() 665 666 def getSummary(self, event_parent=None): 667 668 """ 669 Return either the given title or summary of the event according to the 670 event details, or a summary made from using the pretty version of the 671 page name. 672 673 If the optional 'event_parent' is specified, any page beneath the given 674 'event_parent' page in the page hierarchy will omit this parent information 675 if its name is used as the summary. 676 """ 677 678 event_details = self.details 679 680 if event_details.has_key("title"): 681 return event_details["title"] 682 elif event_details.has_key("summary"): 683 return event_details["summary"] 684 else: 685 # If appropriate, remove the parent details and "/" character. 686 687 title = self.page.getPageName() 688 689 if event_parent and title.startswith(event_parent): 690 title = title[len(event_parent.rstrip("/")) + 1:] 691 692 return getPrettyTitle(title) 693 694 def getDetails(self): 695 696 "Return the details for this event." 697 698 return self.details 699 700 def setDetails(self, event_details): 701 702 "Set the 'event_details' for this event." 703 704 self.details = event_details 705 706 def getRawDetails(self): 707 708 "Return the details for this event as they were written in a page." 709 710 return self.raw_details 711 712 # Timespan-related methods. 713 714 def __contains__(self, other): 715 return self == other 716 717 def __eq__(self, other): 718 if isinstance(other, Event): 719 return self.getSummary() == other.getSummary() and self.getEventURL() == other.getEventURL() and self._cmp(other) 720 else: 721 return self._cmp(other) == 0 722 723 def __ne__(self, other): 724 return not self.__eq__(other) 725 726 def __lt__(self, other): 727 return self._cmp(other) == -1 728 729 def __le__(self, other): 730 return self._cmp(other) in (-1, 0) 731 732 def __gt__(self, other): 733 return self._cmp(other) == 1 734 735 def __ge__(self, other): 736 return self._cmp(other) in (0, 1) 737 738 def _cmp(self, other): 739 740 "Compare this event to an 'other' event purely by their timespans." 741 742 if isinstance(other, Event): 743 return cmp(self.as_timespan(), other.as_timespan()) 744 else: 745 return cmp(self.as_timespan(), other) 746 747 def as_timespan(self): 748 details = self.details 749 if details.has_key("start") and details.has_key("end"): 750 return Timespan(details["start"], details["end"]) 751 else: 752 return None 753 754 def as_limits(self): 755 ts = self.as_timespan() 756 return ts and ts.as_limits() 757 758 class CalendarEvent(Event): 759 760 "An event from a remote calendar." 761 762 def getEventURL(self): 763 764 """ 765 Return the URL of this event, fixing any misinterpreted or incorrectly 766 formatted value in the event definition or returning the resource URL in 767 the absence of any URL in the event details. 768 """ 769 770 return self.details.get("url") and \ 771 self.valueToString(self.details["url"]) or \ 772 self.page.getPageURL() 773 774 def getSummary(self, event_parent=None): 775 776 """ 777 Return the event summary, fixing any misinterpreted or incorrectly 778 formatted value in the event definition. 779 """ 780 781 return self.valueToString(self.details["summary"]) 782 783 def valueToString(self, value): 784 785 "Return the given 'value' converted to a string." 786 787 if isinstance(value, list): 788 return ",".join(value) 789 elif isinstance(value, tuple): 790 return ";".join(value) 791 else: 792 return value 793 794 def linkToEvent(self, request, text, query_string=None, anchor=None): 795 796 """ 797 Using 'request', return a link to this event with the given link 'text' 798 and optional 'query_string' and 'anchor'. 799 """ 800 801 return linkToResource(self.getEventURL(), request, text, query_string, anchor) 802 803 def getMetadata(self): 804 805 """ 806 Return a dictionary containing items describing the event's "created" 807 time, "last-modified" time, "sequence" (or revision number) and the 808 "last-comment" made about the last edit. 809 """ 810 811 metadata = self.page.getMetadata() 812 813 return { 814 "created" : self.details.get("created") or self.details.get("dtstamp") or metadata["created"], 815 "last-modified" : self.details.get("last-modified") or self.details.get("dtstamp") or metadata["last-modified"], 816 "sequence" : self.details.get("sequence") or 0, 817 "last-comment" : "" 818 } 819 820 # vim: tabstop=4 expandtab shiftwidth=4