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

@@ -186,6 +188,8 @@ {% if not page.error %} + {{ page.clipboard_text|json_script:"page-clipboard-text" }} + {{ page.clipboard_html|json_script:"page-clipboard-html" }} {% endif %} {% endblock %} diff --git a/docs/usecases/reichelag-weekly-call.md b/docs/usecases/reichelag-weekly-call.md index 5577b5a..bb7915e 100644 --- a/docs/usecases/reichelag-weekly-call.md +++ b/docs/usecases/reichelag-weekly-call.md @@ -1,7 +1,7 @@ # CSOC-UC-008 — ReichelAg weekly founder call -**Evidence:** user-confirmed workflow, 2026-10-06. Original Bubble behavior was -reported by the user; no fresh browser observation or private notes inspected. +**Evidence:** user-confirmed workflow, 2026-10-06. Clipboard behavior additionally observed through an authenticated Bubble +session on 2026-10-07; private protocol text is excluded from fixtures. **Owner:** CSOC-WP-0008-T03/T05/T08/T09/T10. **Transfer:** pending user review, deployment and real-content reconciliation. @@ -35,9 +35,10 @@ no AI transcription/recording or automated mail delivery is assumed. asset are copied; arbitrary body-linked assets remain a fidelity gap. 3. Open **Edit page** and edit the copy's agenda/protocol by hand. Save and reopen it. The old protocol must remain unchanged. -4. Choose **Copy content to clipboard** on the finished page. Rich-text email - editors receive rendered headings/bullets; plain-text editors receive text - with bullet markers. App navigation, action buttons, metadata frontmatter +4. Choose **Copy content to clipboard** on the finished page. The plain-text payload matches Bubble: title, italic abstractor, + Markdown body and its `xxx` footer, separated by blank lines. Markdown + headings, emphasis and nested bullets are retained. Rich-text email + editors also receive sanitized HTML with the title, subtitle and footer. App navigation, action buttons, metadata frontmatter and source diagnostics are excluded. 5. Paste into the external email draft, check formatting and recipients, then send manually. The app does not contact an email provider or send messages. @@ -60,7 +61,7 @@ Review on the pinned new-app release with a privately nominated latest protocol: | Find the latest entry | Date/title/navigation identifies the correct call | Pending real-content review; no automatic latest-entry selection implemented | | Duplicate | Full protocol preserved; independent new entry | Implemented; synthetic regression evidence | | Prepare/write/save | Hand-edited bullets and notes reopen correctly; source stays unchanged | Implemented; synthetic regression evidence | -| Copy for email | Rendered list/heading formatting or readable plain bullets; no app chrome | Verified with synthetic Playwright clipboard interception; real mail-client review pending | +| Copy for email | Full original title/subtitle/Markdown/footer; no app chrome | Original Bubble clipboard compared exactly with rebuilt export; synthetic exact-payload checks pass; user review pending | | Clipboard blocked | Selected manual text and clear instructions; no false success | Verified with synthetic Playwright clipboard interception; real mail-client review pending | | Paste into your mail client | Useful protocol presentation and working relevant links | Pending your actual mail-client review | | Privacy and continuity | Access stays appropriate; restart/paired recovery succeeds | Access tests exist; live persistence/restore pending | @@ -88,3 +89,19 @@ node IDs. `make check` drives the actual isolated app through duplication, manua editing, reopen, HTML/plain/manual clipboard export and reader/outsider checks. See README.md for setup, reports and extending protection to further cases. This protects established behavior without marking productive transfer accepted. + +## Clipboard fidelity review — 2026-10-07 + +Founder review found the rendered-body export incomplete. Direct read-only +observation of Bubble's clipboard action confirms the exact plain format: +`title + "\n\n" + "*" + abstractor + "*\n\n" + body + "\n\nxxx"`. +For an empty abstractor, omit the subtitle block. The final `xxx` is emitted by +Bubble's action, rather than stored in the protocol body. Preserve this legacy +footer for compatibility. Content-plane serialization's terminal newline is +removed before appending the footer; internal Markdown whitespace is retained. + +The nominated protocol's rebuilt export matches the observed original clipboard +character for character. Only comparison receipts are recorded here. Tests use +synthetic notes and verify exact plain text in rich/plain/manual fallback modes, +sanitized metadata in HTML, and the actual duplicate/edit/reload user journey. +Full gate: `make check`, **97 passed**. Actual email-client review remains T09. diff --git a/scripts/test-copy-content.mjs b/scripts/test-copy-content.mjs index 2532d89..f71639f 100644 --- a/scripts/test-copy-content.mjs +++ b/scripts/test-copy-content.mjs @@ -7,10 +7,14 @@ const browser = await chromium.launch({headless: true}); try { const page = await browser.newPage(); const script = fileURLToPath(new URL('../coulomb_social/apps/spaces/static/spaces/copy-content.js', import.meta.url)); + const expected = "Synthetic protocol\n\n*Synthetic board call*\n\n## Weekly call\n\n- Open point\n - Nested follow-up\n- Completed point\n\n### Protocol\n\nNew notes with **emphasis**.\n\nxxx"; + const exportedHtml = '

Synthetic protocol

Synthetic board call

Weekly call

Protocol

New notes with emphasis.

xxx

'; const fixture = `

+ +

Weekly call

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 "