Preserve full Bubble protocol clipboard format
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled
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:
parent
8feca53a8b
commit
d521ddcbed
11 changed files with 102 additions and 39 deletions
2
SCOPE.md
2
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/<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. |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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. */ }
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 "<script>" in page.clipboard_html
|
||||
assert "<strong>emphasis</strong>" in page.clipboard_html
|
||||
assert page.clipboard_html.endswith("<p>xxx</p>")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue