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