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:
parent
75bec564b2
commit
46bdf3a217
8 changed files with 258 additions and 0 deletions
26
docs/api.md
26
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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -%}
|
||||
|
|
|
|||
|
|
@ -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" %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue