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