diff --git a/docs/api.md b/docs/api.md
index 1d0ce8e..8ac3b72 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -527,6 +527,32 @@ Response `200`:
---
+### Diff Revisions
+
+```
+GET /api/v1/revisions/{revision_id}/diff/{other_id}
+```
+
+Compares two revisions of the same node. Returns a unified diff.
+
+Response `200`:
+```json
+{
+ "from_revision": "...",
+ "to_revision": "...",
+ "from_number": 1,
+ "to_number": 2,
+ "node_id": "...",
+ "diff": "--- revision 1\n+++ revision 2\n@@ ... @@\n..."
+}
+```
+
+Errors:
+- `400` if the two revisions belong to different nodes
+- `404` if either revision is not found
+
+---
+
### Theme CSS
```
diff --git a/docs/testing.md b/docs/testing.md
index f33dd5a..a6e6b0a 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -191,6 +191,12 @@ curl -s "$REMARKBOX/api/v1/nodes/$NODE_ID/revisions" | python3 -m json.tool
curl -s "$REMARKBOX/api/v1/revisions/$REVISION_ID" | python3 -m json.tool
```
+### Diff Revisions
+
+```bash
+curl -s "$REMARKBOX/api/v1/revisions/$REVISION_ID/diff/$OTHER_REVISION_ID" | python3 -m json.tool
+```
+
## Themes
### Get Namespace Theme CSS
diff --git a/remarkbox/api/__init__.py b/remarkbox/api/__init__.py
index 1d67c2d..913f665 100644
--- a/remarkbox/api/__init__.py
+++ b/remarkbox/api/__init__.py
@@ -9,6 +9,7 @@ def includeme(config):
config.add_route("api-node-wiki-edit", "/api/v1/nodes/{node_id}/wiki-edit")
config.add_route("api-node-detail", "/api/v1/nodes/{node_id}")
config.add_route("api-revision-detail", "/api/v1/revisions/{revision_id}")
+ config.add_route("api-revision-diff", "/api/v1/revisions/{revision_id}/diff/{other_id}")
config.add_route("api-auth-login", "/api/v1/auth/login")
config.add_route("api-auth-verify", "/api/v1/auth/verify")
config.add_route("api-user-profile", "/api/v1/user/profile")
diff --git a/remarkbox/api/remarkbox_client.py b/remarkbox/api/remarkbox_client.py
index 9d0c9a0..b6c0745 100644
--- a/remarkbox/api/remarkbox_client.py
+++ b/remarkbox/api/remarkbox_client.py
@@ -553,6 +553,21 @@ class RemarkboxClient:
"""
return self._request("GET", "/api/v1/revisions/{}".format(revision_id))
+ def diff_revisions(self, revision_id, other_id):
+ """Compare two revisions of the same node via unified diff.
+
+ Args:
+ revision_id: UUID of the first (from) revision
+ other_id: UUID of the second (to) revision
+
+ Returns:
+ dict with keys: from_revision, to_revision, from_number,
+ to_number, node_id, diff
+ """
+ return self._request(
+ "GET", "/api/v1/revisions/{}/diff/{}".format(revision_id, other_id)
+ )
+
# ----- Themes -----
def get_theme_css(self, namespace):
diff --git a/remarkbox/api/wiki.py b/remarkbox/api/wiki.py
index 19b7e45..af17e66 100644
--- a/remarkbox/api/wiki.py
+++ b/remarkbox/api/wiki.py
@@ -1,5 +1,7 @@
"""Wiki mode and revision history API endpoints."""
+import difflib
+
from pyramid.view import view_config
from remarkbox.models.node import get_node_by_id, Node
@@ -132,3 +134,54 @@ def api_get_revision(request):
return denied
return {"revision": serialize_revision(revision)}
+
+
+@view_config(
+ route_name="api-revision-diff",
+ request_method="GET",
+ renderer="json",
+ require_csrf=False,
+)
+def api_revision_diff(request):
+ """Compare two revisions of the same node via unified diff."""
+ revision_id = request.matchdict["revision_id"]
+ other_id = request.matchdict["other_id"]
+
+ revision = get_object_by_id(request.dbsession, revision_id, Revision)
+ if revision is None:
+ request.response.status_code = 404
+ return {"error": "Revision not found: {}".format(revision_id)}
+
+ other = get_object_by_id(request.dbsession, other_id, Revision)
+ if other is None:
+ request.response.status_code = 404
+ return {"error": "Revision not found: {}".format(other_id)}
+
+ if revision.node_id != other.node_id:
+ request.response.status_code = 400
+ return {"error": "Revisions belong to different nodes"}
+
+ node = get_node_by_id(request.dbsession, revision.node_id)
+ if node:
+ namespace = node.root.namespace
+ denied = check_namespace_api_access(request, namespace)
+ if denied:
+ return denied
+
+ from_lines = revision.data.splitlines(keepends=True)
+ to_lines = other.data.splitlines(keepends=True)
+ diff = difflib.unified_diff(
+ from_lines,
+ to_lines,
+ fromfile="revision {}".format(revision.revision_number),
+ tofile="revision {}".format(other.revision_number),
+ )
+
+ return {
+ "from_revision": str(revision.id),
+ "to_revision": str(other.id),
+ "from_number": revision.revision_number,
+ "to_number": other.revision_number,
+ "node_id": str(revision.node_id),
+ "diff": "".join(diff),
+ }
diff --git a/remarkbox/templates/show-node.j2 b/remarkbox/templates/show-node.j2
index 85ac27f..48c60eb 100644
--- a/remarkbox/templates/show-node.j2
+++ b/remarkbox/templates/show-node.j2
@@ -70,6 +70,10 @@
{{ snippets.actions(request.node) }}
+ {% if request.node.is_root %}
+ {{ snippets.export_menu(request.node) }}
+ {{ snippets.wiki_actions(request.node) }}
+ {% endif %}
@@ -145,6 +149,7 @@
{{ snippets.permalinks(parent, prev_parent, request.root_node) }}
{{ snippets.actions(parent) }}
+ {{ snippets.export_node_menu(parent) }}
{%- if children_ids %}
{{ snippets.button_collapse(parent) }}
{% endif -%}
diff --git a/remarkbox/templates/snippets/snippets.j2 b/remarkbox/templates/snippets/snippets.j2
index 6f13465..81ff2a8 100644
--- a/remarkbox/templates/snippets/snippets.j2
+++ b/remarkbox/templates/snippets/snippets.j2
@@ -239,6 +239,42 @@
{% endif %}
{% endmacro %}
+{% macro export_menu(node) %}
+
+{% endmacro %}
+
+{% macro export_node_menu(node) %}
+
+{% endmacro %}
+
+{% macro wiki_actions(node) %}
+{% if request.namespace.wiki and node.is_root and request.user and request.user.authenticated %}
+
history
+{% endif %}
+{% endmacro %}
+
{% macro namespace_actions(namespace) %}
{% if request.mode != "embed" %}
diff --git a/remarkbox/tests/test_undigg.py b/remarkbox/tests/test_undigg.py
index 73612ef..0246774 100644
--- a/remarkbox/tests/test_undigg.py
+++ b/remarkbox/tests/test_undigg.py
@@ -676,6 +676,122 @@ class TestWikiModeAndRevisions(UndiggFunctionalTests):
self.assertEqual(res.status_int, 404)
+class TestRevisionDiff(UndiggFunctionalTests):
+ """Test GET /api/v1/revisions/{id}/diff/{other_id}."""
+
+ @classmethod
+ def setUpClass(cls):
+ try:
+ UndiggFunctionalTests.setUpClass.im_func(cls)
+ except AttributeError:
+ UndiggFunctionalTests.setUpClass.__func__(cls)
+
+ def setUp(self):
+ ns = get_or_create_namespace(self.dbsession, "diff-test.example.com")
+ ns.allow_anonymous = True
+ ns.subscription_type = "production"
+ ns.wiki = True
+ self.dbsession.add(ns)
+ self.dbsession.flush()
+ self.namespace_name = str(ns.name)
+ self.namespace_id = ns.id
+ self.tm.commit()
+
+ def tearDown(self):
+ super(TestRevisionDiff, self).tearDown()
+ self.dbsession.query(Revision).filter(
+ Revision.node_id.in_(
+ self.dbsession.query(Node.id).filter(
+ Node.namespace_id == self.namespace_id
+ )
+ )
+ ).delete(synchronize_session=False)
+ self.dbsession.query(UserSurrogate).filter(
+ UserSurrogate.namespace_id == self.namespace_id
+ ).delete(synchronize_session=False)
+ self.dbsession.query(Node).filter(
+ Node.namespace_id == self.namespace_id
+ ).delete(synchronize_session=False)
+ self.dbsession.flush()
+ self.tm.commit()
+
+ def _create_thread(self, title="Diff Thread", data="Original content."):
+ res = self.testapp.post_json(
+ "/api/v1/threads",
+ {
+ "namespace": self.namespace_name,
+ "title": title,
+ "data": data,
+ "anonymous_name": "Bot",
+ },
+ expect_errors=True,
+ )
+ return res.json["node"]["id"]
+
+ def _create_revisions(self, node_id, data_v1, data_v2):
+ """Manually create two revisions for a node."""
+ node = self.dbsession.query(Node).get(node_id)
+ rev1 = Revision(node=node, data=data_v1, revision_number=1)
+ rev2 = Revision(node=node, data=data_v2, revision_number=2)
+ self.dbsession.add(rev1)
+ self.dbsession.add(rev2)
+ self.dbsession.flush()
+ rev1_id = str(rev1.id)
+ rev2_id = str(rev2.id)
+ self.tm.commit()
+ return rev1_id, rev2_id
+
+ def test_diff_same_revision(self):
+ node_id = self._create_thread()
+ rev1_id, rev2_id = self._create_revisions(
+ node_id, "Same content.", "Same content."
+ )
+ res = self.testapp.get(
+ "/api/v1/revisions/{}/diff/{}".format(rev1_id, rev1_id),
+ expect_errors=True,
+ )
+ self.assertEqual(res.status_int, 200)
+ self.assertEqual(res.json["diff"], "")
+ self.assertEqual(res.json["from_revision"], rev1_id)
+ self.assertEqual(res.json["to_revision"], rev1_id)
+
+ def test_diff_different_revisions(self):
+ node_id = self._create_thread()
+ rev1_id, rev2_id = self._create_revisions(
+ node_id, "Original content.", "Updated content."
+ )
+ res = self.testapp.get(
+ "/api/v1/revisions/{}/diff/{}".format(rev1_id, rev2_id),
+ expect_errors=True,
+ )
+ self.assertEqual(res.status_int, 200)
+ self.assertIn("from_revision", res.json)
+ self.assertIn("to_revision", res.json)
+ self.assertEqual(res.json["from_number"], 1)
+ self.assertEqual(res.json["to_number"], 2)
+ self.assertIn("Original", res.json["diff"])
+ self.assertIn("Updated", res.json["diff"])
+
+ def test_diff_not_found(self):
+ res = self.testapp.get(
+ "/api/v1/revisions/00000000-0000-0000-0000-000000000000/diff/00000000-0000-0000-0000-000000000001",
+ expect_errors=True,
+ )
+ self.assertEqual(res.status_int, 404)
+
+ def test_diff_different_nodes(self):
+ node1_id = self._create_thread(title="Thread 1", data="Content 1.")
+ node2_id = self._create_thread(title="Thread 2", data="Content 2.")
+ rev1_id, _ = self._create_revisions(node1_id, "A", "B")
+ rev2_id, _ = self._create_revisions(node2_id, "C", "D")
+ res = self.testapp.get(
+ "/api/v1/revisions/{}/diff/{}".format(rev1_id, rev2_id),
+ expect_errors=True,
+ )
+ self.assertEqual(res.status_int, 400)
+ self.assertIn("different nodes", res.json["error"])
+
+
# ---------------------------------------------------------------------------
# Theme API
# ---------------------------------------------------------------------------