From d521ddcbed3185567c88938e0bd9b5bb1c5df2ab Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 7 Oct 2026 00:26:18 +0200 Subject: [PATCH] Preserve full Bubble protocol clipboard format Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680 --- SCOPE.md | 2 +- coulomb_social/apps/spaces/content.py | 25 ++++++++++++++- .../apps/spaces/static/spaces/copy-content.js | 31 +++++-------------- coulomb_social/templates/spaces/detail.html | 4 +++ docs/usecases/reichelag-weekly-call.md | 29 +++++++++++++---- scripts/test-copy-content.mjs | 9 ++++-- scripts/test-usecase-browser.mjs | 5 +-- tests/test_content.py | 19 ++++++++++++ .../fixtures/reichelag-weekly-call.json | 3 +- tests/usecases/test_reichelag_browser.py | 3 +- ...-WP-0008-production-transfer-acceptance.md | 11 ++++++- 11 files changed, 102 insertions(+), 39 deletions(-) diff --git a/SCOPE.md b/SCOPE.md index 50c4136..e55b4c2 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -22,7 +22,7 @@ favorites, payments and real-time collaboration are later capabilities. | Identity | OIDC/PKCE login, logout, JIT local Member linked to user-engine; issuer+subject identity, session diagnostics and assurance handling. Development claims login is restricted to DEBUG with OIDC disabled. `apps/identity/`, identity tests. | | Registration entry | Configured NetKingdom registration redirect; this app does not issue passwords or send verification mail. CSOC-IN-0001 is closed with 2026-08-14 Case B evidence. | | Spaces | Tenant-filtered list/detail, member-facing create/update/archive, Title, Abstractor (`description`) and Visual metadata. `apps/spaces/services.py`, `tests/test_space_crud.py`. | -| Pages | Markdown/frontmatter list, render, create/update/delete, independent copy and transfer with source/destination access checks. Index cannot be deleted or transferred. Separate clipboard copy exports rendered HTML/plain text for manual email distribution. `pageops.py`, PageOps tests. | +| Pages | Markdown/frontmatter list, render, create/update/delete, independent copy and transfer with source/destination access checks. Index cannot be deleted or transferred. Separate clipboard copy exports the full title, italic subtitle, original Markdown and legacy Bubble `xxx` footer as plain text, plus sanitized rich HTML, for manual email distribution. `pageops.py`, PageOps tests. | | Content sources | Local `CONTENT_ROOT/spaces//pages` and assets; optional Forgejo read/edit fallback, cache refresh and push webhook. Markdown is sanitized on render. Local writes do not automatically commit/push to Forgejo. | | Import | `import_content_tree` for one ADR-0004 tree; `import_content_corpus` for a preflighted JSON batch with explicit existing tenant+issuer+subject membership mappings. Dry-run, replacement opt-in, per-space staging/rollback and aggregate counts. Synthetic multi-space regression coverage; one historical private-space rehearsal. | | Delivery preparation | Docker/Gunicorn packaging, SQLite development / PostgreSQL URL support, migrations, health endpoint and deployment/runbook docs. Actual Helm values and cluster hosting belong to railiance-apps. | diff --git a/coulomb_social/apps/spaces/content.py b/coulomb_social/apps/spaces/content.py index ef7031c..3716e1f 100644 --- a/coulomb_social/apps/spaces/content.py +++ b/coulomb_social/apps/spaces/content.py @@ -65,6 +65,9 @@ class RenderedPage: html: str source: str error: str | None = None + clipboard_text: str = "" + clipboard_html: str = "" + abstractor: str = "" def normalize_page_slug(page: str | None) -> str: @@ -130,13 +133,33 @@ def load_space_page(space: Space, page: str | None = None) -> RenderedPage: str(meta.get("title") or "").strip() or _title_from_markdown(body or fetched.text, space.title) ) - html = render_markdown(body if meta else fetched.text) + markdown_body = body if meta else fetched.text + abstractor = str(meta.get("abstractor") or "") + # Bubble's copy action exports the title, italic abstractor, unchanged + # Markdown body and its legacy footer. Keep this separate from rendering: + # walking the DOM loses Markdown syntax, whitespace and source links. + from html import escape + + heading = display_title + "\n\n" + if abstractor: + heading += "*" + abstractor + "*\n\n" + # The content plane adds one terminal LF when serializing a page. + # It is a storage separator, absent from Bubble's clipboard body. + clipboard_text = heading + markdown_body.removesuffix("\n") + "\n\nxxx" + html = render_markdown(markdown_body) + clipboard_html = "

" + escape(display_title) + "

" + if abstractor: + clipboard_html += "

" + escape(abstractor) + "

" + clipboard_html += html + "

xxx

