1 # -*- coding: iso-8859-1 -*- 2 """ 3 MoinMoin - SharedContent macro, based on the FeedReader macro 4 5 @copyright: 2008, 2012, 2013 by Paul Boddie <paul@boddie.org.uk> 6 @license: GNU GPL (v2 or later), see COPYING.txt for details. 7 """ 8 9 from DateSupport import getDateTimeFromISO8601, DateTime 10 from MoinMoin.Page import Page 11 from MoinRemoteSupport import * 12 from MoinSupport import parseMacroArguments, getParsersForContentType, \ 13 formatText, formatTextForOutputType 14 from MoinShare import getUpdateSources, getUpdatesFromPage, \ 15 getUpdatesFromStore, getUpdateFromPart, \ 16 Update, get_make_parser 17 from email.utils import parsedate 18 import xml.dom.pulldom 19 20 try: 21 from cStringIO import StringIO 22 except ImportError: 23 from StringIO import StringIO 24 25 Dependencies = ["time"] 26 27 MAX_ENTRIES = 5 28 ATOM_NS = "http://www.w3.org/2005/Atom" 29 30 # Utility functions. 31 32 def text(element): 33 nodes = [] 34 for node in element.childNodes: 35 if node.nodeType == node.TEXT_NODE: 36 nodes.append(node.nodeValue) 37 return "".join(nodes) 38 39 def children(element): 40 nodes = [] 41 for node in element.childNodes: 42 nodes.append(node.toxml()) 43 return "".join(nodes) 44 45 def unescape(text): 46 return text.replace("<", "<").replace(">", ">").replace("&", "&") 47 48 def linktext(element, feed_type): 49 if feed_type == "rss": 50 return text(element) 51 else: 52 return element.getAttribute("href") 53 54 def need_content(show_content, tagname): 55 return show_content in ("content", "description") and tagname in ("content", "description") 56 57 # Error classes. 58 59 class FeedError(Exception): 60 pass 61 62 class FeedMissingError(FeedError): 63 pass 64 65 class FeedContentTypeError(FeedError): 66 pass 67 68 # Feed retrieval. 69 70 def getUpdates(request, feed_url, max_entries, show_content): 71 72 """ 73 Using the given 'request', retrieve from 'feed_url' up to the given number 74 'max_entries' of update entries. The 'show_content' parameter can indicate 75 that a "summary" is to be obtained for each update, that the "content" of 76 each update is to be obtained (falling back to a summary if no content is 77 provided), or no content (indicated by a false value) is to be obtained. 78 79 A tuple of the form ((feed_type, channel_title, channel_link), updates) is 80 returned. 81 """ 82 83 feed_updates = [] 84 85 # Obtain the resource, using a cached version if appropriate. 86 87 max_cache_age = int(getattr(request.cfg, "moin_share_max_cache_age", "300")) 88 data = getCachedResource(request, feed_url, "MoinShare", "wiki", max_cache_age) 89 if not data: 90 raise FeedMissingError 91 92 # Interpret the cached feed. 93 94 feed = StringIO(data) 95 _url, content_type, _encoding, _metadata = getCachedResourceMetadata(feed) 96 97 if content_type not in ("application/atom+xml", "application/rss+xml", "application/xml"): 98 raise FeedContentTypeError 99 100 try: 101 # Parse each node from the feed. 102 103 channel_title = channel_link = None 104 105 feed_type = None 106 update = None 107 in_source = False 108 109 events = xml.dom.pulldom.parse(feed) 110 111 for event, value in events: 112 113 if not in_source and event == xml.dom.pulldom.START_ELEMENT: 114 tagname = value.localName 115 116 # Detect the feed type and items. 117 118 if tagname == "feed" and value.namespaceURI == ATOM_NS: 119 feed_type = "atom" 120 121 elif tagname == "rss": 122 feed_type = "rss" 123 124 # Detect items. 125 126 elif feed_type == "rss" and tagname == "item" or \ 127 feed_type == "atom" and tagname == "entry": 128 129 update = Update() 130 131 # Detect source declarations. 132 133 elif feed_type == "atom" and tagname == "source": 134 in_source = True 135 136 # Handle item elements. 137 138 elif tagname == "title": 139 events.expandNode(value) 140 if update: 141 update.title = text(value) 142 else: 143 channel_title = text(value) 144 145 elif tagname == "link": 146 events.expandNode(value) 147 if update: 148 update.link = linktext(value, feed_type) 149 else: 150 channel_link = linktext(value, feed_type) 151 152 elif show_content and ( 153 feed_type == "atom" and tagname in ("content", "summary") or 154 feed_type == "rss" and tagname == "description"): 155 156 events.expandNode(value) 157 158 # Obtain content where requested or, failing that, a 159 # summary. 160 161 if update and (need_content(show_content, tagname) or tagname == "summary" and not update.content): 162 if feed_type == "atom": 163 update.content_type = value.getAttribute("type") or "text" 164 165 # Normalise the content types and extract the 166 # content. 167 168 if update.content_type in ("xhtml", "application/xhtml+xml", "application/xml"): 169 update.content = children(value) 170 update.content_type = "application/xhtml+xml" 171 elif update.content_type in ("html", "text/html"): 172 update.content = text(value) 173 update.content_type = "text/html" 174 else: 175 update.content = text(value) 176 update.content_type = "text/plain" 177 else: 178 update.content_type = "text/html" 179 update.content = text(value) 180 181 elif feed_type == "atom" and tagname == "updated" or \ 182 feed_type == "rss" and tagname == "pubDate": 183 184 events.expandNode(value) 185 186 if update: 187 if feed_type == "atom": 188 value = getDateTimeFromISO8601(text(value)) 189 else: 190 value = DateTime(parsedate(text(value))) 191 update.updated = value 192 193 elif event == xml.dom.pulldom.END_ELEMENT: 194 tagname = value.localName 195 196 if feed_type == "rss" and tagname == "item" or \ 197 feed_type == "atom" and tagname == "entry": 198 199 feed_updates.append(update) 200 201 update = None 202 203 elif feed_type == "atom" and tagname == "source": 204 in_source = False 205 206 finally: 207 feed.close() 208 209 return (feed_type, channel_title, channel_link), feed_updates 210 211 # Update formatting. 212 213 def getUpdatesForFormatting(update): 214 215 "Get a list of updates for formatting given 'update'." 216 217 updates = [] 218 219 # Handle multipart/alternative. 220 221 if update.parts: 222 for n, part in enumerate(update.parts): 223 update_part = getUpdateFromPart(update, part, n) 224 updates += getUpdatesForFormatting(update_part) 225 else: 226 updates.append(update) 227 228 return updates 229 230 def getFormattedUpdate(update, request, fmt): 231 232 """ 233 Return the formatted form of the given 'update' using the given 'request' 234 and 'fmt'. 235 """ 236 237 # NOTE: Some control over the HTML and XHTML should be exercised. 238 239 if update.content: 240 if update.content_type == "text/html" and update.message_number is not None: 241 parsers = [get_make_parser(update.page, update.message_number)] 242 else: 243 parsers = getParsersForContentType(request.cfg, update.content_type) 244 245 if parsers: 246 for parser_cls in parsers: 247 if hasattr(parser_cls, "formatForOutputType"): 248 return formatTextForOutputType(update.content, request, parser_cls, "text/html") 249 else: 250 return formatText(update.content, request, fmt, parser_cls=parser_cls) 251 break 252 else: 253 return None 254 else: 255 return None 256 257 def formatUpdate(update, request, fmt): 258 259 "Format the given 'update' using the given 'request' and 'fmt'." 260 261 result = [] 262 append = result.append 263 264 updates = getUpdatesForFormatting(update) 265 single = len(updates) == 1 266 267 # Format some navigation tabs. 268 269 if not single: 270 append(fmt.div(on=1, css_class="moinshare-alternatives")) 271 272 first = True 273 274 for update_part in updates: 275 append(fmt.url(1, "#%s" % update_part.unique_id())) 276 append(fmt.text(update_part.content_type)) 277 append(fmt.url(0)) 278 279 first = False 280 281 append(fmt.div(on=0)) 282 283 # Format the content. 284 285 first = True 286 287 for update_part in updates: 288 289 # Encapsulate each alternative if many exist. 290 291 if not single: 292 css_class = first and "moinshare-default" or "moinshare-other" 293 append(fmt.div(on=1, css_class="moinshare-alternative %s" % css_class, id=update_part.unique_id())) 294 295 # Include the content. 296 297 append(formatUpdatePart(update_part, request, fmt)) 298 299 if not single: 300 append(fmt.div(on=0)) 301 302 first = False 303 304 return "".join(result) 305 306 def formatUpdatePart(update, request, fmt): 307 308 "Format the given 'update' using the given 'request' and 'fmt'." 309 310 _ = request.getText 311 312 result = [] 313 append = result.append 314 315 # Encapsulate the content. 316 317 append(fmt.div(on=1, css_class="moinshare-content")) 318 text = getFormattedUpdate(update, request, fmt) 319 if text: 320 append(text) 321 else: 322 append(fmt.text(_("Update cannot be shown for content of type %s.") % update.content_type)) 323 append(fmt.div(on=0)) 324 325 return "".join(result) 326 327 # The macro itself. 328 329 def execute(macro, args): 330 request = macro.request 331 fmt = macro.formatter 332 _ = request.getText 333 334 source_pages = [] 335 show_content = None 336 max_entries = None 337 338 for arg, value in parseMacroArguments(args): 339 if arg == "sources": 340 source_pages.append(value) 341 elif arg == "show": 342 show_content = value.lower() 343 elif arg == "limit": 344 try: 345 max_entries = int(value) 346 except ValueError: 347 return fmt.text(_("SharedContent: limit must be set to the maximum number of entries to be shown")) 348 349 if not source_pages: 350 return fmt.text(_("SharedContent: at least one sources page must be specified")) 351 352 sources = {} 353 354 for source_page in source_pages: 355 sources.update(getUpdateSources(source_page, request)) 356 357 if not sources: 358 return fmt.text(_("SharedContent: at least one update source must be specified")) 359 360 show_content = show_content or False 361 max_entries = max_entries or MAX_ENTRIES 362 363 # Retrieve updates, classifying them as missing or bad and excluding them if 364 # appropriate. 365 366 updates = [] 367 feeds = [] 368 unspecified = [] 369 missing = [] 370 bad_content = [] 371 372 for source_name, source_parameters in sources.items(): 373 location = source_parameters.get("location") 374 if not location: 375 unspecified.append(source_name) 376 continue 377 378 try: 379 max_entries_for_feed = int(source_parameters["limit"]) 380 except (KeyError, ValueError): 381 max_entries_for_feed = None 382 383 # Retrieve updates from feeds. 384 385 if source_parameters.get("type") == "url": 386 try: 387 feed_info, feed_updates = getUpdates(request, location, max_entries_for_feed, show_content) 388 updates += feed_updates 389 feeds.append((location, feed_info)) 390 except FeedMissingError: 391 missing.append(location) 392 except FeedContentTypeError: 393 bad_content.append(location) 394 395 # Retrieve updates from pages. 396 397 elif source_parameters.get("type") == "page": 398 page = Page(request, location) 399 updates += getUpdatesFromPage(page, request) 400 401 # Build feed-equivalent information for the update source. 402 403 feeds.append(( 404 page.url(request, {"action" : "SharedUpdates", "doit" : "1"}), ( 405 "internal", _("Updates from page %s") % location, 406 page.url(request) 407 ) 408 )) 409 410 # Retrieve updates from message stores. 411 412 elif source_parameters.get("type") == "store": 413 page = Page(request, location) 414 updates += getUpdatesFromStore(page, request) 415 416 # Build feed-equivalent information for the update source. 417 418 feeds.append(( 419 page.url(request, {"action" : "SharedUpdates", "store" : "1", "doit" : "1"}), ( 420 "internal", _("Updates from message store on page %s") % location, 421 page.url(request) 422 ) 423 )) 424 425 # Prepare the output. 426 427 output = [] 428 append = output.append 429 430 # Show the updates. 431 432 if not show_content: 433 append(fmt.bullet_list(on=1)) 434 435 # NOTE: Permit configurable sorting. 436 437 updates.sort() 438 updates.reverse() 439 440 # Truncate the number of updates to the maximum number. 441 442 updates = updates[:max_entries] 443 444 for update in updates: 445 446 # Emit content where appropriate. 447 448 if show_content: 449 append(fmt.div(on=1, css_class="moinshare-update")) 450 451 append(formatUpdate(update, request, fmt)) 452 453 append(fmt.div(on=1, css_class="moinshare-date")) 454 append(fmt.text(str(update.updated))) 455 append(fmt.div(on=0)) 456 457 append(fmt.div(on=0)) 458 459 # Or emit title and link information for items. 460 461 elif update.title and update.link: 462 append(fmt.listitem(on=1, css_class="moinshare-update")) 463 append(fmt.url(on=1, href=update.link)) 464 append(fmt.icon('www')) 465 append(fmt.text(" " + update.title)) 466 append(fmt.url(on=0)) 467 append(fmt.listitem(on=0)) 468 469 if not show_content: 470 append(fmt.bullet_list(on=0)) 471 472 # Show the feeds. 473 474 for feed_url, (feed_type, channel_title, channel_link) in feeds: 475 if channel_title and channel_link: 476 append(fmt.paragraph(on=1, css_class="moinshare-feed")) 477 append(fmt.url(on=1, href=channel_link)) 478 append(fmt.text(channel_title)) 479 append(fmt.url(on=0)) 480 append(fmt.text(" ")) 481 append(fmt.url(on=1, href=feed_url)) 482 append(fmt.icon('rss')) 483 append(fmt.url(on=0)) 484 append(fmt.paragraph(on=0)) 485 486 # Show errors. 487 488 for feed_url in missing: 489 append(fmt.paragraph(on=1, css_class="moinshare-missing-feed-error")) 490 append(fmt.text(_("SharedContent: updates could not be retrieved for %s") % feed_url)) 491 append(fmt.paragraph(on=0)) 492 493 for feed_url in bad_content: 494 append(fmt.paragraph(on=1, css_class="moinshare-content-type-feed-error")) 495 return fmt.text(_("SharedContent: updates for %s were not provided in Atom or RSS format") % feed_url) 496 append(fmt.paragraph(on=0)) 497 498 return ''.join(output) 499 500 # vim: tabstop=4 expandtab shiftwidth=4