Add revision diff endpoint and progressive enhancement export/wiki UI.

Diff: GET /api/v1/revisions/{id}/diff/{other_id} — unified diff between two revisions.
UI: <details>-based export menus on thread and node views (works without JS).
UI: Revision history link for wiki-mode namespaces.
This commit is contained in:
russell@unturf.com 2026-03-10 11:13:39 -04:00
parent 75bec564b2
commit 46bdf3a217
8 changed files with 258 additions and 0 deletions

View file

@ -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
```

View file

@ -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

View file

@ -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")

View file

@ -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):

View file

@ -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),
}

View file

@ -70,6 +70,10 @@
<div class="node-actions">
{{ snippets.actions(request.node) }}
{% if request.node.is_root %}
{{ snippets.export_menu(request.node) }}
{{ snippets.wiki_actions(request.node) }}
{% endif %}
</div>
</div>
@ -145,6 +149,7 @@
<div class="node-actions">
{{ snippets.permalinks(parent, prev_parent, request.root_node) }}
{{ snippets.actions(parent) }}
{{ snippets.export_node_menu(parent) }}
{%- if children_ids %}
{{ snippets.button_collapse(parent) }}
{% endif -%}

View file

@ -239,6 +239,42 @@
{% endif %}
{% endmacro %}
{% macro export_menu(node) %}
<details class="export-menu">
<summary class="action link">export</summary>
<div class="export-options">
<a href="/api/v1/export/threads/{{ node.id }}.md" class="export-link">markdown</a>
<a href="/api/v1/export/threads/{{ node.id }}.html" class="export-link">html</a>
<a href="/api/v1/export/threads/{{ node.id }}.pdf" class="export-link">pdf</a>
<a href="/api/v1/export/threads/{{ node.id }}.epub" class="export-link">epub</a>
<a href="/api/v1/export/threads/{{ node.id }}.docx" class="export-link">docx</a>
<a href="/api/v1/export/threads/{{ node.id }}.rst" class="export-link">rst</a>
<a href="/api/v1/export/threads/{{ node.id }}.latex" class="export-link">latex</a>
<a href="/api/v1/export/threads/{{ node.id }}.odt" class="export-link">odt</a>
<a href="/api/v1/export/threads/{{ node.id }}.plain" class="export-link">plain text</a>
</div>
</details>
{% endmacro %}
{% macro export_node_menu(node) %}
<details class="export-menu">
<summary class="action link">export</summary>
<div class="export-options">
<a href="/api/v1/export/nodes/{{ node.id }}.md" class="export-link">markdown</a>
<a href="/api/v1/export/nodes/{{ node.id }}.html" class="export-link">html</a>
<a href="/api/v1/export/nodes/{{ node.id }}.pdf" class="export-link">pdf</a>
<a href="/api/v1/export/nodes/{{ node.id }}.epub" class="export-link">epub</a>
<a href="/api/v1/export/nodes/{{ node.id }}.docx" class="export-link">docx</a>
</div>
</details>
{% endmacro %}
{% macro wiki_actions(node) %}
{% if request.namespace.wiki and node.is_root and request.user and request.user.authenticated %}
<a href="/api/v1/nodes/{{ node.id }}/revisions" class="action" target="_blank">history</a>
{% endif %}
{% endmacro %}
{% macro namespace_actions(namespace) %}
{% if request.mode != "embed" %}

View file

@ -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
# ---------------------------------------------------------------------------