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