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 getDateTime, 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): 79 80 """ 81 Using the given 'request', retrieve from 'feed_url' up to the given number 82 'max_entries' of update entries. 83 84 A tuple of the form ((feed_type, channel_title, channel_link), updates) is 85 returned. 86 """ 87 88 feed_updates = [] 89 90 # Obtain the resource, using a cached version if appropriate. 91 92 max_cache_age = int(getattr(request.cfg, "moin_share_max_cache_age", "300")) 93 data = getCachedResource(request, feed_url, "MoinShare", "wiki", max_cache_age) 94 if not data: 95 raise FeedMissingError 96 97 # Interpret the cached feed. 98 99 feed = StringIO(data) 100 _url, content_type, _encoding, _metadata = getCachedResourceMetadata(feed) 101 102 if content_type not in ("application/atom+xml", "application/rss+xml"): 103 raise FeedContentTypeError 104 105 try: 106 # Parse each node from the feed. 107 108 channel_title = channel_link = None 109 110 feed_type = None 111 update = None 112 113 events = xml.dom.pulldom.parse(feed) 114 115 for event, value in events: 116 117 if event == xml.dom.pulldom.START_ELEMENT: 118 tagname = value.localName 119 120 # Detect the feed type and items. 121 122 if tagname == "feed" and value.namespaceURI == ATOM_NS: 123 feed_type = "atom" 124 125 elif tagname == "rss": 126 feed_type = "rss" 127 128 # Detect items. 129 130 elif feed_type == "rss" and tagname == "item" or \ 131 feed_type == "atom" and tagname == "entry": 132 133 update = Update() 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 feed_type == "atom" and tagname == "content": 150 events.expandNode(value) 151 if update: 152 update.content = text(value) 153 update.content_type = value.getAttribute("type") 154 155 elif feed_type == "atom" and tagname == "updated" or \ 156 feed_type == "rss" and tagname == "pubDate": 157 events.expandNode(value) 158 159 if update: 160 if feed_type == "atom": 161 value = getDateTime(text(value)) 162 else: 163 value = DateTime(parsedate(text(value))) 164 update.updated = value 165 166 elif event == xml.dom.pulldom.END_ELEMENT: 167 tagname = value.localName 168 169 if feed_type == "rss" and tagname == "item" or \ 170 feed_type == "atom" and tagname == "entry": 171 172 feed_updates.append(update) 173 174 update = None 175 176 finally: 177 feed.close() 178 179 return (feed_type, channel_title, channel_link), feed_updates 180 181 # The macro itself. 182 183 def execute(macro, args): 184 request = macro.request 185 fmt = macro.formatter 186 _ = request.getText 187 188 feed_urls = [] 189 show_content = None 190 max_entries = None 191 192 for arg, value in parseMacroArguments(args): 193 if arg == "url": 194 feed_urls.append(value) 195 elif arg == "show": 196 show_content = value in ("true", "True", "yes") 197 elif arg == "limit": 198 try: 199 max_entries = int(value) 200 except ValueError: 201 return fmt.text(_("SharedContent: limit must be set to the maximum number of entries to be shown")) 202 203 if not feed_urls: 204 return fmt.text(_("SharedContent: a feed URL must be specified")) 205 206 show_content = show_content or False 207 max_entries = max_entries or MAX_ENTRIES 208 209 # Retrieve updates from feeds, classifying them as missing or bad and 210 # excluding them if appropriate. 211 212 updates = [] 213 feeds = [] 214 missing = [] 215 bad_content = [] 216 217 for feed_url in feed_urls: 218 try: 219 feed_info, feed_updates = getUpdates(request, feed_url, max_entries) 220 updates += feed_updates 221 feeds.append(feed_info) 222 except FeedMissingError: 223 missing.append(feed_url) 224 except FeedContentTypeError: 225 bad_content.append(feed_url) 226 227 output = [] 228 append = output.append 229 230 # Show the updates. 231 232 if not show_content: 233 append(fmt.bullet_list(on=1)) 234 235 # NOTE: Permit configurable sorting. 236 237 updates.sort() 238 updates.reverse() 239 240 # Truncate the number of updates to the maximum number. 241 242 updates = updates[:max_entries] 243 244 for update in updates: 245 246 # Emit content where appropriate. 247 # NOTE: HTML should be sanitised. 248 249 if show_content: 250 append(fmt.div(on=1, css_class="moinshare-update")) 251 if update.content and update.content_type == "html": 252 append(fmt.rawHTML(unescape(update.content))) 253 append(fmt.div(on=0)) 254 255 # Or emit title and link information for items. 256 257 elif update.title and update.link: 258 append(fmt.listitem(on=1, css_class="moinshare-update")) 259 append(fmt.url(on=1, href=update.link)) 260 append(fmt.icon('www')) 261 append(fmt.text(" " + update.title)) 262 append(fmt.url(on=0)) 263 append(fmt.listitem(on=0)) 264 265 if not show_content: 266 append(fmt.bullet_list(on=0)) 267 268 # Show the feeds. 269 270 for feed_type, channel_title, channel_link in feeds: 271 if channel_title and channel_link: 272 append(fmt.paragraph(on=1, css_class="moinshare-feed")) 273 append(fmt.url(on=1, href=channel_link)) 274 append(fmt.text(channel_title)) 275 append(fmt.url(on=0)) 276 append(fmt.text(" ")) 277 append(fmt.url(on=1, href=feed_url)) 278 append(fmt.icon('rss')) 279 append(fmt.url(on=0)) 280 append(fmt.paragraph(on=0)) 281 282 # Show errors. 283 284 for feed_url in missing: 285 append(fmt.paragraph(on=1, css_class="moinshare-missing-feed-error")) 286 append(fmt.text(_("SharedContent: updates could not be retrieved for %s") % feed_url)) 287 append(fmt.paragraph(on=0)) 288 289 for feed_url in bad_content: 290 append(fmt.paragraph(on=1, css_class="moinshare-content-type-feed-error")) 291 return fmt.text(_("SharedContent: updates for %s were not provided in Atom or RSS format") % feed_url) 292 append(fmt.paragraph(on=0)) 293 294 return ''.join(output) 295 296 # vim: tabstop=4 expandtab shiftwidth=4