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