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