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 nentries = 0 113 114 events = xml.dom.pulldom.parse(feed) 115 116 for event, value in events: 117 118 if event == xml.dom.pulldom.START_ELEMENT: 119 tagname = value.localName 120 121 # Detect the feed type and items. 122 123 if tagname == "feed" and value.namespaceURI == ATOM_NS: 124 feed_type = "atom" 125 126 elif tagname == "rss": 127 feed_type = "rss" 128 129 # Detect items. 130 131 elif feed_type == "rss" and tagname == "item" or \ 132 feed_type == "atom" and tagname == "entry": 133 134 update = Update() 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 feed_type == "atom" and tagname == "content": 151 events.expandNode(value) 152 if update: 153 update.content = text(value) 154 update.content_type = value.getAttribute("type") 155 156 elif feed_type == "atom" and tagname == "updated" or \ 157 feed_type == "rss" and tagname == "pubDate": 158 events.expandNode(value) 159 160 if update: 161 if feed_type == "atom": 162 value = getDateTime(text(value)) 163 else: 164 value = DateTime(parsedate(text(value))) 165 update.updated = value 166 167 elif event == xml.dom.pulldom.END_ELEMENT: 168 tagname = value.localName 169 170 if feed_type == "rss" and tagname == "item" or \ 171 feed_type == "atom" and tagname == "entry": 172 173 if nentries < max_entries: 174 feed_updates.append(update) 175 176 update = None 177 nentries += 1 178 179 finally: 180 feed.close() 181 182 return (feed_type, channel_title, channel_link), feed_updates 183 184 # The macro itself. 185 186 def execute(macro, args): 187 request = macro.request 188 fmt = macro.formatter 189 _ = request.getText 190 191 feed_urls = [] 192 show_content = None 193 max_entries = None 194 195 for arg, value in parseMacroArguments(args): 196 if arg == "url": 197 feed_urls.append(value) 198 elif arg == "show": 199 show_content = value in ("true", "True", "yes") 200 elif arg == "limit": 201 try: 202 max_entries = int(value) 203 except ValueError: 204 return fmt.text(_("SharedContent: limit must be set to the maximum number of entries to be shown")) 205 206 if not feed_urls: 207 return fmt.text(_("SharedContent: a feed URL must be specified")) 208 209 show_content = show_content or False 210 max_entries = max_entries or MAX_ENTRIES 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 for update in updates: 241 242 # Emit content where appropriate. 243 # NOTE: HTML should be sanitised. 244 245 if show_content: 246 append(fmt.div(on=1, css_class="moinshare-update")) 247 if update.content and update.content_type == "html": 248 append(fmt.rawHTML(unescape(update.content))) 249 append(fmt.div(on=0)) 250 251 # Or emit title and link information for items. 252 253 elif update.title and update.link: 254 append(fmt.listitem(on=1, css_class="moinshare-update")) 255 append(fmt.url(on=1, href=update.link)) 256 append(fmt.icon('www')) 257 append(fmt.text(" " + update.title)) 258 append(fmt.url(on=0)) 259 append(fmt.listitem(on=0)) 260 261 if not show_content: 262 append(fmt.bullet_list(on=0)) 263 264 # Show the feeds. 265 266 for feed_type, channel_title, channel_link in feeds: 267 if channel_title and channel_link: 268 append(fmt.paragraph(on=1, css_class="moinshare-feed")) 269 append(fmt.url(on=1, href=channel_link)) 270 append(fmt.text(channel_title)) 271 append(fmt.url(on=0)) 272 append(fmt.text(" ")) 273 append(fmt.url(on=1, href=feed_url)) 274 append(fmt.icon('rss')) 275 append(fmt.url(on=0)) 276 append(fmt.paragraph(on=0)) 277 278 # Show errors. 279 280 for feed_url in missing: 281 append(fmt.paragraph(on=1, css_class="moinshare-missing-feed-error")) 282 append(fmt.text(_("SharedContent: updates could not be retrieved for %s") % feed_url)) 283 append(fmt.paragraph(on=0)) 284 285 for feed_url in bad_content: 286 append(fmt.paragraph(on=1, css_class="moinshare-content-type-feed-error")) 287 return fmt.text(_("SharedContent: updates for %s were not provided in Atom or RSS format") % feed_url) 288 append(fmt.paragraph(on=0)) 289 290 return ''.join(output) 291 292 # vim: tabstop=4 expandtab shiftwidth=4