def object_to_json(obj, trim_nulls=True, entry_class='h-entry', default_object_type=None, synthesize_content=True): """Converts an ActivityStreams object to microformats2 JSON. Args: obj: dict, a decoded JSON ActivityStreams object trim_nulls: boolean, whether to remove elements with null or empty values entry_class: string, the mf2 class that entries should be given (e.g. 'h-cite' when parsing a reference to a foreign entry). defaults to 'h-entry' default_object_type: string, the ActivityStreams objectType to use if one is not present. defaults to None synthesize_content: whether to generate synthetic content if the object doesn't have its own, e.g. 'likes this.' or 'shared this.' Returns: dict, decoded microformats2 JSON """ if not obj: return {} obj_type = source.object_type(obj) or default_object_type # if the activity type is a post, then it's really just a conduit # for the object. for other verbs, the activity itself is the # interesting thing if obj_type == 'post': primary = obj.get('object', {}) obj_type = source.object_type(primary) or default_object_type else: primary = obj # TODO: extract snippet name = primary.get('displayName', primary.get('title')) summary = primary.get('summary') author = obj.get('author', obj.get('actor', {})) in_reply_tos = obj.get('inReplyTo', obj.get('context', {}).get('inReplyTo', [])) is_rsvp = obj_type in ('rsvp-yes', 'rsvp-no', 'rsvp-maybe') if (is_rsvp or obj_type == 'react') and obj.get('object'): objs = obj['object'] in_reply_tos.extend(objs if isinstance(objs, list) else [objs]) # TODO: more tags. most will be p-category? ret = { 'type': (['h-card'] if obj_type == 'person' else ['h-card', 'p-location'] if obj_type == 'place' else [entry_class]), 'properties': { 'uid': [obj.get('id', '')], 'name': [name], 'summary': [summary], 'url': (list(object_urls(obj) or object_urls(primary)) + obj.get('upstreamDuplicates', [])), 'photo': [ image.get('url') for image in (util.get_list(obj, 'image') or util.get_list(primary, 'image')) ], 'video': [obj.get('stream', primary.get('stream', {})).get('url')], 'published': [obj.get('published', primary.get('published', ''))], 'updated': [obj.get('updated', primary.get('updated', ''))], 'content': [{ 'value': xml.sax.saxutils.unescape(primary.get('content', '')), 'html': render_content(primary, include_location=False, synthesize_content=synthesize_content), }], 'in-reply-to': util.trim_nulls([o.get('url') for o in in_reply_tos]), 'author': [ object_to_json(author, trim_nulls=False, default_object_type='person') ], 'location': [ object_to_json(primary.get('location', {}), trim_nulls=False, default_object_type='place') ], 'latitude': primary.get('latitude'), 'longitude': primary.get('longitude'), 'comment': [ object_to_json(c, trim_nulls=False, entry_class='h-cite') for c in obj.get('replies', {}).get('items', []) ], }, 'children': [ object_to_json(c, trim_nulls=False, entry_class='h-cite') for c in primary.get('attachments', []) if c.get('objectType') in ('note', 'article') ], } # hashtags and person tags tags = obj.get('tags', []) or util.get_first(obj, 'object', {}).get( 'tags', []) ret['properties']['category'] = [] for tag in tags: if tag.get('objectType') == 'person': cls = 'u-category h-card' elif tag.get('objectType') == 'hashtag': cls = 'u-category' else: continue ret['properties']['category'].append( object_to_json(tag, entry_class=cls)) # rsvp if is_rsvp: ret['properties']['rsvp'] = [obj_type[len('rsvp-'):]] elif obj_type == 'invite': invitee = object_to_json(obj.get('object'), trim_nulls=False, default_object_type='person') ret['properties']['invitee'] = [invitee] # like and repost mentions for type, prop in ('like', 'like'), ('share', 'repost'): if obj_type == type: # The ActivityStreams spec says the object property should always be a # single object, but it's useful to let it be a list, e.g. when a like has # multiple targets, e.g. a like of a post with original post URLs in it, # which brid.gy does. objs = util.get_list(obj, 'object') ret['properties'][prop + '-of'] = [ # flatten contexts that are just a url o['url'] if 'url' in o and set(o.keys()) <= set(['url', 'objectType']) else object_to_json(o, trim_nulls=False, entry_class='h-cite') for o in objs ] else: # received likes and reposts ret['properties'][prop] = [ object_to_json(t, trim_nulls=False, entry_class='h-cite') for t in tags if source.object_type(t) == type ] if trim_nulls: ret = util.trim_nulls(ret) return ret
def render_content(obj, include_location=True, synthesize_content=True): """Renders the content of an ActivityStreams object. Includes tags, mentions, and non-note/article attachments. (Note/article attachments are converted to mf2 children in object_to_json and then rendered in json_to_html.) Args: obj: decoded JSON ActivityStreams object include_location: whether to render location, if provided synthesize_content: whether to generate synthetic content if the object doesn't have its own, e.g. 'likes this.' or 'shared this.' Returns: string, rendered HTML """ content = obj.get('content', '') # extract tags. preserve order but de-dupe, ie don't include a tag more than # once. seen_ids = set() mentions = [] tags = {} # maps string objectType to list of tag objects for t in obj.get('tags', []): id = t.get('id') if id and id in seen_ids: continue seen_ids.add(id) if 'startIndex' in t and 'length' in t: mentions.append(t) else: tags.setdefault(source.object_type(t), []).append(t) # linkify embedded mention tags inside content. if mentions: mentions.sort(key=lambda t: t['startIndex']) last_end = 0 orig = content content = '' for tag in mentions: start = tag['startIndex'] end = start + tag['length'] content += orig[last_end:start] content += '<a href="%s">%s</a>' % (tag['url'], orig[start:end]) last_end = end content += orig[last_end:] # convert newlines to <br>s # do this *after* linkifying tags so we don't have to shuffle indices over content = content.replace('\n', '<br />\n') # linkify embedded links. ignore the "mention" tags that we added ourselves. # TODO: fix the bug in test_linkify_broken() in webutil/util_test.py, then # uncomment this. # if content: # content = util.linkify(content) # attachments, e.g. links (aka articles) # TODO: use oEmbed? http://oembed.com/ , http://code.google.com/p/python-oembed/ attachments = [a for a in obj.get('attachments', []) if a.get('objectType') not in ('note', 'article')] for tag in attachments + tags.pop('article', []): name = tag.get('displayName', '') open_a_tag = False if tag.get('objectType') == 'video': video = util.get_first(tag, 'stream') or util.get_first(obj, 'stream') poster = util.get_first(tag, 'image', {}) if video and video.get('url'): content += '\n<p>%s' % vid(video['url'], poster.get('url'), 'thumbnail') else: content += '\n<p>' url = tag.get('url') or obj.get('url') if url: content += '\n<a class="link" href="%s">' % url open_a_tag = True image = util.get_first(tag, 'image') or util.get_first(obj, 'image') if image and image.get('url'): content += '\n' + img(image['url'], 'thumbnail', name) if name: content += '\n<span class="name">%s</span>' % name if open_a_tag: content += '\n</a>' summary = tag.get('summary') if summary and summary != name: content += '\n<span class="summary">%s</span>' % summary content += '\n</p>' # generate share/like contexts if the activity does not have content # of its own for as_type, verb in [('share', 'Shared'), ('like', 'Likes')]: obj_type = source.object_type(obj) if (not synthesize_content or obj_type != as_type or 'object' not in obj or 'content' in obj): continue targets = util.get_list(obj, 'object') if not targets: continue for target in targets: # sometimes likes don't have enough content to render anything # interesting if 'url' in target and set(target) <= set(['url', 'objectType']): content += '<a href="%s">%s this.</a>' % ( target.get('url'), verb.lower()) else: author = target.get('author', target.get('actor', {})) # special case for twitter RT's if obj_type == 'share' and 'url' in obj and re.search( '^https?://(?:www\.|mobile\.)?twitter\.com/', obj.get('url')): content += 'RT <a href="%s">@%s</a> ' % ( target.get('url', '#'), author.get('username')) else: # image looks bad in the simplified rendering author = {k: v for k, v in author.iteritems() if k != 'image'} content += '%s <a href="%s">%s</a> by %s' % ( verb, target.get('url', '#'), target.get('displayName', target.get('title', 'a post')), hcard_to_html(object_to_json(author, default_object_type='person')), ) content += render_content(target, include_location=include_location, synthesize_content=synthesize_content) # only include the first context in the content (if there are # others, they'll be included as separate properties) break break # location loc = obj.get('location') if include_location and loc: content += '\n' + hcard_to_html( object_to_json(loc, default_object_type='place'), parent_props=['p-location']) # these are rendered manually in json_to_html() for type in 'like', 'share', 'react', 'person': tags.pop(type, None) # render the rest content += tags_to_html(tags.pop('hashtag', []), 'p-category') content += tags_to_html(tags.pop('mention', []), 'u-mention') content += tags_to_html(sum(tags.values(), []), 'tag') return content
def render_content(obj, include_location=True, synthesize_content=True): """Renders the content of an ActivityStreams object. Includes tags, mentions, and non-note/article attachments. (Note/article attachments are converted to mf2 children in object_to_json and then rendered in json_to_html.) Args: obj: decoded JSON ActivityStreams object include_location: whether to render location, if provided synthesize_content: whether to generate synthetic content if the object doesn't have its own, e.g. 'likes this.' or 'shared this.' Returns: string, rendered HTML """ content = obj.get('content', '') # extract tags. preserve order but de-dupe, ie don't include a tag more than # once. seen_ids = set() mentions = [] tags = {} # maps string objectType to list of tag objects for t in obj.get('tags', []): id = t.get('id') if id and id in seen_ids: continue seen_ids.add(id) if 'startIndex' in t and 'length' in t: mentions.append(t) else: tags.setdefault(source.object_type(t), []).append(t) # linkify embedded mention tags inside content. if mentions: mentions.sort(key=lambda t: t['startIndex']) last_end = 0 orig = content content = '' for tag in mentions: start = tag['startIndex'] end = start + tag['length'] content += orig[last_end:start] content += '<a href="%s">%s</a>' % (tag['url'], orig[start:end]) last_end = end content += orig[last_end:] # convert newlines to <br>s # do this *after* linkifying tags so we don't have to shuffle indices over content = content.replace('\n', '<br />\n') # linkify embedded links. ignore the "mention" tags that we added ourselves. # TODO: fix the bug in test_linkify_broken() in webutil/util_test.py, then # uncomment this. # if content: # content = util.linkify(content) # attachments, e.g. links (aka articles) # TODO: use oEmbed? http://oembed.com/ , http://code.google.com/p/python-oembed/ attachments = [ a for a in obj.get('attachments', []) if a.get('objectType') not in ('note', 'article') ] for tag in attachments + tags.pop('article', []): name = tag.get('displayName', '') open_a_tag = False if tag.get('objectType') == 'video': video = util.get_first(tag, 'stream') or util.get_first( obj, 'stream') poster = util.get_first(tag, 'image', {}) if video and video.get('url'): content += '\n<p>%s' % vid(video['url'], poster.get('url'), 'thumbnail') else: content += '\n<p>' url = tag.get('url') or obj.get('url') if url: content += '\n<a class="link" href="%s">' % url open_a_tag = True image = util.get_first(tag, 'image') or util.get_first( obj, 'image') if image and image.get('url'): content += '\n' + img(image['url'], 'thumbnail', name) if name: content += '\n<span class="name">%s</span>' % name if open_a_tag: content += '\n</a>' summary = tag.get('summary') if summary and summary != name: content += '\n<span class="summary">%s</span>' % summary content += '\n</p>' # generate share/like contexts if the activity does not have content # of its own for as_type, verb in [('share', 'Shared'), ('like', 'Likes')]: obj_type = source.object_type(obj) if (not synthesize_content or obj_type != as_type or 'object' not in obj or 'content' in obj): continue targets = util.get_list(obj, 'object') if not targets: continue for target in targets: # sometimes likes don't have enough content to render anything # interesting if 'url' in target and set(target) <= set(['url', 'objectType']): content += '<a href="%s">%s this.</a>' % (target.get('url'), verb.lower()) else: author = target.get('author', target.get('actor', {})) # special case for twitter RT's if obj_type == 'share' and 'url' in obj and re.search( '^https?://(?:www\.|mobile\.)?twitter\.com/', obj.get('url')): content += 'RT <a href="%s">@%s</a> ' % (target.get( 'url', '#'), author.get('username')) else: # image looks bad in the simplified rendering author = { k: v for k, v in author.iteritems() if k != 'image' } content += '%s <a href="%s">%s</a> by %s' % ( verb, target.get('url', '#'), target.get('displayName', target.get( 'title', 'a post')), hcard_to_html( object_to_json(author, default_object_type='person')), ) content += render_content( target, include_location=include_location, synthesize_content=synthesize_content) # only include the first context in the content (if there are # others, they'll be included as separate properties) break break # location loc = obj.get('location') if include_location and loc: content += '\n' + hcard_to_html(object_to_json( loc, default_object_type='place'), parent_props=['p-location']) # these are rendered manually in json_to_html() for type in 'like', 'share', 'react', 'person': tags.pop(type, None) # render the rest content += tags_to_html(tags.pop('hashtag', []), 'p-category') content += tags_to_html(tags.pop('mention', []), 'u-mention') content += tags_to_html(sum(tags.values(), []), 'tag') return content
def activities_to_atom(activities, actor, title=None, request_url=None, host_url=None, xml_base=None, rels=None, reader=True): """Converts ActivityStreams activites to an Atom feed. Args: activities: list of ActivityStreams activity dicts actor: ActivityStreams actor dict, the author of the feed title: string, the feed <title> element. Defaults to 'User feed for [NAME]' request_url: the URL of this Atom feed, if any. Used in a link rel="self". host_url: the home URL for this Atom feed, if any. Used in the top-level feed <id> element. xml_base: the base URL, if any. Used in the top-level xml:base attribute. rels: rel links to include. dict mapping string rel value to string URL. reader: boolean, whether the output will be rendered in a feed reader. Currently just includes location if True, not otherwise. Returns: unicode string with Atom XML """ # Strip query params from URLs so that we don't include access tokens, etc host_url = (_remove_query_params(host_url) if host_url else 'https://github.com/snarfed/granary') if request_url is None: request_url = host_url for a in activities: act_type = source.object_type(a) if not act_type or act_type == 'post': primary = a.get('object', {}) else: primary = a obj = a.setdefault('object', {}) # Render content as HTML; escape &s obj['rendered_content'] = _encode_ampersands(microformats2.render_content( primary, include_location=reader)) # Make sure every activity has the title field, since Atom <entry> requires # the title element. if not a.get('title'): a['title'] = util.ellipsize(_encode_ampersands( a.get('displayName') or a.get('content') or obj.get('title') or obj.get('displayName') or obj.get('content') or 'Untitled')) # strip HTML tags. the Atom spec says title is plain text: # http://atomenabled.org/developers/syndication/#requiredEntryElements a['title'] = xml.sax.saxutils.escape(BeautifulSoup(a['title']).get_text('')) # Normalize attachments.image to always be a list. attachments = a.get('attachments') or obj.get('attachments') or [] for att in attachments: att['image'] = util.get_list(att, 'image') obj['rendered_children'] = [] for att in attachments: if att.get('objectType') in ('note', 'article'): html = microformats2.render_content(att, include_location=reader) author = att.get('author') if author: name = microformats2.maybe_linked_name( microformats2.object_to_json(author).get('properties', [])) html = '%s: %s' % (name.strip(), html) obj['rendered_children'].append(_encode_ampersands(html)) # Emulate Django template behavior that returns a special default value that # can continue to be referenced when an attribute or item lookup fails. Helps # avoid conditionals in the template itself. # https://docs.djangoproject.com/en/1.8/ref/templates/language/#variables class Defaulter(collections.defaultdict): def __init__(self, **kwargs): super(Defaulter, self).__init__(Defaulter, **{ k: (Defaulter(**v) if isinstance(v, dict) else v) for k, v in kwargs.items()}) def __unicode__(self): return super(Defaulter, self).__unicode__() if self else u'' env = jinja2.Environment(loader=jinja2.PackageLoader(__package__, 'templates'), autoescape=True) if actor is None: actor = {} return env.get_template(ATOM_TEMPLATE_FILE).render( items=[Defaulter(**a) for a in activities], host_url=host_url, request_url=request_url, xml_base=xml_base, title=title or 'User feed for ' + source.Source.actor_name(actor), updated=activities[0]['object'].get('published', '') if activities else '', actor=Defaulter(**actor), rels=rels or {}, )
def object_to_json(obj, trim_nulls=True, entry_class='h-entry', default_object_type=None, synthesize_content=True): """Converts an ActivityStreams object to microformats2 JSON. Args: obj: dict, a decoded JSON ActivityStreams object trim_nulls: boolean, whether to remove elements with null or empty values entry_class: string, the mf2 class that entries should be given (e.g. 'h-cite' when parsing a reference to a foreign entry). defaults to 'h-entry' default_object_type: string, the ActivityStreams objectType to use if one is not present. defaults to None synthesize_content: whether to generate synthetic content if the object doesn't have its own, e.g. 'likes this.' or 'shared this.' Returns: dict, decoded microformats2 JSON """ if not obj or not isinstance(obj, dict): return {} obj_type = source.object_type(obj) or default_object_type # if the activity type is a post, then it's really just a conduit # for the object. for other verbs, the activity itself is the # interesting thing if obj_type == 'post': primary = obj.get('object', {}) obj_type = source.object_type(primary) or default_object_type else: primary = obj # TODO: extract snippet name = primary.get('displayName', primary.get('title')) summary = primary.get('summary') author = obj.get('author', obj.get('actor', {})) in_reply_tos = obj.get( 'inReplyTo', obj.get('context', {}).get('inReplyTo', [])) is_rsvp = obj_type in ('rsvp-yes', 'rsvp-no', 'rsvp-maybe') if (is_rsvp or obj_type == 'react') and obj.get('object'): objs = obj['object'] in_reply_tos.extend(objs if isinstance(objs, list) else [objs]) # TODO: more tags. most will be p-category? ret = { 'type': (['h-card'] if obj_type == 'person' else ['h-card', 'p-location'] if obj_type == 'place' else [entry_class]), 'properties': { 'uid': [obj.get('id', '')], 'name': [name], 'summary': [summary], 'url': (list(object_urls(obj) or object_urls(primary)) + obj.get('upstreamDuplicates', [])), 'photo': [image.get('url') for image in (util.get_list(obj, 'image') or util.get_list(primary, 'image'))], 'video': [obj.get('stream', primary.get('stream', {})).get('url')], 'published': [obj.get('published', primary.get('published', ''))], 'updated': [obj.get('updated', primary.get('updated', ''))], 'content': [{ 'value': xml.sax.saxutils.unescape(primary.get('content', '')), 'html': render_content(primary, include_location=False, synthesize_content=synthesize_content), }], 'in-reply-to': util.trim_nulls([o.get('url') for o in in_reply_tos]), 'author': [object_to_json( author, trim_nulls=False, default_object_type='person')], 'location': [object_to_json( primary.get('location', {}), trim_nulls=False, default_object_type='place')], 'latitude': primary.get('latitude'), 'longitude': primary.get('longitude'), 'comment': [object_to_json(c, trim_nulls=False, entry_class='h-cite') for c in obj.get('replies', {}).get('items', [])], }, 'children': [object_to_json(c, trim_nulls=False, entry_class='h-cite') for c in primary.get('attachments', []) if c.get('objectType') in ('note', 'article')], } # hashtags and person tags tags = obj.get('tags', []) or util.get_first(obj, 'object', {}).get('tags', []) ret['properties']['category'] = [] for tag in tags: if tag.get('objectType') == 'person': cls = 'u-category h-card' elif tag.get('objectType') == 'hashtag': cls = 'u-category' else: continue ret['properties']['category'].append(object_to_json(tag, entry_class=cls)) # rsvp if is_rsvp: ret['properties']['rsvp'] = [obj_type[len('rsvp-'):]] elif obj_type == 'invite': invitee = object_to_json(obj.get('object'), trim_nulls=False, default_object_type='person') ret['properties']['invitee'] = [invitee] # like and repost mentions for type, prop in ('like', 'like'), ('share', 'repost'): if obj_type == type: # The ActivityStreams spec says the object property should always be a # single object, but it's useful to let it be a list, e.g. when a like has # multiple targets, e.g. a like of a post with original post URLs in it, # which brid.gy does. objs = util.get_list(obj, 'object') ret['properties'][prop + '-of'] = [ # flatten contexts that are just a url o['url'] if 'url' in o and set(o.keys()) <= set(['url', 'objectType']) else object_to_json(o, trim_nulls=False, entry_class='h-cite') for o in objs] else: # received likes and reposts ret['properties'][prop] = [ object_to_json(t, trim_nulls=False, entry_class='h-cite') for t in tags if source.object_type(t) == type] if trim_nulls: ret = util.trim_nulls(ret) return ret
def _create(self, obj, preview, include_link=source.OMIT_LINK, ignore_formatting=False): """Creates or previews creating for the previous two methods. https://www.flickr.com/services/api/upload.api.html https://www.flickr.com/services/api/flickr.photos.comments.addComment.html https://www.flickr.com/services/api/flickr.favorites.add.html https://www.flickr.com/services/api/flickr.photos.people.add.html Args: obj: ActivityStreams object preview: boolean include_link: string ignore_formatting: boolean Return: a CreationResult """ # photo, comment, or like type = source.object_type(obj) logging.debug('publishing object type %s to Flickr', type) link_text = '(Originally published at: %s)' % obj.get('url') image_url = util.get_first(obj, 'image', {}).get('url') video_url = util.get_first(obj, 'stream', {}).get('url') content = self._content_for_create( obj, ignore_formatting=ignore_formatting, strip_first_video_tag=bool(video_url)) if (video_url or image_url) and type in ('note', 'article'): name = obj.get('displayName') people = self._get_person_tags(obj) hashtags = [ t.get('displayName') for t in obj.get('tags', []) if t.get('objectType') == 'hashtag' and t.get('displayName') ] lat = obj.get('location', {}).get('latitude') lng = obj.get('location', {}).get('longitude') # if name does not represent an explicit title, then we'll just # use it as the title and wipe out the content if name and content and not mf2util.is_name_a_title(name, content): name = content content = None # add original post link if include_link == source.INCLUDE_LINK: content = ((content + '\n\n') if content else '') + link_text if preview: preview_content = '' if name: preview_content += '<h4>%s</h4>' % name if content: preview_content += '<div>%s</div>' % content if hashtags: preview_content += '<div> %s</div>' % ' '.join( '#' + t for t in hashtags) if people: preview_content += '<div> with %s</div>' % ', '.join( ('<a href="%s">%s</a>' % (p.get('url'), p.get('displayName') or 'User %s' % p.get('id')) for p in people)) if lat and lng: preview_content += '<div> at <a href="https://maps.google.com/maps?q=%s,%s">%s, %s</a></div>' % ( lat, lng, lat, lng) if video_url: preview_content += ( '<video controls src="%s"><a href="%s">this video' '</a></video>' % (video_url, video_url)) else: preview_content += '<img src="%s" />' % image_url return source.creation_result(content=preview_content, description='post') params = [] if name: params.append(('title', name)) if content: params.append(('description', content.encode('utf-8'))) if hashtags: params.append(('tags', ','.join( ('"%s"' % t if ' ' in t else t).encode('utf-8') for t in hashtags))) file = util.urlopen(video_url or image_url) try: resp = self.upload(params, file) except requests.exceptions.ConnectionError as e: if e.args[0].message.startswith( 'Request exceeds 10 MiB limit'): msg = 'Sorry, photos and videos must be under 10MB.' return source.creation_result(error_plain=msg, error_html=msg) else: raise photo_id = resp.get('id') resp.update({ 'type': 'post', 'url': self.photo_url(self.path_alias() or self.user_id(), photo_id), }) if video_url: resp['granary_message'] = \ "Note that videos take time to process before they're visible." # add person tags for person_id in sorted(p.get('id') for p in people): self.call_api_method('flickr.photos.people.add', { 'photo_id': photo_id, 'user_id': person_id, }) # add location if lat and lng: self.call_api_method('flickr.photos.geo.setLocation', { 'photo_id': photo_id, 'lat': lat, 'lon': lng, }) return source.creation_result(resp) base_obj = self.base_object(obj) base_id = base_obj.get('id') base_url = base_obj.get('url') # maybe a comment on a flickr photo? if type == 'comment' or obj.get('inReplyTo'): if not base_id: return source.creation_result( abort=True, error_plain='Could not find a photo to comment on.', error_html= 'Could not find a photo to <a href="http://indiewebcamp.com/reply">comment on</a>. ' 'Check that your post has an <a href="http://indiewebcamp.com/comment">in-reply-to</a> ' 'link to a Flickr photo or to an original post that publishes a ' '<a href="http://indiewebcamp.com/rel-syndication">rel-syndication</a> link to Flickr.' ) if include_link == source.INCLUDE_LINK: content += '\n\n' + link_text if preview: return source.creation_result( content=content, description='comment on <a href="%s">this photo</a>.' % base_url) resp = self.call_api_method( 'flickr.photos.comments.addComment', { 'photo_id': base_id, 'comment_text': content.encode('utf-8'), }) resp = resp.get('comment', {}) resp.update({ 'type': 'comment', 'url': resp.get('permalink'), }) return source.creation_result(resp) if type == 'like': if not base_id: return source.creation_result( abort=True, error_plain='Could not find a photo to favorite.', error_html= 'Could not find a photo to <a href="http://indiewebcamp.com/like">favorite</a>. ' 'Check that your post has an <a href="http://indiewebcamp.com/like">like-of</a> ' 'link to a Flickr photo or to an original post that publishes a ' '<a href="http://indiewebcamp.com/rel-syndication">rel-syndication</a> link to Flickr.' ) if preview: return source.creation_result( description='favorite <a href="%s">this photo</a>.' % base_url) # this method doesn't return any data self.call_api_method('flickr.favorites.add', { 'photo_id': base_id, }) # TODO should we canonicalize the base_url (e.g. removing trailing path # info like "/in/contacts/") return source.creation_result({ 'type': 'like', 'url': '%s#favorited-by-%s' % (base_url, self.user_id()), }) return source.creation_result( abort=False, error_plain='Cannot publish type=%s to Flickr.' % type, error_html='Cannot publish type=%s to Flickr.' % type)
def _create(self, obj, preview, include_link): """Creates or previews creating for the previous two methods. https://www.flickr.com/services/api/upload.api.html https://www.flickr.com/services/api/flickr.photos.comments.addComment.html https://www.flickr.com/services/api/flickr.favorites.add.html https://www.flickr.com/services/api/flickr.photos.people.add.html Args: obj: ActivityStreams object preview: boolean include_link: boolean Return: a CreationResult """ # photo, comment, or like type = source.object_type(obj) logging.debug('publishing object type %s to Flickr', type) content = self._content_for_create(obj) link_text = '(Originally published at: %s)' % obj.get('url') if obj.get('image') and type in ('note', 'article'): image_url = obj.get('image').get('url') name = obj.get('displayName') people = self._get_person_tags(obj) # if name does not represent an explicit title, then we'll just # use it as the title and wipe out the content if name and content and not mf2util.is_name_a_title(name, content): content = None # add original post link if include_link: content = ((content + '\n\n') if content else '') + link_text if preview: preview_content = '' if name: preview_content += '<h4>%s</h4>' % name if content: preview_content += '<div>%s</div>' % content if people: preview_content += '<div> with %s</div>' % ', '.join( ('<a href="%s">%s</a>' % ( p.get('url'), p.get('displayName') or 'User %s' % p.get('id')) for p in people)) preview_content += '<img src="%s" />' % image_url return source.creation_result( content=preview_content, description='post') params = [] if name: params.append(('title', name)) if content: params.append(('description', content)) resp = self.upload_photo(params, urllib2.urlopen(image_url)) photo_id = resp.get('id') resp.update({ 'type': 'post', 'url': self.photo_url(self.path_alias() or self.user_id(), photo_id), }) # add person tags for person_id in sorted(p.get('id') for p in people): self.call_api_method('flickr.photos.people.add', { 'photo_id': photo_id, 'user_id': person_id, }) return source.creation_result(resp) base_obj = self.base_object(obj) base_id = base_obj.get('id') base_url = base_obj.get('url') # maybe a comment on a flickr photo? if type == 'comment' or obj.get('inReplyTo'): if not base_id: return source.creation_result( abort=True, error_plain='Could not find a photo to comment on.', error_html='Could not find a photo to <a href="http://indiewebcamp.com/reply">comment on</a>. ' 'Check that your post has an <a href="http://indiewebcamp.com/comment">in-reply-to</a> ' 'link to a Flickr photo or to an original post that publishes a ' '<a href="http://indiewebcamp.com/rel-syndication">rel-syndication</a> link to Flickr.') if include_link: content += '\n\n' + link_text if preview: return source.creation_result( content=content, description='comment on <a href="%s">this photo</a>.' % base_url) resp = self.call_api_method('flickr.photos.comments.addComment', { 'photo_id': base_id, 'comment_text': content, }) resp = resp.get('comment', {}) resp.update({ 'type': 'comment', 'url': resp.get('permalink'), }) return source.creation_result(resp) if type == 'like': if not base_id: return source.creation_result( abort=True, error_plain='Could not find a photo to favorite.', error_html='Could not find a photo to <a href="http://indiewebcamp.com/like">favorite</a>. ' 'Check that your post has an <a href="http://indiewebcamp.com/like">like-of</a> ' 'link to a Flickr photo or to an original post that publishes a ' '<a href="http://indiewebcamp.com/rel-syndication">rel-syndication</a> link to Flickr.') if preview: return source.creation_result( description='favorite <a href="%s">this photo</a>.' % base_url) # this method doesn't return any data self.call_api_method('flickr.favorites.add', { 'photo_id': base_id, }) # TODO should we canonicalize the base_url (e.g. removing trailing path # info like "/in/contacts/") return source.creation_result({ 'type': 'like', 'url': '%s#favorited-by-%s' % (base_url, self.user_id()), }) return source.creation_result( abort=False, error_plain='Cannot publish type=%s to Flickr.' % type, error_html='Cannot publish type=%s to Flickr.' % type)
def render_content(obj, include_location=True): """Renders the content of an ActivityStreams object. Includes tags, mentions, and attachments. Args: obj: decoded JSON ActivityStreams object include_location: whether to render location, if provided Returns: string, rendered HTML """ content = obj.get('content', '') # extract tags. preserve order but de-dupe, ie don't include a tag more than # once. seen_ids = set() mentions = [] tags = {} # maps string objectType to list of tag objects for t in obj.get('tags', []): id = t.get('id') if id and id in seen_ids: continue seen_ids.add(id) if 'startIndex' in t and 'length' in t: mentions.append(t) else: tags.setdefault(source.object_type(t), []).append(t) # linkify embedded mention tags inside content. if mentions: mentions.sort(key=lambda t: t['startIndex']) last_end = 0 orig = content content = '' for tag in mentions: start = tag['startIndex'] end = start + tag['length'] content += orig[last_end:start] content += '<a href="%s">%s</a>' % ( tag['url'], orig[start:end]) last_end = end content += orig[last_end:] # convert newlines to <br>s # do this *after* linkifying tags so we don't have to shuffle indices over content = content.replace('\n', '<br />\n') # linkify embedded links. ignore the "mention" tags that we added ourselves. # TODO: fix the bug in test_linkify_broken() in webutil/util_test.py, then # uncomment this. # if content: # content = util.linkify(content) # attachments, e.g. links (aka articles) # TODO: use oEmbed? http://oembed.com/ , http://code.google.com/p/python-oembed/ for tag in obj.get('attachments', []) + tags.pop('article', []): name = tag.get('displayName', '') open_a_tag = False if tag.get('objectType') == 'video': video = tag.get('stream') or obj.get('stream') if video: if isinstance(video, list): video = video[0] poster = tag.get('image', {}) if poster and isinstance(poster, list): poster = poster[0] if video.get('url'): content += '\n<p>%s</p>' % vid( video['url'], poster.get('url'), 'thumbnail') else: content += '\n<p>' url = tag.get('url') or obj.get('url') if url: content += '\n<a class="link" href="%s">' % url open_a_tag = True image = tag.get('image') or obj.get('image') if image: if isinstance(image, list): image = image[0] if image.get('url'): content += '\n' + img(image['url'], 'thumbnail', name) if name: content += '\n<span class="name">%s</span>' % name if open_a_tag: content += '\n</a>' summary = tag.get('summary') if summary and summary != name: content += '\n<span class="summary">%s</span>' % summary content += '\n</p>' # location loc = obj.get('location') if include_location and loc: loc_mf2 = object_to_json(loc) loc_mf2['type'] = ['h-card', 'p-location'] content += '\n' + hcard_to_html(loc_mf2) # other tags, except likes and (re)shares. they're rendered manually in # json_to_html(). tags.pop('like', []) tags.pop('share', []) content += tags_to_html(tags.pop('hashtag', []), 'p-category') content += tags_to_html(tags.pop('mention', []), 'u-mention') content += tags_to_html(sum(tags.values(), []), 'tag') return content
def object_to_json(obj, ctx={}, trim_nulls=True): """Converts an ActivityStreams object to microformats2 JSON. Args: obj: dict, a decoded JSON ActivityStreams object ctx: dict, a decoded JSON ActivityStreams context trim_nulls: boolean, whether to remove elements with null or empty values Returns: dict, decoded microformats2 JSON """ if not obj: return {} types_map = {'article': ['h-entry', 'h-as-article'], 'comment': ['h-entry', 'p-comment'], 'like': ['h-entry', 'h-as-like'], 'note': ['h-entry', 'h-as-note'], 'person': ['h-card'], 'place': ['h-card', 'p-location'], 'share': ['h-entry', 'h-as-repost'], 'rsvp-yes': ['h-entry', 'h-as-rsvp'], 'rsvp-no': ['h-entry', 'h-as-rsvp'], 'rsvp-maybe': ['h-entry', 'h-as-rsvp'], 'invite': ['h-entry'], } obj_type = source.object_type(obj) types = types_map.get(obj_type, ['h-entry']) url = obj.get('url', '') content = obj.get('content', '') # TODO: extract snippet name = obj.get('displayName', obj.get('title')) summary = obj.get('summary') author = obj.get('author', obj.get('actor', {})) author = object_to_json(author, trim_nulls=False) if author: author['type'] = ['h-card'] location = object_to_json(obj.get('location', {}), trim_nulls=False) if location: location['type'] = ['h-card', 'p-location'] in_reply_tos = obj.get('inReplyTo', []) + ctx.get('inReplyTo', []) if 'h-as-rsvp' in types and 'object' in obj: in_reply_tos.append(obj['object']) # TODO: more tags. most will be p-category? ret = { 'type': types, 'properties': { 'uid': [obj.get('id', '')], 'name': [name], 'summary': [summary], 'url': [url] + obj.get('upstreamDuplicates', []), 'photo': [obj.get('image', {}).get('url', '')], 'video': [obj.get('stream', {}).get('url')], 'published': [obj.get('published', '')], 'updated': [obj.get('updated', '')], 'content': [{ 'value': xml.sax.saxutils.unescape(content), 'html': render_content(obj, include_location=False), }], 'in-reply-to': util.trim_nulls([o.get('url') for o in in_reply_tos]), 'author': [author], 'location': [location], 'comment': [object_to_json(c, trim_nulls=False) for c in obj.get('replies', {}).get('items', [])], } } # rsvp if 'h-as-rsvp' in types: ret['properties']['rsvp'] = [obj_type[len('rsvp-'):]] elif obj_type == 'invite': invitee = object_to_json(obj.get('object'), trim_nulls=False) invitee['type'].append('p-invitee') ret['properties']['invitee'] = [invitee] # likes and reposts # http://indiewebcamp.com/like#Counterproposal for type, prop in ('like', 'like'), ('share', 'repost'): if obj_type == type: # The ActivityStreams spec says the object property should always be a # single object, but it's useful to let it be a list, e.g. when a like has # multiple targets, e.g. a like of a post with original post URLs in it, # which brid.gy does. objs = obj.get('object', []) if not isinstance(objs, list): objs = [objs] ret['properties'][prop] = ret['properties'][prop + '-of'] = \ [o.get('url') for o in objs] else: ret['properties'][prop] = [object_to_json(t, trim_nulls=False) for t in obj.get('tags', []) if source.object_type(t) == type] if trim_nulls: ret = util.trim_nulls(ret) return ret
def _create(self, obj, preview, include_link=False, ignore_formatting=False): """Creates or previews creating for the previous two methods. https://www.flickr.com/services/api/upload.api.html https://www.flickr.com/services/api/flickr.photos.comments.addComment.html https://www.flickr.com/services/api/flickr.favorites.add.html https://www.flickr.com/services/api/flickr.photos.people.add.html Args: obj: ActivityStreams object preview: boolean include_link: boolean Return: a CreationResult """ # photo, comment, or like type = source.object_type(obj) logging.debug('publishing object type %s to Flickr', type) link_text = '(Originally published at: %s)' % obj.get('url') image_url = util.get_first(obj, 'image', {}).get('url') video_url = util.get_first(obj, 'stream', {}).get('url') content = self._content_for_create(obj, ignore_formatting=ignore_formatting, strip_first_video_tag=bool(video_url)) if (video_url or image_url) and type in ('note', 'article'): name = obj.get('displayName') people = self._get_person_tags(obj) hashtags = [t.get('displayName') for t in obj.get('tags', []) if t.get('objectType') == 'hashtag' and t.get('displayName')] lat = obj.get('location', {}).get('latitude') lng = obj.get('location', {}).get('longitude') # if name does not represent an explicit title, then we'll just # use it as the title and wipe out the content if name and content and not mf2util.is_name_a_title(name, content): name = content content = None # add original post link if include_link: content = ((content + '\n\n') if content else '') + link_text if preview: preview_content = '' if name: preview_content += '<h4>%s</h4>' % name if content: preview_content += '<div>%s</div>' % content if hashtags: preview_content += '<div> %s</div>' % ' '.join('#' + t for t in hashtags) if people: preview_content += '<div> with %s</div>' % ', '.join( ('<a href="%s">%s</a>' % ( p.get('url'), p.get('displayName') or 'User %s' % p.get('id')) for p in people)) if lat and lng: preview_content += '<div> at <a href="https://maps.google.com/maps?q=%s,%s">%s, %s</a></div>' % (lat, lng, lat, lng) if video_url: preview_content += ('<video controls src="%s"><a href="%s">this video' '</a></video>' % (video_url, video_url)) else: preview_content += '<img src="%s" />' % image_url return source.creation_result(content=preview_content, description='post') params = [] if name: params.append(('title', name)) if content: params.append(('description', content)) if hashtags: params.append( ('tags', ','.join('"%s"' % t if ' ' in t else t for t in hashtags))) file = util.urlopen(video_url or image_url) resp = self.upload(params, file) photo_id = resp.get('id') resp.update({ 'type': 'post', 'url': self.photo_url(self.path_alias() or self.user_id(), photo_id), }) if video_url: resp['granary_message'] = \ "Note that videos take time to process before they're visible." # add person tags for person_id in sorted(p.get('id') for p in people): self.call_api_method('flickr.photos.people.add', { 'photo_id': photo_id, 'user_id': person_id, }) # add location if lat and lng: self.call_api_method('flickr.photos.geo.setLocation', { 'photo_id': photo_id, 'lat': lat, 'lon': lng, }) return source.creation_result(resp) base_obj = self.base_object(obj) base_id = base_obj.get('id') base_url = base_obj.get('url') # maybe a comment on a flickr photo? if type == 'comment' or obj.get('inReplyTo'): if not base_id: return source.creation_result( abort=True, error_plain='Could not find a photo to comment on.', error_html='Could not find a photo to <a href="http://indiewebcamp.com/reply">comment on</a>. ' 'Check that your post has an <a href="http://indiewebcamp.com/comment">in-reply-to</a> ' 'link to a Flickr photo or to an original post that publishes a ' '<a href="http://indiewebcamp.com/rel-syndication">rel-syndication</a> link to Flickr.') if include_link: content += '\n\n' + link_text if preview: return source.creation_result( content=content, description='comment on <a href="%s">this photo</a>.' % base_url) resp = self.call_api_method('flickr.photos.comments.addComment', { 'photo_id': base_id, 'comment_text': content, }) resp = resp.get('comment', {}) resp.update({ 'type': 'comment', 'url': resp.get('permalink'), }) return source.creation_result(resp) if type == 'like': if not base_id: return source.creation_result( abort=True, error_plain='Could not find a photo to favorite.', error_html='Could not find a photo to <a href="http://indiewebcamp.com/like">favorite</a>. ' 'Check that your post has an <a href="http://indiewebcamp.com/like">like-of</a> ' 'link to a Flickr photo or to an original post that publishes a ' '<a href="http://indiewebcamp.com/rel-syndication">rel-syndication</a> link to Flickr.') if preview: return source.creation_result( description='favorite <a href="%s">this photo</a>.' % base_url) # this method doesn't return any data self.call_api_method('flickr.favorites.add', { 'photo_id': base_id, }) # TODO should we canonicalize the base_url (e.g. removing trailing path # info like "/in/contacts/") return source.creation_result({ 'type': 'like', 'url': '%s#favorited-by-%s' % (base_url, self.user_id()), }) return source.creation_result( abort=False, error_plain='Cannot publish type=%s to Flickr.' % type, error_html='Cannot publish type=%s to Flickr.' % type)
def activities_to_atom(activities, actor, title=None, request_url=None, host_url=None): """Converts ActivityStreams activites to an Atom feed. Args: activities: list of ActivityStreams activity dicts actor: ActivityStreams actor dict, the author of the feed title: string, the feed <title> element. Defaults to 'User feed for [NAME]' request_url: the URL of this Atom feed, if any. Used in a link rel="self". host_url: the home URL for this Atom feed, if any. Used in the top-level feed <id> element. Returns: unicode string with Atom XML """ # Strip query params from URLs so that we don't include access tokens, etc host_url = _remove_query_params(host_url) if host_url else "https://github.com/snarfed/granary" request_url = _remove_query_params(request_url) if request_url else host_url for a in activities: act_type = source.object_type(a) if not act_type or act_type == "post": primary = a.get("object", {}) else: primary = a obj = a.setdefault("object", {}) # Render content as HTML; escape &s rendered = [] rendered.append(microformats2.render_content(primary)) obj["rendered_content"] = _encode_ampersands("\n".join(rendered)) # Make sure every activity has the title field, since Atom <entry> requires # the title element. if not a.get("title"): a["title"] = util.ellipsize( _encode_ampersands( a.get("displayName") or a.get("content") or obj.get("title") or obj.get("displayName") or obj.get("content") or "Untitled" ) ) # strip HTML tags. the Atom spec says title is plain text: # http://atomenabled.org/developers/syndication/#requiredEntryElements a["title"] = xml.sax.saxutils.escape(BeautifulSoup(a["title"], "html.parser").get_text("")) # Normalize attachments.image to always be a list. for att in primary.get("attachments", []): image = att.get("image") if image and not isinstance(image, list): att["image"] = [image] # Emulate Django template behavior that returns a special default value that # can continue to be referenced when an attribute or item lookup fails. Helps # avoid conditionals in the template itself. # https://docs.djangoproject.com/en/1.8/ref/templates/language/#variables class Defaulter(collections.defaultdict): def __init__(self, **kwargs): super(Defaulter, self).__init__( Defaulter, **{k: (Defaulter(**v) if isinstance(v, dict) else v) for k, v in kwargs.items()} ) def __unicode__(self): return super(Defaulter, self).__unicode__() if self else u"" env = jinja2.Environment(loader=jinja2.PackageLoader(__package__, "templates"), autoescape=True) if actor is None: actor = {} return env.get_template(ATOM_TEMPLATE_FILE).render( items=[Defaulter(**a) for a in activities], host_url=host_url, request_url=request_url, title=title or "User feed for " + source.Source.actor_name(actor), updated=activities[0]["object"].get("published", "") if activities else "", actor=Defaulter(**actor), )
def object_to_json(obj, ctx={}, trim_nulls=True): """Converts an ActivityStreams object to microformats2 JSON. Args: obj: dict, a decoded JSON ActivityStreams object ctx: dict, a decoded JSON ActivityStreams context trim_nulls: boolean, whether to remove elements with null or empty values Returns: dict, decoded microformats2 JSON """ if not obj: return {} types_map = { 'article': ['h-entry', 'h-as-article'], 'comment': ['h-entry', 'p-comment'], 'like': ['h-entry', 'h-as-like'], 'note': ['h-entry', 'h-as-note'], 'person': ['h-card'], 'place': ['h-card', 'p-location'], 'share': ['h-entry', 'h-as-repost'], 'rsvp-yes': ['h-entry', 'h-as-rsvp'], 'rsvp-no': ['h-entry', 'h-as-rsvp'], 'rsvp-maybe': ['h-entry', 'h-as-rsvp'], 'invite': ['h-entry'], } obj_type = source.object_type(obj) types = types_map.get(obj_type, ['h-entry']) url = obj.get('url', '') content = obj.get('content', '') # TODO: extract snippet name = obj.get('displayName', obj.get('title')) summary = obj.get('summary') author = obj.get('author', obj.get('actor', {})) author = object_to_json(author, trim_nulls=False) if author: author['type'] = ['h-card'] location = object_to_json(obj.get('location', {}), trim_nulls=False) if location: location['type'] = ['h-card', 'p-location'] in_reply_tos = obj.get('inReplyTo', []) + ctx.get('inReplyTo', []) if 'h-as-rsvp' in types and 'object' in obj: in_reply_tos.append(obj['object']) # TODO: more tags. most will be p-category? ret = { 'type': types, 'properties': { 'uid': [obj.get('id', '')], 'name': [name], 'summary': [summary], 'url': [url], 'photo': [obj.get('image', {}).get('url', '')], 'video': [obj.get('stream', {}).get('url')], 'published': [obj.get('published', '')], 'updated': [obj.get('updated', '')], 'content': [{ 'value': xml.sax.saxutils.unescape(content), 'html': render_content(obj, include_location=False), }], 'in-reply-to': util.trim_nulls([o.get('url') for o in in_reply_tos]), 'author': [author], 'location': [location], 'comment': [ object_to_json(c, trim_nulls=False) for c in obj.get('replies', {}).get('items', []) ], } } # rsvp if 'h-as-rsvp' in types: ret['properties']['rsvp'] = [obj_type[len('rsvp-'):]] elif obj_type == 'invite': invitee = object_to_json(obj.get('object'), trim_nulls=False) invitee['type'].append('p-invitee') ret['properties']['invitee'] = [invitee] # likes and reposts # http://indiewebcamp.com/like#Counterproposal for type, prop in ('like', 'like'), ('share', 'repost'): if obj_type == type: # The ActivityStreams spec says the object property should always be a # single object, but it's useful to let it be a list, e.g. when a like has # multiple targets, e.g. a like of a post with original post URLs in it, # which brid.gy does. objs = obj.get('object', []) if not isinstance(objs, list): objs = [objs] ret['properties'][prop] = ret['properties'][prop + '-of'] = \ [o.get('url') for o in objs] else: ret['properties'][prop] = [ object_to_json(t, trim_nulls=False) for t in obj.get('tags', []) if source.object_type(t) == type ] if trim_nulls: ret = util.trim_nulls(ret) return ret
def render_content(obj, include_location=True): """Renders the content of an ActivityStreams object. Includes tags, mentions, and attachments. Args: obj: decoded JSON ActivityStreams object include_location: whether to render location, if provided Returns: string, rendered HTML """ content = obj.get('content', '') # extract tags. preserve order but de-dupe, ie don't include a tag more than # once. seen_ids = set() mentions = [] tags = {} # maps string objectType to list of tag objects for t in obj.get('tags', []): id = t.get('id') if id and id in seen_ids: continue seen_ids.add(id) if 'startIndex' in t and 'length' in t: mentions.append(t) else: tags.setdefault(source.object_type(t), []).append(t) # linkify embedded mention tags inside content. if mentions: mentions.sort(key=lambda t: t['startIndex']) last_end = 0 orig = content content = '' for tag in mentions: start = tag['startIndex'] end = start + tag['length'] content += orig[last_end:start] content += '<a href="%s">%s</a>' % (tag['url'], orig[start:end]) last_end = end content += orig[last_end:] # convert newlines to <br>s # do this *after* linkifying tags so we don't have to shuffle indices over content = content.replace('\n', '<br />\n') # linkify embedded links. ignore the "mention" tags that we added ourselves. # TODO: fix the bug in test_linkify_broken() in webutil/util_test.py, then # uncomment this. # if content: # content = util.linkify(content) # attachments, e.g. links (aka articles) # TODO: use oEmbed? http://oembed.com/ , http://code.google.com/p/python-oembed/ for tag in obj.get('attachments', []) + tags.pop('article', []): name = tag.get('displayName', '') open_a_tag = False if tag.get('objectType') == 'video': video = tag.get('stream') or obj.get('stream') if video: if isinstance(video, list): video = video[0] poster = tag.get('image', {}) if poster and isinstance(poster, list): poster = poster[0] if video.get('url'): content += '\n<p>%s</p>' % vid( video['url'], poster.get('url'), 'thumbnail') else: content += '\n<p>' url = tag.get('url') or obj.get('url') if url: content += '\n<a class="link" href="%s">' % url open_a_tag = True image = tag.get('image') or obj.get('image') if image: if isinstance(image, list): image = image[0] if image.get('url'): content += '\n' + img(image['url'], 'thumbnail', name) if name: content += '\n<span class="name">%s</span>' % name if open_a_tag: content += '\n</a>' summary = tag.get('summary') if summary and summary != name: content += '\n<span class="summary">%s</span>' % summary content += '\n</p>' # location loc = obj.get('location') if include_location and loc: loc_mf2 = object_to_json(loc) loc_mf2['type'] = ['h-card', 'p-location'] content += '\n' + hcard_to_html(loc_mf2) # other tags, except likes and (re)shares. they're rendered manually in # json_to_html(). tags.pop('like', []) tags.pop('share', []) content += tags_to_html(tags.pop('hashtag', []), 'p-category') content += tags_to_html(tags.pop('mention', []), 'u-mention') content += tags_to_html(sum(tags.values(), []), 'tag') return content
def activities_to_atom(activities, actor, title=None, request_url=None, host_url=None, xml_base=None, rels=None): """Converts ActivityStreams activites to an Atom feed. Args: activities: list of ActivityStreams activity dicts actor: ActivityStreams actor dict, the author of the feed title: string, the feed <title> element. Defaults to 'User feed for [NAME]' request_url: the URL of this Atom feed, if any. Used in a link rel="self". host_url: the home URL for this Atom feed, if any. Used in the top-level feed <id> element. xml_base: the base URL, if any. Used in the top-level xml:base attribute. rels: rel links to include. dict mapping string rel value to string URL. Returns: unicode string with Atom XML """ # Strip query params from URLs so that we don't include access tokens, etc host_url = (_remove_query_params(host_url) if host_url else 'https://github.com/snarfed/granary') if request_url is None: request_url = host_url for a in activities: act_type = source.object_type(a) if not act_type or act_type == 'post': primary = a.get('object', {}) else: primary = a obj = a.setdefault('object', {}) # Render content as HTML; escape &s obj['rendered_content'] = _encode_ampersands(microformats2.render_content(primary)) # Make sure every activity has the title field, since Atom <entry> requires # the title element. if not a.get('title'): a['title'] = util.ellipsize(_encode_ampersands( a.get('displayName') or a.get('content') or obj.get('title') or obj.get('displayName') or obj.get('content') or 'Untitled')) # strip HTML tags. the Atom spec says title is plain text: # http://atomenabled.org/developers/syndication/#requiredEntryElements a['title'] = xml.sax.saxutils.escape(source.strip_html_tags(a['title'])) # Normalize attachments.image to always be a list. attachments = a.get('attachments') or obj.get('attachments') or [] for att in attachments: att['image'] = util.get_list(att, 'image') obj['rendered_children'] = [ _encode_ampersands(microformats2.render_content(att)) for att in attachments if att.get('objectType') in ('note', 'article')] # Emulate Django template behavior that returns a special default value that # can continue to be referenced when an attribute or item lookup fails. Helps # avoid conditionals in the template itself. # https://docs.djangoproject.com/en/1.8/ref/templates/language/#variables class Defaulter(collections.defaultdict): def __init__(self, **kwargs): super(Defaulter, self).__init__(Defaulter, **{ k: (Defaulter(**v) if isinstance(v, dict) else v) for k, v in kwargs.items()}) def __unicode__(self): return super(Defaulter, self).__unicode__() if self else u'' env = jinja2.Environment(loader=jinja2.PackageLoader(__package__, 'templates'), autoescape=True) if actor is None: actor = {} return env.get_template(ATOM_TEMPLATE_FILE).render( items=[Defaulter(**a) for a in activities], host_url=host_url, request_url=request_url, xml_base=xml_base, title=title or 'User feed for ' + source.Source.actor_name(actor), updated=activities[0]['object'].get('published', '') if activities else '', actor=Defaulter(**actor), rels=rels or {}, )