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