" return RenderedPage( slug=page_slug, path=path, title=display_title, html=html, source=fetched.source, + clipboard_text=clipboard_text, + clipboard_html=clipboard_html, + abstractor=abstractor, error=None, ) except ContentFetchError as exc: diff --git a/coulomb_social/apps/spaces/static/spaces/copy-content.js b/coulomb_social/apps/spaces/static/spaces/copy-content.js index 2104176..97b67d5 100644 --- a/coulomb_social/apps/spaces/static/spaces/copy-content.js +++ b/coulomb_social/apps/spaces/static/spaces/copy-content.js @@ -1,4 +1,4 @@ -/* Copy only the rendered page body for manual distribution, never app chrome. */ +/* Export the full protocol in the original Bubble format, never app chrome. */ (() => { const content = document.getElementById("page-content"); const button = document.getElementById("copy-page-content"); @@ -6,27 +6,11 @@ const manual = document.getElementById("copy-content-manual"); if (!content || !button || !status || !manual) return; - function plainText(node, depth = 0) { - if (node.nodeType === Node.TEXT_NODE) return node.textContent; - if (node.nodeType !== Node.ELEMENT_NODE) return ""; - const tag = node.tagName.toLowerCase(); - if (tag === "br") return "\n"; - if (tag === "img") return node.getAttribute("alt") || ""; - if (tag === "ul" || tag === "ol") { - const start = Number(node.getAttribute("start")) || 1; - return "\n" + Array.from(node.children).map((item, index) => { - const marker = tag === "ol" ? `${start + index}. ` : "- "; - const body = Array.from(item.childNodes).map(child => plainText(child, depth + 1)) - .join("").trim(); - return " ".repeat(depth) + marker + body + "\n"; - }).join("") + "\n"; - } - const text = Array.from(node.childNodes).map(child => plainText(child, depth)).join(""); - if (/^(p|h[1-6]|blockquote|pre|table|div)$/.test(tag)) return text.trim() + "\n\n"; - if (tag === "tr") return text + "\n"; - if (tag === "td" || tag === "th") return text.trim() + "\t"; - return text; - } + const textPayload = document.getElementById("page-clipboard-text"); + const htmlPayload = document.getElementById("page-clipboard-html"); + if (!textPayload || !htmlPayload) return; + const text = JSON.parse(textPayload.textContent); + const html = JSON.parse(htmlPayload.textContent); function showManualCopy(text) { manual.value = text; @@ -37,7 +21,6 @@ } button.addEventListener("click", async () => { - const text = plainText(content).replace(/\n{3,}/g, "\n\n").trim(); manual.hidden = true; button.disabled = true; status.textContent = "Copying…"; @@ -49,7 +32,7 @@ try { await navigator.clipboard.write([new ClipboardItem({ "text/plain": new Blob([text], {type: "text/plain"}), - "text/html": new Blob([content.innerHTML], {type: "text/html"}), + "text/html": new Blob([html], {type: "text/html"}), })]); richCopied = true; } catch (_) { /* Try plain text before asking the user to copy manually. */ } diff --git a/coulomb_social/templates/spaces/detail.html b/coulomb_social/templates/spaces/detail.html index 3f84e1c..c0f3e6f 100644 --- a/coulomb_social/templates/spaces/detail.html +++ b/coulomb_social/templates/spaces/detail.html @@ -174,6 +174,8 @@ {% else %}
+

{{ page.title }}

+ {% if page.abstractor %}

{{ page.abstractor }}

{% endif %} {{ page.html|safe }}

Synthetic protocol

Synthetic board call

Weekly call

Protocol

New notes with emphasis.

xxx

'; const fixture = `

+ +

Weekly call

  • Open point
    • Nested follow-up
  • Completed point

Protocol

New notes with emphasis.

`; // A secure origin enables the browser's native ClipboardItem implementation. await page.route('https://clipboard.test/', route => route.fulfill({ @@ -49,11 +53,12 @@ try { selected: document.getElementById('copy-content-manual').selectionEnd, })); const text = mode === 'denied' ? result.manual : result.receipts[0].text; + assert.equal(text, expected); assert.match(text, /Weekly call/); assert.match(text, /- Open point/); - assert.match(text, / - Nested follow-up/); + assert.match(text, / - Nested follow-up/); assert.match(text, /- Completed point/); - assert.match(text, /New notes with emphasis/); + assert.match(text, /New notes with \*\*emphasis\*\*/); assert.doesNotMatch(text, /PRIVATE APP CHROME|Copy content to clipboard/); if (mode === 'denied') { assert.equal(result.receipts.length, 0); diff --git a/scripts/test-usecase-browser.mjs b/scripts/test-usecase-browser.mjs index 3963bc0..9497363 100644 --- a/scripts/test-usecase-browser.mjs +++ b/scripts/test-usecase-browser.mjs @@ -84,9 +84,10 @@ try { const text = mode === 'denied' ? result.manual : result.receipts[0].text; assert.match(text, /Next weekly call/); assert.match(text, /- Follow-up point/); - assert.match(text, / - Nested detail/); + assert.match(text, / - Nested detail/); assert.match(text, /- Another agenda item/); - assert.match(text, /New notes with emphasis/); + assert.match(text, /New notes with \*\*emphasis\*\*/); + assert.equal(text, scenario.next_title + "\n\n*" + scenario.abstractor + "*\n\n" + scenario.next_body.replace(/\n$/, "") + "\n\nxxx"); assert.doesNotMatch(text, /Edit page|Duplicate page|Source:|content-plane|Sign out/); if (mode === 'denied') { assert.equal(result.receipts.length, 0); diff --git a/tests/test_content.py b/tests/test_content.py index c38cd75..97daf93 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -111,3 +111,22 @@ def test_space_detail_renders_content(client, settings): assert r.status_code == 200 assert b"From Forgejo" in r.content assert b"Visible body" in r.content + + +@pytest.mark.django_db +def test_clipboard_preserves_full_source_and_escapes_metadata(tmp_path, settings): + from coulomb_social.apps.spaces import pageops + + settings.CONTENT_ROOT = str(tmp_path) + title = "Synthetic & board" + abstractor = "Call " + body = "## Weekly call\n\n- First point\n - Detail\n\nNotes with **emphasis**." + pageops.write_page("synthetic", "protocol", title=title, + abstractor=abstractor, body=body) + page = load_space_page(Space(slug="synthetic", title="Synthetic"), "protocol") + assert page.clipboard_text == title + "\n\n*" + abstractor + "*\n\n" + body + "\n\nxxx" + assert "