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