From 46bdf3a217290b62976f6fa53ac29166564c0d9f Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Tue, 10 Mar 2026 11:13:39 -0400 Subject: [PATCH] Add revision diff endpoint and progressive enhancement export/wiki UI. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diff: GET /api/v1/revisions/{id}/diff/{other_id} — unified diff between two revisions. UI:
-based export menus on thread and node views (works without JS). UI: Revision history link for wiki-mode namespaces. --- docs/api.md | 26 +++++ docs/testing.md | 6 ++ remarkbox/api/__init__.py | 1 + remarkbox/api/remarkbox_client.py | 15 +++ remarkbox/api/wiki.py | 53 +++++++++++ remarkbox/templates/show-node.j2 | 5 + remarkbox/templates/snippets/snippets.j2 | 36 +++++++ remarkbox/tests/test_undigg.py | 116 +++++++++++++++++++++++ 8 files changed, 258 insertions(+) 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) %} +
+ export +
+ markdown + html + pdf + epub + docx + rst + latex + odt + plain text +
+
+{% endmacro %} + +{% macro export_node_menu(node) %} +
+ export +
+ markdown + html + pdf + epub + docx +
+
+{% 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 # ---------------------------------------------------------------------------