From 38475eceeb38daf4811d521c07b26396c14865b6 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sat, 25 Apr 2026 06:14:39 -0400 Subject: [PATCH] feat: provenance + QR codes on every export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- remarkbox/api/export.py | 29 ++++++- remarkbox/lib/pandoc.py | 67 +++++++++++++-- remarkbox/lib/provenance.py | 161 ++++++++++++++++++++++++++++++++++++ requirements.py3.txt | 4 + 4 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 remarkbox/lib/provenance.py diff --git a/remarkbox/api/export.py b/remarkbox/api/export.py index 9d9b805..ff2e634 100644 --- a/remarkbox/api/export.py +++ b/remarkbox/api/export.py @@ -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"): diff --git a/remarkbox/lib/pandoc.py b/remarkbox/lib/pandoc.py index 615e185..fe22b3f 100644 --- a/remarkbox/lib/pandoc.py +++ b/remarkbox/lib/pandoc.py @@ -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) diff --git a/remarkbox/lib/provenance.py b/remarkbox/lib/provenance.py new file mode 100644 index 0000000..5c54f3d --- /dev/null +++ b/remarkbox/lib/provenance.py @@ -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("![Scan to visit the living source]({})".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, + } diff --git a/requirements.py3.txt b/requirements.py3.txt index 9a5d848..c264f0b 100644 --- a/requirements.py3.txt +++ b/requirements.py3.txt @@ -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