Merge pull request #419 from vincentbernat/feature/atom-feed
api: add /feed API to get an Atom feed for an URI
This commit is contained in:
commit
b5c40bedf7
@ -20,7 +20,8 @@ preferably in the script tag which embeds the JS:
|
||||
data-isso-avatar-bg="#f0f0f0"
|
||||
data-isso-avatar-fg="#9abf88 #5698c4 #e279a3 #9163b6 ..."
|
||||
data-isso-vote="true"
|
||||
data-vote-levels=""
|
||||
data-isso-vote-levels=""
|
||||
data-isso-feed="false"
|
||||
src="/prefix/js/embed.js"></script>
|
||||
|
||||
Furthermore you can override the automatic title detection inside
|
||||
@ -125,3 +126,10 @@ For example, the value `"-5,5"` will cause each `isso-comment` to be given one o
|
||||
- `isso-vote-level-2` for scores of `5` and greater
|
||||
|
||||
These classes can then be used to customize the appearance of comments (eg. put a star on popular comments)
|
||||
|
||||
data-isso-feed
|
||||
--------------
|
||||
|
||||
Enable or disable the addition of a link to the feed for the comment
|
||||
thread. The link will only be valid if the appropriate setting, in
|
||||
``[rss]`` section, is also enabled server-side.
|
||||
|
@ -308,6 +308,27 @@ algorithm
|
||||
Arguments have to be in that order, but can be reduced to `pbkdf2:4096`
|
||||
for example to override the iterations only.
|
||||
|
||||
.. _configure-rss:
|
||||
|
||||
RSS
|
||||
---
|
||||
|
||||
Isso can provide an Atom feed for each comment thread. Users can use
|
||||
them to subscribe to comments and be notified of changes. Atom feeds
|
||||
are enabled as soon as there is a base URL defined in this section.
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[rss]
|
||||
base =
|
||||
limit = 100
|
||||
|
||||
base
|
||||
base URL to use to build complete URI to pages (by appending the URI from Isso)
|
||||
|
||||
limit
|
||||
number of most recent comments to return for a thread
|
||||
|
||||
Appendum
|
||||
--------
|
||||
|
||||
|
@ -185,3 +185,16 @@ uri :
|
||||
|
||||
returns an integer
|
||||
|
||||
Get Atom feed
|
||||
-------------
|
||||
|
||||
Get an Atom feed of comments for thread `uri`:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
GET /feed?uri=%2Fhello-world%2F
|
||||
|
||||
uri :
|
||||
URI to get comments for, required.
|
||||
|
||||
Returns an XML document as the Atom feed.
|
||||
|
@ -15,6 +15,14 @@
|
||||
color: #555;
|
||||
font-weight: bold;
|
||||
}
|
||||
#isso-thread > .isso-feedlink {
|
||||
float: right;
|
||||
padding-left: 1em;
|
||||
}
|
||||
#isso-thread > .isso-feedlink > a {
|
||||
font-size: 0.8em;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
#isso-thread .textarea {
|
||||
min-height: 58px;
|
||||
outline: 0;
|
||||
@ -110,10 +118,12 @@
|
||||
color: gray !important;
|
||||
clear: left;
|
||||
}
|
||||
.isso-feedlink,
|
||||
.isso-comment > div.text-wrapper > .isso-comment-footer a {
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
.isso-feedlink:hover,
|
||||
.isso-comment > div.text-wrapper > .isso-comment-footer a:hover {
|
||||
color: #111111 !important;
|
||||
text-shadow: #aaaaaa 0 0 1px !important;
|
||||
@ -143,6 +153,7 @@
|
||||
.isso-postbox {
|
||||
max-width: 68em;
|
||||
margin: 0 auto 2em;
|
||||
clear: right;
|
||||
}
|
||||
.isso-postbox > .form-wrapper {
|
||||
display: block;
|
||||
|
@ -159,7 +159,8 @@ class Comments:
|
||||
for item in rv:
|
||||
yield dict(zip(fields_comments + fields_threads, item))
|
||||
|
||||
def fetch(self, uri, mode=5, after=0, parent='any', order_by='id', limit=None):
|
||||
def fetch(self, uri, mode=5, after=0, parent='any',
|
||||
order_by='id', asc=1, limit=None):
|
||||
"""
|
||||
Return comments for :param:`uri` with :param:`mode`.
|
||||
"""
|
||||
@ -181,7 +182,8 @@ class Comments:
|
||||
order_by = 'id'
|
||||
sql.append('ORDER BY ')
|
||||
sql.append(order_by)
|
||||
sql.append(' ASC')
|
||||
if not asc:
|
||||
sql.append(' DESC')
|
||||
|
||||
if limit:
|
||||
sql.append('LIMIT ?')
|
||||
|
@ -191,6 +191,10 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
var feed = function(tid) {
|
||||
return endpoint + "/feed?" + qs({uri: tid || location});
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: endpoint,
|
||||
salt: salt,
|
||||
@ -202,6 +206,7 @@ define(["app/lib/promise", "app/globals"], function(Q, globals) {
|
||||
fetch: fetch,
|
||||
count: count,
|
||||
like: like,
|
||||
dislike: dislike
|
||||
dislike: dislike,
|
||||
feed: feed
|
||||
};
|
||||
});
|
||||
|
@ -15,7 +15,8 @@ define(function() {
|
||||
"avatar-fg": ["#9abf88", "#5698c4", "#e279a3", "#9163b6",
|
||||
"#be5168", "#f19670", "#e4bf80", "#447c69"].join(" "),
|
||||
"vote": true,
|
||||
"vote-levels": null
|
||||
"vote-levels": null,
|
||||
"feed": false
|
||||
};
|
||||
|
||||
var js = document.getElementsByTagName("script");
|
||||
|
@ -7,6 +7,7 @@ define({
|
||||
|
||||
"num-comments": "One Comment\n{{ n }} Comments",
|
||||
"no-comments": "No Comments Yet",
|
||||
"atom-feed": "Atom feed",
|
||||
|
||||
"comment-reply": "Reply",
|
||||
"comment-edit": "Edit",
|
||||
|
@ -6,6 +6,7 @@ define({
|
||||
"postbox-submit": "Soumettre",
|
||||
"num-comments": "{{ n }} commentaire\n{{ n }} commentaires",
|
||||
"no-comments": "Aucun commentaire pour l'instant",
|
||||
"atom-feed": "Flux Atom",
|
||||
"comment-reply": "Répondre",
|
||||
"comment-edit": "Éditer",
|
||||
"comment-save": "Enregistrer",
|
||||
|
@ -27,6 +27,13 @@ require(["app/lib/ready", "app/config", "app/i18n", "app/api", "app/isso", "app/
|
||||
return console.log("abort, #isso-thread is missing");
|
||||
}
|
||||
|
||||
if (config["feed"]) {
|
||||
var feedLink = $.new('a', i18n.translate('atom-feed'));
|
||||
var feedLinkWrapper = $.new('span.isso-feedlink');
|
||||
feedLink.href = api.feed($("#isso-thread").getAttribute("data-isso-id"));
|
||||
feedLinkWrapper.append(feedLink);
|
||||
$("#isso-thread").append(feedLinkWrapper);
|
||||
}
|
||||
$("#isso-thread").append($.new('h4'));
|
||||
$("#isso-thread").append(new isso.Postbox(null));
|
||||
$("#isso-thread").append('<div id="isso-root"></div>');
|
||||
|
@ -4,6 +4,7 @@ from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
@ -32,6 +33,7 @@ class TestComments(unittest.TestCase):
|
||||
conf.set("general", "dbpath", self.path)
|
||||
conf.set("guard", "enabled", "off")
|
||||
conf.set("hash", "algorithm", "none")
|
||||
self.conf = conf
|
||||
|
||||
class App(Isso, core.Mixin):
|
||||
pass
|
||||
@ -324,6 +326,36 @@ class TestComments(unittest.TestCase):
|
||||
|
||||
self.assertListEqual(list(rv.keys()), [])
|
||||
|
||||
def testNoFeed(self):
|
||||
rv = self.get('/feed?uri=%2Fpath%2Fnothing')
|
||||
self.assertEqual(rv.status_code, 404)
|
||||
|
||||
def testFeedEmpty(self):
|
||||
self.conf.set("rss", "base", "https://example.org")
|
||||
|
||||
rv = self.get('/feed?uri=%2Fpath%2Fnothing')
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
self.assertEqual(rv.headers['ETag'], '"empty"')
|
||||
data = rv.data.decode('utf-8')
|
||||
self.assertEqual(data, """<?xml version=\'1.0\' encoding=\'utf-8\'?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thr="http://purl.org/syndication/thread/1.0"><updated>1970-01-01T01:00:00Z</updated><id>tag:example.org,2018:/isso/thread/path/nothing</id><title>Comments for example.org/path/nothing</title></feed>""")
|
||||
|
||||
def testFeed(self):
|
||||
self.conf.set("rss", "base", "https://example.org")
|
||||
|
||||
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'First'}))
|
||||
self.post('/new?uri=%2Fpath%2F',
|
||||
data=json.dumps({'text': '*Second*', 'parent': 1}))
|
||||
|
||||
rv = self.get('/feed?uri=%2Fpath%2F')
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
self.assertEqual(rv.headers['ETag'], '"1-2"')
|
||||
data = rv.data.decode('utf-8')
|
||||
data = re.sub('[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+Z',
|
||||
'2018-04-01T10:00:00Z', data)
|
||||
self.assertEqual(data, """<?xml version=\'1.0\' encoding=\'utf-8\'?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thr="http://purl.org/syndication/thread/1.0"><updated>2018-04-01T10:00:00Z</updated><id>tag:example.org,2018:/isso/thread/path/</id><title>Comments for example.org/path/</title><entry><id>tag:example.org,2018:/isso/1/2</id><title>Comment #2</title><updated>2018-04-01T10:00:00Z</updated><author><name /></author><link href="https://example.org/path/#isso-2" /><content type="html"><p><em>Second</em></p></content><thr:in-reply-to href="https://example.org/path/#isso-1" ref="tag:example.org,2018:/isso/1/1" /></entry><entry><id>tag:example.org,2018:/isso/1/1</id><title>Comment #1</title><updated>2018-04-01T10:00:00Z</updated><author><name /></author><link href="https://example.org/path/#isso-1" /><content type="html"><p>First</p></content></entry></feed>""")
|
||||
|
||||
def testCounts(self):
|
||||
|
||||
self.assertEqual(self.get('/count?uri=%2Fpath%2F').status_code, 404)
|
||||
|
@ -132,3 +132,10 @@ class JSONResponse(Response):
|
||||
kwargs["content_type"] = "application/json"
|
||||
super(JSONResponse, self).__init__(
|
||||
json.dumps(obj).encode("utf-8"), *args, **kwargs)
|
||||
|
||||
|
||||
class XMLResponse(Response):
|
||||
def __init__(self, obj, *args, **kwargs):
|
||||
kwargs["content_type"] = "text/xml"
|
||||
super(XMLResponse, self).__init__(
|
||||
obj, *args, **kwargs)
|
||||
|
@ -9,6 +9,7 @@ import functools
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from itsdangerous import SignatureExpired, BadSignature
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from werkzeug.http import dump_cookie
|
||||
from werkzeug.wsgi import get_current_url
|
||||
@ -20,11 +21,21 @@ from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
from isso.compat import text_type as str
|
||||
|
||||
from isso import utils, local
|
||||
from isso.utils import (http, parse, JSONResponse as JSON,
|
||||
from isso.utils import (http, parse,
|
||||
JSONResponse as JSON, XMLResponse as XML,
|
||||
render_template)
|
||||
from isso.views import requires
|
||||
from isso.utils.hash import sha1
|
||||
|
||||
try:
|
||||
from urlparse import urlparse
|
||||
except ImportError:
|
||||
from urllib.parse import urlparse
|
||||
try:
|
||||
from StringIO import StringIO
|
||||
except ImportError:
|
||||
from io import BytesIO as StringIO
|
||||
|
||||
# from Django appearently, looks good to me *duck*
|
||||
__url_re = re.compile(
|
||||
r'^'
|
||||
@ -91,6 +102,7 @@ class API(object):
|
||||
('new', ('POST', '/new')),
|
||||
('count', ('GET', '/count')),
|
||||
('counts', ('POST', '/count')),
|
||||
('feed', ('GET', '/feed')),
|
||||
('view', ('GET', '/id/<int:id>')),
|
||||
('edit', ('PUT', '/id/<int:id>')),
|
||||
('delete', ('DELETE', '/id/<int:id>')),
|
||||
@ -834,6 +846,125 @@ class API(object):
|
||||
|
||||
return JSON(self.comments.count(*data), 200)
|
||||
|
||||
"""
|
||||
@api {get} /feed Atom feed for comments
|
||||
@apiGroup Thread
|
||||
@apiDescription
|
||||
Provide an Atom feed for the given thread.
|
||||
"""
|
||||
@requires(str, 'uri')
|
||||
def feed(self, environ, request, uri):
|
||||
conf = self.isso.conf.section("rss")
|
||||
if not conf.get('base'):
|
||||
raise NotFound
|
||||
|
||||
args = {
|
||||
'uri': uri,
|
||||
'order_by': 'id',
|
||||
'asc': 0,
|
||||
'limit': conf.getint('limit')
|
||||
}
|
||||
try:
|
||||
args['limit'] = max(int(request.args.get('limit')), args['limit'])
|
||||
except TypeError:
|
||||
pass
|
||||
except ValueError:
|
||||
return BadRequest("limit should be integer")
|
||||
comments = self.comments.fetch(**args)
|
||||
base = conf.get('base')
|
||||
hostname = urlparse(base).netloc
|
||||
|
||||
# Let's build an Atom feed.
|
||||
# RFC 4287: https://tools.ietf.org/html/rfc4287
|
||||
# RFC 4685: https://tools.ietf.org/html/rfc4685 (threading extensions)
|
||||
# For IDs: http://web.archive.org/web/20110514113830/http://diveintomark.org/archives/2004/05/28/howto-atom-id
|
||||
feed = ET.Element('feed', {
|
||||
'xmlns': 'http://www.w3.org/2005/Atom',
|
||||
'xmlns:thr': 'http://purl.org/syndication/thread/1.0'
|
||||
})
|
||||
|
||||
# For feed ID, we would use thread ID, but we may not have
|
||||
# one. Therefore, we use the URI. We don't have a year
|
||||
# either...
|
||||
id = ET.SubElement(feed, 'id')
|
||||
id.text = 'tag:{hostname},2018:/isso/thread{uri}'.format(
|
||||
hostname=hostname, uri=uri)
|
||||
|
||||
# For title, we don't have much either. Be pretty generic.
|
||||
title = ET.SubElement(feed, 'title')
|
||||
title.text = 'Comments for {hostname}{uri}'.format(
|
||||
hostname=hostname, uri=uri)
|
||||
|
||||
comment0 = None
|
||||
|
||||
for comment in comments:
|
||||
if comment0 is None:
|
||||
comment0 = comment
|
||||
|
||||
entry = ET.SubElement(feed, 'entry')
|
||||
# We don't use a real date in ID either to help with
|
||||
# threading.
|
||||
id = ET.SubElement(entry, 'id')
|
||||
id.text = 'tag:{hostname},2018:/isso/{tid}/{id}'.format(
|
||||
hostname=hostname,
|
||||
tid=comment['tid'],
|
||||
id=comment['id'])
|
||||
title = ET.SubElement(entry, 'title')
|
||||
title.text = 'Comment #{}'.format(comment['id'])
|
||||
updated = ET.SubElement(entry, 'updated')
|
||||
updated.text = '{}Z'.format(datetime.fromtimestamp(
|
||||
comment['modified'] or comment['created']).isoformat())
|
||||
author = ET.SubElement(entry, 'author')
|
||||
name = ET.SubElement(author, 'name')
|
||||
name.text = comment['author']
|
||||
ET.SubElement(entry, 'link', {
|
||||
'href': '{base}{uri}#isso-{id}'.format(
|
||||
base=base,
|
||||
uri=uri, id=comment['id'])
|
||||
})
|
||||
content = ET.SubElement(entry, 'content', {
|
||||
'type': 'html',
|
||||
})
|
||||
content.text = self.isso.render(comment['text'])
|
||||
|
||||
if comment['parent']:
|
||||
ET.SubElement(entry, 'thr:in-reply-to', {
|
||||
'ref': 'tag:{hostname},2018:/isso/{tid}/{id}'.format(
|
||||
hostname=hostname,
|
||||
tid=comment['tid'],
|
||||
id=comment['parent']),
|
||||
'href': '{base}{uri}#isso-{id}'.format(
|
||||
base=base,
|
||||
uri=uri, id=comment['parent'])
|
||||
})
|
||||
|
||||
# Updated is mandatory. If we have comments, we use the date
|
||||
# of last modification of the first one (which is the last
|
||||
# one). Otherwise, we use a fixed date.
|
||||
updated = ET.Element('updated')
|
||||
if comment0 is None:
|
||||
updated.text = '1970-01-01T01:00:00Z'
|
||||
else:
|
||||
updated.text = datetime.fromtimestamp(
|
||||
comment0['modified'] or comment0['created']).isoformat()
|
||||
updated.text += 'Z'
|
||||
feed.insert(0, updated)
|
||||
|
||||
output = StringIO()
|
||||
ET.ElementTree(feed).write(output,
|
||||
encoding='utf-8',
|
||||
xml_declaration=True)
|
||||
response = XML(output.getvalue(), 200)
|
||||
|
||||
# Add an etag/last-modified value for caching purpose
|
||||
if comment0 is None:
|
||||
response.set_etag('empty')
|
||||
response.last_modified = 0
|
||||
else:
|
||||
response.set_etag('{tid}-{id}'.format(**comment0))
|
||||
response.last_modified = comment0['modified'] or comment0['created']
|
||||
return response.make_conditional(request)
|
||||
|
||||
def preview(self, environment, request):
|
||||
data = request.get_json()
|
||||
|
||||
|
@ -180,3 +180,15 @@ salt = Eech7co8Ohloopo9Ol6baimi
|
||||
# strengthening. Arguments have to be in that order, but can be reduced to
|
||||
# pbkdf2:4096 for example to override the iterations only.
|
||||
algorithm = pbkdf2
|
||||
|
||||
|
||||
[rss]
|
||||
# Provide an Atom feed for each comment thread for users to subscribe to.
|
||||
|
||||
# The base URL of pages is needed to build the Atom feed. By appending
|
||||
# the URI, we should get the complete URL to use to access the page
|
||||
# with the comments. When empty, Atom feeds are disabled.
|
||||
base =
|
||||
|
||||
# Limit the number of elements to return for each thread.
|
||||
limit = 100
|
||||
|
Loading…
Reference in New Issue
Block a user