# HG changeset patch # User Nicolas Chauvat <nicolas.chauvat@logilab.fr> # Date 1280694035 -7200 # Sun Aug 01 22:20:35 2010 +0200 # Node ID e8340fe485c92437f0bec4dffbf5acff3bd69f77 # Parent 4c9a9b321087f7d978c42085e496a715c36a4f36 add microblogging diff --git a/__pkginfo__.py b/__pkginfo__.py --- a/__pkginfo__.py +++ b/__pkginfo__.py @@ -4,7 +4,7 @@ modname = 'blog' distname = "cubicweb-%s" % modname -numversion = (1, 8, 0) +numversion = (1, 9, 0) version = '.'.join(str(num) for num in numversion) license = 'LGPL' @@ -14,7 +14,6 @@ web = 'http://www.cubicweb.org/project/%s' % distname description = "blogging component for the CubicWeb framework" -short_desc = description # XXX cw < 3.8 bw compat classifiers = [ 'Environment :: Web Environment' @@ -23,7 +22,11 @@ 'Programming Language :: JavaScript', ] +__depends_cubes__ = {'datafeed': None, + } __depends__ = {'cubicweb': '>= 3.9.0'} +for key, value in __depends_cubes__.items(): + __depends__['cubicweb-'+key] = value __recommends_cubes__ = {'tag': None, 'comment': '>= 1.6.3'} __recommends__ = {} diff --git a/data/cubes.blog.css b/data/cubes.blog.css --- a/data/cubes.blog.css +++ b/data/cubes.blog.css @@ -49,25 +49,25 @@ } span.previousmonth { - float:left; + float:left; } span.nextmonth { - float:right; + float:right; } div.author_date div{ - float:right; - padding-top: 3px; - color: #999999; + float:right; + padding-top: 3px; + color: #999999; } div.author_date { - font-size:1.2em; - font-style:italic; - border-top-style: ridge; - border-top-color: #CCC; - border-top-width: thin; + font-size:1.2em; + font-style:italic; + border-top-style: ridge; + border-top-color: #CCC; + border-top-width: thin; } /*div.blogentry_title h1{ @@ -78,5 +78,23 @@ }*/ div.blogentry_title { - padding: 0px 0px 15px 0px; + padding: 0px 0px 15px 0px; +} + +div.microblog { + clear: both; } + +div.microblog span.author { + margin: 0 10px 5px 0; + float: left; +} + +div.microblog span.msgtxt { + margin: 0 0 10px 0; +} + +div.microblog span.meta { + color: #999; + display: block; +} \ No newline at end of file diff --git a/migration/1.9.0_Any.py b/migration/1.9.0_Any.py new file mode 100644 --- /dev/null +++ b/migration/1.9.0_Any.py @@ -0,0 +1,2 @@ +add_entity_type('MicroBlogEntry') +add_entity_type('UserAccount') diff --git a/schema.py b/schema.py --- a/schema.py +++ b/schema.py @@ -1,4 +1,4 @@ -from yams.buildobjs import EntityType, String, RichString, SubjectRelation +from yams.buildobjs import EntityType, String, RichString, SubjectRelation, RelationDefinition from cubicweb.schema import WorkflowableEntityType, ERQLExpression class Blog(EntityType): @@ -16,4 +16,29 @@ } title = String(required=True, fulltextindexed=True, maxsize=256) content = RichString(required=True, fulltextindexed=True) - entry_of = SubjectRelation('Blog', cardinality='**') + entry_of = SubjectRelation('Blog') + same_as = SubjectRelation('ExternalUri') + + +class MicroBlogEntry(EntityType): + __permissions__ = { + 'read': ('managers', 'users'), + 'add': ('managers', 'users'), + 'update': ('managers', 'owners'), + 'delete': ('managers', 'owners') + } + content = RichString(required=True, fulltextindexed=True) + entry_of = SubjectRelation('Blog') + same_as = SubjectRelation('ExternalUri') + +class UserAccount(EntityType): + name = String(required=True) # see foaf:accountName + +class has_creator(RelationDefinition): + subject = ('BlogEntry', 'MicroBlogEntry') + object = 'UserAccount' + +class has_avatar(RelationDefinition): + subject = 'UserAccount' + object = 'ExternalUri' + diff --git a/sobjects.py b/sobjects.py --- a/sobjects.py +++ b/sobjects.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- import sys +import re from datetime import datetime -from lxml import etree +from lxml.html import fromstring, tostring import feedparser import rdflib @@ -35,38 +36,102 @@ item['content'] = unicode(get_object(g, post, sioc_content)) yield item +format_map = {'application/xhtml+xml':u'text/html', + 'text/html':u'text/html', + 'text/plain':u'text/plain', + } + +def remove_content_spies(content): + root = fromstring(content) + for img in root.findall('.//img'): + if img.get('src').startswith('http://feeds.feedburner.com'): + img.drop_tree() + for anchor in root.findall('.//a'): + href = anchor.get('href') + if href and href.startswith('http://api.tweetmeme.com/share'): + anchor.drop_tree() + return unicode(tostring(root)) + def parse_blogpost_rss(url): feed = feedparser.parse(url) for entry in feed.entries: item = {} + if 'id' in entry: + item['uri'] = entry.id + else: + item['uri'] = entry.link + item['title'] = entry.title + if hasattr(entry, 'content'): + content = entry.content[0].value + mimetype = entry.content[0].type + elif hasattr(entry, 'summary_detail'): + content = entry.summary_detail.value + mimetype = entry.summary_detail.type + else: + content = u''#XXX entry.description? + mimetype = u'text/plain' + if mimetype == u'text/html': + content = remove_content_spies(content) + item['content'] = content + item['content_format'] = format_map.get(mimetype, u'text/plain') + if hasattr(entry, 'date_parsed'): + item['creation_date'] = datetime(*entry.date_parsed[:6]) + yield item + +def parse_microblogpost_rss(url): + feed = feedparser.parse(url) + for entry in feed.entries: + item = {} item['uri'] = entry.id - item['title'] = entry.title - item['content'] = entry.description + item['content'] = entry.description.split(':',1)[1][:140] item['creation_date'] = datetime(*entry.date_parsed[:6]) + item['modification_date'] = datetime(*entry.date_parsed[:6]) + item['author'] = feed.channel.link # true for twitter + screen_name = feed.channel.link.split('/')[-1] + item['avatar'] = get_twitter_avatar(screen_name) yield item +AVATAR_CACHE = {} + +def get_twitter_avatar(screen_name): + if screen_name not in AVATAR_CACHE: + from urllib2 import urlopen + import simplejson + data = urlopen('http://api.twitter.com/1/users/show.json?screen_name=%s' % screen_name).read() + user = simplejson.loads(data) + AVATAR_CACHE[screen_name] = user['profile_image_url'] + return AVATAR_CACHE[screen_name] + class BlogPostParser(DataFeedParser): __abstract__ = True + entity_type = 'BlogEntry' def process(self, url): for item in self.parse(url): - euri = self.sget_externaluri(item.pop('uri')) + author = item.pop('author', None) + avatar = item.pop('avatar', None) + euri = self.sget_entity('ExternalUri', uri=item.pop('uri')) if euri.same_as: sys.stdout.write('.') - self.update_blogpost(euri.same_as[0], item) + post = self.update_blogpost(euri.same_as[0], item) else: sys.stdout.write('+') - self.create_blogpost(item, euri) + post = self.create_blogpost(item, euri) + if author: + account = self.sget_entity('UserAccount', name=author) + self.sget_relation(post.eid, 'has_creator', account.eid) + if avatar: + auri = self.sget_entity('ExternalUri', uri=avatar) + self.sget_relation(account.eid, 'has_avatar', auri.eid) sys.stdout.flush() def create_blogpost(self, item, uri): - entity = self._cw.create_entity('BlogEntry', **item) + entity = self._cw.create_entity(self.entity_type, **item) entity.set_relations(same_as=uri) - return self.update_blogpost(entity, None) + return entity def update_blogpost(self, entity, item): - if item: - entity.set_attributes(**item) + entity.set_attributes(**item) return entity class BlogPostSiocParser(BlogPostParser): @@ -77,6 +142,11 @@ __regid__ = 'blogpost-rss' parse = staticmethod(parse_blogpost_rss) +class MicroBlogPostRSSParser(BlogPostParser): + __regid__ = 'microblogpost-rss' + entity_type = 'MicroBlogEntry' + parse = staticmethod(parse_microblogpost_rss) + if __name__ == '__main__': import sys from pprint import pprint diff --git a/views/boxes.py b/views/boxes.py --- a/views/boxes.py +++ b/views/boxes.py @@ -16,6 +16,7 @@ class BlogArchivesBox(boxes.BoxTemplate): """blog side box displaying a Blog Archive""" __regid__ = 'blog_archives_box' + __select__ = boxes.BoxTemplate.__select__ & is_instance('Blog','BlogEntry','MicroBlogEntry') title = _('boxes_blog_archives_box') order = 35 @@ -31,6 +32,7 @@ class BlogsByAuthorBox(boxes.BoxTemplate): __regid__ = 'blog_summary_box' + __select__ = boxes.BoxTemplate.__select__ & is_instance('Blog','BlogEntry','MicroBlogEntry') title = _('boxes_blog_summary_box') order = 36 @@ -50,6 +52,7 @@ class LatestBlogsBox(boxes.BoxTemplate): """display a box with latest blogs and rss""" __regid__ = 'latest_blogs_box' + __select__ = boxes.BoxTemplate.__select__ & is_instance('Blog','BlogEntry','MicroBlogEntry') title = _('latest_blogs_box') visible = True # enabled by default order = 34 diff --git a/views/primary.py b/views/primary.py --- a/views/primary.py +++ b/views/primary.py @@ -11,7 +11,7 @@ from cubicweb.utils import UStringIO from cubicweb.selectors import is_instance from cubicweb.web import uicfg, component -from cubicweb.web.views import primary, workflow +from cubicweb.web.views import primary, workflow, baseviews _pvs = uicfg.primaryview_section _pvs.tag_attribute(('Blog', 'title'), 'hidden') @@ -19,6 +19,8 @@ _pvs.tag_attribute(('BlogEntry', 'title'), 'hidden') _pvs.tag_object_of(('*', 'entry_of', 'Blog'), 'hidden') _pvs.tag_subject_of(('BlogEntry', 'entry_of', '*'), 'relations') +_pvs.tag_object_of(('*', 'has_creator', 'UserAccount'), 'relations') +_pvs.tag_attribute(('UserAccount', 'name'), 'hidden') _pvdc = uicfg.primaryview_display_ctrl _pvdc.tag_attribute(('Blog', 'description'), {'showlabel': False}) @@ -43,9 +45,6 @@ self.wview('sameetypelist', rset, showtitle=False) self.w(strio.getvalue()) - - - class SubscribeToBlogComponent(component.EntityVComponent): __regid__ = 'blogsubscribe' __select__ = component.EntityVComponent.__select__ & is_instance('Blog') @@ -96,3 +95,42 @@ def cell_call(self, row, col, view=None): pass + +def format_microblog(entity): + author = entity.has_creator[0] + if author.has_avatar: + imgurl = author.has_avatar[0].uri + ablock = u'<a href="%s"><img src="%s" /></a>' % (author.absolute_url(), + xml_escape(imgurl)) + else: + ablock = entity.has_creator[0].view('outofcontext') + words = [] + for word in entity.content.split(): + if word.startswith('http://'): + word = u'<a href="%s">%s</a>' % (word, word) + else: + word = xml_escape(word) + words.append(word) + content = u' '.join(words) + return (u'<div class="microblog">' + u'<span class="author">%s</span>' + u'<span class="msgtxt">%s</span>' + u'<span class="meta">%s</span>' + u'</div>' % (ablock, content, entity.creation_date)) + +class MicroBlogEntryPrimaryView(primary.PrimaryView): + __select__ = primary.PrimaryView.__select__ & is_instance('MicroBlogEntry') + + def cell_call(self, row, col): + self._cw.add_css('cubes.blog.css') + entity = self.cw_rset.get_entity(row, col) + self.w(format_microblog(entity)) + +class MicroBlogEntrySameETypeListView(baseviews.SameETypeListView): + __select__ = baseviews.SameETypeListView.__select__ & is_instance('MicroBlogEntry') + + def cell_call(self, row, col): + self._cw.add_css('cubes.blog.css') + entity = self.cw_rset.get_entity(row, col) + self.w(format_microblog(entity)) + diff --git a/views/secondary.py b/views/secondary.py --- a/views/secondary.py +++ b/views/secondary.py @@ -128,11 +128,20 @@ __regid__ = 'blog' __select__ = is_instance('BlogEntry') + def toolbar_components(self, context): + # copy from PrimaryView + self.w(u'<div class="%s">' % context) + for comp in self._cw.vreg['toolbar'].poss_visible_objects( + self._cw, rset=self.cw_rset, row=self.cw_row, view=self, context=context): + comp.render(w=self.w, row=self.cw_row, view=self) + self.w(u'</div>') + def cell_call(self, row, col, **kwargs): entity = self.cw_rset.get_entity(row, col) w = self.w _ = self._cw._ w(u'<div class="post">') + self.toolbar_components('ctxtoolbar') render_blogentry_title(self._cw, w, entity) w(u'<div class="entry">') body = entity.printable_value('content')