feat: provenance + QR codes on every export
Every exported document (PDF, EPUB, DOCX, HTML, markdown, plain, ...)
now points back to its living source on Remarkbox so a printed snapshot
remains traceable years later.
What's injected:
- Top banner: source URI, snapshot ISO timestamp, generator commit hash,
plus a notice the document is a snapshot of a living source.
- QR code (PNG, embedded as data URI) linking back to the canonical URI.
Scannable from print, survives the pandoc image pipeline across
HTML, PDF (via wkhtmltopdf), DOCX, EPUB, ODT.
- Per-reply permalinks: each reply heading hyperlinks its date to the
deep-link permalink (thread-uri#node-id), academic-citation style.
- Footer repeating the source URI + commit hash.
- Namespace exports also hyperlink each chapter title to the live
thread.
Canonical URI = https://{namespace.name}{node.path} — namespace name is
its host on the public web.
New module: remarkbox/lib/provenance.py — header_md, footer_md,
reply_heading_md, qr_png_data_uri, canonical_uri helpers, and a one-shot
build() bundler.
Dep added: segno (pure-Python QR generator, BSD, zero deps).
This commit is contained in:
parent
9e22c0d768
commit
38475eceeb
4 changed files with 251 additions and 10 deletions
|
|
@ -29,6 +29,7 @@ from remarkbox.lib.pandoc import (
|
|||
FILE_EXTENSIONS,
|
||||
BINARY_FORMATS,
|
||||
)
|
||||
from remarkbox.lib import provenance as prov
|
||||
|
||||
from .views import check_namespace_api_access
|
||||
|
||||
|
|
@ -131,8 +132,16 @@ def api_export_thread(request):
|
|||
visibility_filters={"disabled": False},
|
||||
).all()
|
||||
|
||||
# Build provenance bundle so the exported document points back to its
|
||||
# living source on Remarkbox (canonical URI + QR + per-reply permalinks).
|
||||
provenance = prov.build(
|
||||
canonical_uri=prov.canonical_uri_for_node(root),
|
||||
version=request.static_version,
|
||||
kind="thread",
|
||||
)
|
||||
|
||||
# Render tree to markdown
|
||||
md = node_tree_to_markdown(root, nodes)
|
||||
md = node_tree_to_markdown(root, nodes, provenance=provenance)
|
||||
title = root.title or str(root.id)
|
||||
|
||||
if to_format in ("markdown", "gfm", "commonmark"):
|
||||
|
|
@ -201,7 +210,13 @@ def api_export_namespace(request):
|
|||
visibility_filters={"disabled": False},
|
||||
).all()
|
||||
|
||||
md = namespace_to_markdown(namespace, roots, node_fetcher)
|
||||
provenance = prov.build(
|
||||
canonical_uri=prov.canonical_uri_for_namespace(namespace),
|
||||
version=request.static_version,
|
||||
kind="namespace",
|
||||
)
|
||||
|
||||
md = namespace_to_markdown(namespace, roots, node_fetcher, provenance=provenance)
|
||||
title = namespace.description or namespace.name
|
||||
|
||||
if to_format in ("markdown", "gfm", "commonmark"):
|
||||
|
|
@ -284,7 +299,15 @@ def api_export_node(request):
|
|||
# Filter to just the subtree
|
||||
subtree_nodes = [n for n in all_nodes if n.id in descendant_ids]
|
||||
|
||||
md = node_tree_to_markdown(node, subtree_nodes, include_root=True)
|
||||
provenance = prov.build(
|
||||
canonical_uri=prov.canonical_uri_for_node(node),
|
||||
version=request.static_version,
|
||||
kind="subthread",
|
||||
)
|
||||
|
||||
md = node_tree_to_markdown(
|
||||
node, subtree_nodes, include_root=True, provenance=provenance,
|
||||
)
|
||||
title = node.title or "Thread {}".format(str(node.id)[:8])
|
||||
|
||||
if to_format in ("markdown", "gfm", "commonmark"):
|
||||
|
|
|
|||
|
|
@ -277,17 +277,23 @@ def _node_data_as_markdown(node):
|
|||
return (node.data or "")
|
||||
|
||||
|
||||
def node_tree_to_markdown(root_node, nodes, include_root=True):
|
||||
def node_tree_to_markdown(root_node, nodes, include_root=True, provenance=None):
|
||||
"""Render a node tree as a nested markdown document.
|
||||
|
||||
Args:
|
||||
root_node: The root Node object.
|
||||
nodes: Iterable of all Node objects in the tree (flat, any order).
|
||||
include_root: Whether to include the root node content.
|
||||
provenance: Optional dict from `provenance.build(...)` — when present,
|
||||
the document is wrapped in a header banner + QR code, with each
|
||||
reply heading hyperlinking its date to the canonical permalink,
|
||||
and a footer repeating the source URI.
|
||||
|
||||
Returns:
|
||||
Markdown string with the full tree rendered as a document.
|
||||
"""
|
||||
from remarkbox.lib import provenance as prov
|
||||
|
||||
# Build lookup: parent_id -> sorted children
|
||||
children_map = {}
|
||||
node_map = {}
|
||||
|
|
@ -308,6 +314,15 @@ def node_tree_to_markdown(root_node, nodes, include_root=True):
|
|||
|
||||
lines = []
|
||||
|
||||
if provenance:
|
||||
lines.append(prov.header_md(
|
||||
canonical_uri=provenance["canonical_uri"],
|
||||
snapshot_iso=provenance["snapshot_iso"],
|
||||
version=provenance["version"],
|
||||
qr_data_uri=provenance.get("qr_data_uri"),
|
||||
kind=provenance.get("kind", "thread"),
|
||||
))
|
||||
|
||||
# Thread bodies already carry their own H1 (or RST ==== underline that
|
||||
# becomes H1 after conversion). Don't prepend another `# {title}` — it
|
||||
# triples the title when pandoc then also adds a title-block in HTML/PDF.
|
||||
|
|
@ -323,7 +338,10 @@ def node_tree_to_markdown(root_node, nodes, include_root=True):
|
|||
heading_level = min(depth + 2, 6)
|
||||
author = _get_author_name(child)
|
||||
date = child.created_date or ""
|
||||
lines.append("{} {} — {}".format("#" * heading_level, author, date))
|
||||
permalink = prov.permalink_for_node(child) if provenance else None
|
||||
lines.append(prov.reply_heading_md(
|
||||
heading_level, author, date, permalink=permalink,
|
||||
))
|
||||
lines.append("")
|
||||
if child.data:
|
||||
lines.append(_node_data_as_markdown(child))
|
||||
|
|
@ -332,22 +350,44 @@ def node_tree_to_markdown(root_node, nodes, include_root=True):
|
|||
|
||||
_render_children(root_node.id, 0)
|
||||
|
||||
if provenance:
|
||||
lines.append(prov.footer_md(
|
||||
canonical_uri=provenance["canonical_uri"],
|
||||
snapshot_iso=provenance["snapshot_iso"],
|
||||
version=provenance["version"],
|
||||
))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def namespace_to_markdown(namespace, roots, node_fetcher):
|
||||
def namespace_to_markdown(namespace, roots, node_fetcher, provenance=None):
|
||||
"""Render an entire namespace as a markdown book.
|
||||
|
||||
Args:
|
||||
namespace: The Namespace object.
|
||||
roots: Iterable of root Node objects (chapters).
|
||||
node_fetcher: Callable(root_node) -> list of all nodes in that tree.
|
||||
provenance: Optional dict from `provenance.build(...)` — when present,
|
||||
wraps the book with a header banner + QR pointing at the namespace
|
||||
index, hyperlinks every chapter title to the live thread, and
|
||||
hyperlinks every reply date to the live permalink.
|
||||
|
||||
Returns:
|
||||
Markdown string with the full namespace as a document.
|
||||
"""
|
||||
from remarkbox.lib import provenance as prov
|
||||
|
||||
lines = []
|
||||
|
||||
if provenance:
|
||||
lines.append(prov.header_md(
|
||||
canonical_uri=provenance["canonical_uri"],
|
||||
snapshot_iso=provenance["snapshot_iso"],
|
||||
version=provenance["version"],
|
||||
qr_data_uri=provenance.get("qr_data_uri"),
|
||||
kind=provenance.get("kind", "namespace"),
|
||||
))
|
||||
|
||||
# Book title
|
||||
title = namespace.description or namespace.name
|
||||
lines.append("# {}".format(title))
|
||||
|
|
@ -356,9 +396,14 @@ def namespace_to_markdown(namespace, roots, node_fetcher):
|
|||
for root in roots:
|
||||
if root.disabled:
|
||||
continue
|
||||
# Chapter heading
|
||||
# Chapter heading — hyperlink to the live thread when provenance is on
|
||||
chapter_title = root.title or str(root.id)
|
||||
lines.append("## {}".format(chapter_title))
|
||||
if provenance:
|
||||
lines.append("## [{}]({})".format(
|
||||
chapter_title, prov.canonical_uri_for_node(root),
|
||||
))
|
||||
else:
|
||||
lines.append("## {}".format(chapter_title))
|
||||
lines.append("")
|
||||
if root.data:
|
||||
lines.append(_node_data_as_markdown(root))
|
||||
|
|
@ -385,8 +430,9 @@ def namespace_to_markdown(namespace, roots, node_fetcher):
|
|||
heading_level = min(depth + 3, 6)
|
||||
author = _get_author_name(child)
|
||||
date = child.created_date or ""
|
||||
lines.append("{} {} — {}".format(
|
||||
"#" * heading_level, author, date
|
||||
permalink = prov.permalink_for_node(child) if provenance else None
|
||||
lines.append(prov.reply_heading_md(
|
||||
heading_level, author, date, permalink=permalink,
|
||||
))
|
||||
lines.append("")
|
||||
if child.data:
|
||||
|
|
@ -396,6 +442,13 @@ def namespace_to_markdown(namespace, roots, node_fetcher):
|
|||
|
||||
_render(root.id, 0)
|
||||
|
||||
if provenance:
|
||||
lines.append(prov.footer_md(
|
||||
canonical_uri=provenance["canonical_uri"],
|
||||
snapshot_iso=provenance["snapshot_iso"],
|
||||
version=provenance["version"],
|
||||
))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
|
|
|||
161
remarkbox/lib/provenance.py
Normal file
161
remarkbox/lib/provenance.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Provenance for exported documents.
|
||||
|
||||
Every exported snapshot (PDF, EPUB, DOCX, HTML, plain markdown, ...) carries:
|
||||
- a top banner with canonical source URI, snapshot timestamp, generator version
|
||||
- a QR code linking back to the living source on Remarkbox
|
||||
- per-reply permalinks (academic citation style — date hyperlinks to anchor)
|
||||
- a footer repeating source URI + commit hash
|
||||
|
||||
A PDF printed today should still resolve to its living wiki source years from
|
||||
now, scannable from paper. Documents are dead the moment they ship; we keep a
|
||||
return path open.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import segno
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canonical URIs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def canonical_uri_for_namespace(namespace):
|
||||
"""Return the canonical URI of a namespace's living index.
|
||||
|
||||
A namespace's name IS its host (e.g. `meta.remarkbox.com`). This holds even
|
||||
for namespaces fronted only by `my.remarkbox.com/ns/{name}` — the name
|
||||
remains the unique identifier.
|
||||
"""
|
||||
return "https://{}/".format(namespace.name)
|
||||
|
||||
|
||||
def canonical_uri_for_node(node):
|
||||
"""Return the canonical URI of a node's living source.
|
||||
|
||||
Uses the namespace's name as host. Path comes from `node.path`
|
||||
(`/{id}/{slug}` or `/{id}`).
|
||||
"""
|
||||
return "https://{}{}".format(node.root.namespace.name, node.path)
|
||||
|
||||
|
||||
def permalink_for_node(node):
|
||||
"""Return a deep link to a single reply: thread URI plus node-id anchor.
|
||||
|
||||
Replies live inside a thread page, so the deep link is the thread URI with
|
||||
a fragment identifier.
|
||||
"""
|
||||
root = node.root
|
||||
return "https://{}{}#{}".format(root.namespace.name, root.path, node.id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QR codes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def qr_png_data_uri(uri, scale=4, border=2):
|
||||
"""Encode a URI as a PNG QR code wrapped in a data URI.
|
||||
|
||||
PNG (not SVG) because pandoc's downstream renderers — wkhtmltopdf,
|
||||
rsvg-convert paths in DOCX/ODT/EPUB — handle PNG uniformly. A QR code's
|
||||
two-color palette compresses to a few hundred bytes; resolution is fine
|
||||
at print sizes.
|
||||
"""
|
||||
qr = segno.make(uri, error="M")
|
||||
buf = io.BytesIO()
|
||||
qr.save(buf, kind="png", scale=scale, border=border)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
return "data:image/png;base64,{}".format(b64)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown blocks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _utc_now_iso():
|
||||
"""ISO 8601 UTC timestamp, second precision, with `Z` suffix."""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def header_md(canonical_uri, snapshot_iso=None, version="dev",
|
||||
qr_data_uri=None, kind="document"):
|
||||
"""Top-of-document provenance banner + QR code.
|
||||
|
||||
Args:
|
||||
canonical_uri: https://... URI of the living source.
|
||||
snapshot_iso: ISO 8601 timestamp; defaults to UTC now.
|
||||
version: Remarkbox build identifier (short git hash).
|
||||
qr_data_uri: Result of `qr_png_data_uri`; if None, no QR rendered.
|
||||
kind: "thread", "namespace", "subthread" — used in the snapshot caption.
|
||||
|
||||
Returns markdown intended to sit at the very top of a document. Pandoc
|
||||
propagates this verbatim into HTML, then through wkhtmltopdf for PDF.
|
||||
"""
|
||||
if snapshot_iso is None:
|
||||
snapshot_iso = _utc_now_iso()
|
||||
|
||||
lines = [
|
||||
"> **Source:** [{}]({}) ".format(canonical_uri, canonical_uri),
|
||||
"> **Snapshot:** {} ".format(snapshot_iso),
|
||||
"> **Generator:** Remarkbox `{}` ".format(version),
|
||||
">",
|
||||
"> *This is a {} snapshot. The living document lives at the source URI above — it may have been edited, extended, or replied-to since.*".format(kind),
|
||||
"",
|
||||
]
|
||||
if qr_data_uri:
|
||||
lines.append("".format(qr_data_uri))
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def footer_md(canonical_uri, snapshot_iso=None, version="dev"):
|
||||
"""End-of-document provenance footer."""
|
||||
if snapshot_iso is None:
|
||||
snapshot_iso = _utc_now_iso()
|
||||
return "\n".join([
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"**Source:** [{}]({}) ".format(canonical_uri, canonical_uri),
|
||||
"**Snapshot:** {} ".format(snapshot_iso),
|
||||
"**Generator:** Remarkbox `{}`".format(version),
|
||||
"",
|
||||
])
|
||||
|
||||
|
||||
def reply_heading_md(level, author, date, permalink=None):
|
||||
"""Render a reply heading with optional permalink hyperlinking the date.
|
||||
|
||||
Hyperlinking the date keeps academic-citation style readable: the visible
|
||||
text is still `Author — date`, the date itself becomes the anchor.
|
||||
"""
|
||||
if permalink:
|
||||
return "{} {} — [{}]({})".format("#" * level, author, date, permalink)
|
||||
return "{} {} — {}".format("#" * level, author, date)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# One-shot bundle (convenience)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build(canonical_uri, version, kind="document", include_qr=True):
|
||||
"""Build a complete provenance bundle in one call.
|
||||
|
||||
Returns a dict suitable for splatting into `node_tree_to_markdown` or
|
||||
`namespace_to_markdown` as `**provenance`.
|
||||
"""
|
||||
snapshot_iso = _utc_now_iso()
|
||||
qr_data_uri = qr_png_data_uri(canonical_uri) if include_qr else None
|
||||
return {
|
||||
"canonical_uri": canonical_uri,
|
||||
"snapshot_iso": snapshot_iso,
|
||||
"version": version,
|
||||
"qr_data_uri": qr_data_uri,
|
||||
"kind": kind,
|
||||
}
|
||||
|
|
@ -78,3 +78,7 @@ slacker
|
|||
|
||||
# DKIM Signed Email from Python.
|
||||
dkimpy
|
||||
|
||||
# QR codes for export provenance (links printed PDFs back to their living source).
|
||||
# Pure Python, zero deps, BSD license.
|
||||
segno
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue