Preserve full Bubble protocol clipboard format
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680
This commit is contained in:
tegwick 2026-10-07 00:26:18 +02:00
parent 8feca53a8b
commit d521ddcbed
11 changed files with 102 additions and 39 deletions

View file

@ -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/<slug>/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. |

View file

@ -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 = "<p>" + escape(display_title) + "</p>"
if abstractor:
clipboard_html += "<p><em>" + escape(abstractor) + "</em></p>"
clipboard_html += html + "<p>xxx</p>"
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:

View file

@ -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. */ }

View file

@ -174,6 +174,8 @@
</div>
{% else %}
<div id="page-content" class="card md-body" style="margin-top:0;">
<h1>{{ page.title }}</h1>
{% if page.abstractor %}<p><em>{{ page.abstractor }}</em></p>{% endif %}
{{ page.html|safe }}
</div>
<p class="content-meta muted">
@ -186,6 +188,8 @@
</div>
</div>
{% if not page.error %}
{{ page.clipboard_text|json_script:"page-clipboard-text" }}
{{ page.clipboard_html|json_script:"page-clipboard-html" }}
<script src="{% static 'spaces/copy-content.js' %}" defer></script>
{% endif %}
{% endblock %}

View file

@ -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.

View file

@ -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 = '<p>Synthetic protocol</p><p><em>Synthetic board call</em></p><h2>Weekly call</h2><ul><li>Open point<ul><li>Nested follow-up</li></ul></li><li>Completed point</li></ul><h3>Protocol</h3><p>New notes with <strong>emphasis</strong>.</p><p>xxx</p>';
const fixture = `<nav>PRIVATE APP CHROME</nav>
<button id="copy-page-content">Copy content to clipboard</button>
<p id="copy-content-status" role="status"></p>
<textarea id="copy-content-manual" hidden></textarea>
<script type="application/json" id="page-clipboard-text">${JSON.stringify(expected)}</script>
<script type="application/json" id="page-clipboard-html">${JSON.stringify(exportedHtml)}</script>
<div id="page-content"><h1>Weekly call</h1><ul><li>Open point<ul><li>Nested follow-up</li></ul></li><li>Completed point</li></ul><h2>Protocol</h2><p>New notes with <strong>emphasis</strong>.</p></div>`;
// 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);

View file

@ -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);

View file

@ -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 <script>title</script> & board"
abstractor = "Call <img src=x onerror=alert(1)>"
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 "<script>" not in page.clipboard_html
assert "<img src=x" not in page.clipboard_html
assert "&lt;script&gt;" in page.clipboard_html
assert "<strong>emphasis</strong>" in page.clipboard_html
assert page.clipboard_html.endswith("<p>xxx</p>")

View file

@ -12,5 +12,6 @@
"writer_subject": "usecase-writer",
"reader_subject": "usecase-reader",
"outsider_subject": "usecase-outsider",
"tenant": "tenant:synthetic-usecases"
"tenant": "tenant:synthetic-usecases",
"abstractor": "Synthetic founder and board call"
}

View file

@ -37,7 +37,8 @@ def test_weekly_call_through_real_ui(tmp_path, settings, request):
space = create_space(actors["writer"], title=scenario["space_title"],
slug=scenario["space_slug"])
put_space_page(actors["writer"], space, scenario["previous_slug"],
title=scenario["previous_title"], body=scenario["previous_body"])
title=scenario["previous_title"], body=scenario["previous_body"],
abstractor=scenario["abstractor"])
SpaceMembership.objects.create(space=space, member=actors["reader"], role="viewer")
server = request.getfixturevalue("live_server")
result = subprocess.run(

View file

@ -246,7 +246,7 @@ Validation: **86 tests passed**; Ruff passes changed service/view/test files;
```task
id: CSOC-WP-0008-T10
status: done
status: progress
priority: high
state_hub_task_id: "41334b54-0c99-5c61-9cdf-16231516882b"
```
@ -469,3 +469,12 @@ content and full duplicate/edit/clipboard/mail-client walkthrough. T05 retains
Visual delivery and parent/privacy fidelity. UI latest-change ordering/framework
work is explicitly CSOC-WP-0011, not an assumed parity feature.
Receipt: docs/release/reichelag-imported-2026-10-06.md.
## Clipboard fidelity correction — 2026-10-07
User review found missing original protocol content. Authenticated observation of
the Bubble copy action confirms title + two newlines + italic abstractor + two
newlines + unchanged Markdown body + `\n\nxxx` legacy footer. T10 reopened to
preserve that exact plain payload, including fallback, while retaining sanitized
rich HTML for email. Private notes are not regression fixtures. T09 remains
pending the user's clipboard/mail-client review.