From 12f2b82030810ce8268ba0951bc9aa6aa61bf0f0 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Mon, 29 Jun 2026 17:48:48 -0700 Subject: [PATCH 1/3] Add datasette-paper embed provider for FEC candidates Implements a paper_embed_provider so an FEC candidate renders as a rich inline pill and read-only block card inside a paper document. - datasette_libfec/paper.py: provider descriptor (kind=libfec-candidate, ref_prefixes, /-menu source, vite frontend_assets unwrapped to bare URLs). - frontend/src/paper_embed.ts: client-side provider (matchRef/matchUrl/ resolve/mount/picker/search). Fetches Datasette's native /{db}/libfec_candidates.json with the viewer cookie; leak discipline maps 403->denied, missing->not_found. Stored ref is reordered to /-/libfec/candidate/{db}/{id} so it sits under a static ref_prefix (the real page URL leads with a variable db segment). - frontend/vite.config.ts: paper-embed entry + preserveEntrySignatures "allow-extension" so the provider's `export default` survives the app build (default false tree-shakes it away). - pyproject.toml: datasette-libfec[paper] optional extra pinning datasette-paper>=0.0.2a3 via the sibling wheel. Verified live: both inline pills and the block card render against the fixture db; native table API + /-/databases.json shapes confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013ikxoxihTC15haHeknRcgg --- datasette_libfec/__init__.py | 3 + datasette_libfec/paper.py | 82 +++++++++ frontend/src/paper_embed.ts | 323 +++++++++++++++++++++++++++++++++++ frontend/vite.config.ts | 8 + pyproject.toml | 12 ++ 5 files changed, 428 insertions(+) create mode 100644 datasette_libfec/paper.py create mode 100644 frontend/src/paper_embed.ts diff --git a/datasette_libfec/__init__.py b/datasette_libfec/__init__.py index 5fa776c..945bf2c 100644 --- a/datasette_libfec/__init__.py +++ b/datasette_libfec/__init__.py @@ -14,6 +14,9 @@ ) from .router import router, LIBFEC_ACCESS_NAME, LIBFEC_WRITE_NAME +# datasette-paper third-party embed provider (no-op unless paper is installed). +from .paper import paper_embed_provider # noqa: F401 + _ = ( routes_rss, routes_export, diff --git a/datasette_libfec/paper.py b/datasette_libfec/paper.py new file mode 100644 index 0000000..9ee7eca --- /dev/null +++ b/datasette_libfec/paper.py @@ -0,0 +1,82 @@ +"""datasette-paper integration: a ``paper_embed_provider`` so an FEC candidate +can be referenced/embedded inside a paper document. + +Paper resolves and renders embeds **entirely client-side** (see +``frontend/src/paper_embed.ts``): the bundle claims +``/-/libfec/candidate/{db}/{candidate_id}`` refs, fetches its own data from +Datasette's native table JSON API (``/{db}/libfec_candidates.json``) with the +viewer's cookie, and owns leak discipline. The backend's only job is to +*describe* this provider — its stable ``kind``, the ref namespace it owns, the +``/``-menu source, and where its JS/CSS bundle lives — so paper can lazy-load +that bundle on demand. There is no server-side resolve/render/search. + +Why the ref is reordered (``/-/libfec/candidate/{db}/{id}``) rather than the +literal page path (``/{db}/-/libfec/candidate/{id}``): every libfec URL leads +with a *variable* ``{database}`` segment, and paper claims a stored ref for +lazy-loading by ``ref.startswith(prefix)`` (server-side ``provider_for_ref`` + +the frontend manifest). A literal-path ref could therefore never sit under a +static ``ref_prefix``, so existing embeds would not re-inject this bundle on +page reload. Reordering puts the libfec namespace first, giving a stable static +prefix; the bundle's ``matchUrl`` transforms the real pasted page URL into this +ref, and resolve/mount rebuild the real page URL for the pill/card link. + +The hook is a no-op unless datasette-paper is installed (it owns the +``paper_embed_provider`` spec); when absent, nothing calls this. +""" + +from __future__ import annotations + +from datasette import hookimpl +from datasette_vite import vite_css_urls, vite_js_urls + +# Vite entry that defines the read-only card + default-exports the paper provider. +_PAPER_EMBED_ENTRY = "src/paper_embed.ts" + + +class LibfecCandidateEmbedProvider: + """Describes the FEC-candidate embed for datasette-paper's editor. + + ``kind`` must equal the bundle's default-exported provider ``kind``. The + actual resolve/render/search all live in that bundle. + """ + + kind = "libfec-candidate" + label = "FEC candidate" + # Stored refs are ``/-/libfec/candidate/{db}/{candidate_id}`` — all under + # this namespace. Lets paper inject our bundle for a doc's embeds before + # running our matchRef (see the module docstring for why it's reordered). + ref_prefixes = ["/-/libfec/candidate/"] + # Mirrors the bundle's picker() source so the `/` menu can list it before + # the bundle loads; picking it injects the bundle, then runs its search(). + sources = [ + { + "id": "libfec-candidate", + "label": "FEC candidate", + "icon": "person-vcard", + "mode": "block", + }, + ] + + def frontend_assets(self, datasette): + # Paper's embed loader does `import(url)` over a list of *string* URLs + # (see embedProviders.ts); vite_js_urls returns `{"url", "module"}` + # dicts (shaped for a script-tag consumer), so unwrap to the bare URL. + # vite_css_urls already returns plain href strings. + js = vite_js_urls( + datasette=datasette, + entrypoint=_PAPER_EMBED_ENTRY, + plugin_package="datasette_libfec", + ) + return { + "js": [u["url"] if isinstance(u, dict) else u for u in js], + "css": vite_css_urls( + datasette=datasette, + entrypoint=_PAPER_EMBED_ENTRY, + plugin_package="datasette_libfec", + ), + } + + +@hookimpl +def paper_embed_provider(datasette): + return LibfecCandidateEmbedProvider() diff --git a/frontend/src/paper_embed.ts b/frontend/src/paper_embed.ts new file mode 100644 index 0000000..e3b9c81 --- /dev/null +++ b/frontend/src/paper_embed.ts @@ -0,0 +1,323 @@ +/** + * datasette-paper embed integration for datasette-libfec. + * + * `export default`s a paper embed provider for the `libfec-candidate` kind: + * paper `import()`s this bundle on demand and registers the provider for us + * (see the descriptor in datasette_libfec/paper.py and + * docs/EMBED_PROVIDERS.md in datasette-paper). + * + * Everything is client-side. resolve/search/mount fetch Datasette's *native* + * table JSON API (`/{db}/libfec_candidates.json`) with the viewer's `ds_actor` + * cookie, so per-viewer leak discipline is Datasette's table permissions plus + * ours: a candidate in a db/table the viewer can't see yields `denied` / + * `not_found` / no search hit — never a leaked name. + * + * Ref shape: stored refs are `/-/libfec/candidate/{db}/{candidate_id}` + * (libfec namespace first so it sits under a static `ref_prefix` — the real + * page URL `/{db}/-/libfec/candidate/{id}` leads with a variable db segment). + * No external imports / no CSS import on purpose: a single standalone chunk + * sidesteps datasette-vite's base-prefixed-asset 404s for code-split bundles. + */ + +// bootstrap-icons/person-vcard — inline pill + block-card header icon. Static +// markup only (paper renders it as raw HTML, unsanitized — never interpolate +// resource data into it). +const VCARD_ICON = + ''; + +const REF_RE = /^\/-\/libfec\/candidate\/([^/]+)\/([^/]+)\/?$/; +const URL_RE = /^\/([^/]+)\/-\/libfec\/candidate\/([^/]+)\/?$/; + +interface CandidateRow { + candidate_id?: string; + name?: string | null; + party_affiliation?: string | null; + office?: string | null; + state?: string | null; + district?: string | null; + cycle?: number | null; + principal_campaign_committee?: string | null; +} + +interface Parsed { + db: string; + candidateId: string; +} + +function parseRef(ref: string): Parsed | null { + const m = REF_RE.exec(ref); + if (!m) return null; + const [, db, cid] = m; + if (db === undefined || cid === undefined) return null; + return { db: decodeURIComponent(db), candidateId: decodeURIComponent(cid) }; +} + +/** Real libfec page URL for the pill/card header link (cycle optional). */ +function pageHref(p: Parsed, cycle?: number | null): string { + const base = `/${encodeURIComponent(p.db)}/-/libfec/candidate/${encodeURIComponent(p.candidateId)}`; + return cycle != null ? `${base}?cycle=${cycle}` : base; +} + +const OFFICE_LABELS: Record = { + H: 'House', + S: 'Senate', + P: 'President', +}; + +function officeLabel(row: CandidateRow): string { + const code = (row.office || '').toUpperCase(); + const name = OFFICE_LABELS[code] || code; + if (!name) return ''; + if (code === 'H' && row.district) { + const d = String(row.district).replace(/^0+/, '') || '0'; + return `${name}, ${row.state || ''}-${d}`.trim(); + } + if (row.state) return `${name}, ${row.state}`; + return name; +} + +/** Normalize Datasette's table JSON (1.x `{rows:[...]}` or `_shape=array`). */ +function rowsOf(j: unknown): CandidateRow[] { + if (Array.isArray(j)) return j as CandidateRow[]; + if (j && typeof j === 'object' && Array.isArray((j as { rows?: unknown[] }).rows)) { + return (j as { rows: CandidateRow[] }).rows; + } + return []; +} + +/** + * Fetch the most-recent-cycle row for one candidate via Datasette's native + * table API. Returns the row, `"denied"` (403), or null (missing / error). + */ +async function fetchCandidate(p: Parsed): Promise { + const url = + `/${encodeURIComponent(p.db)}/libfec_candidates.json` + + `?candidate_id__exact=${encodeURIComponent(p.candidateId)}` + + `&_sort_desc=cycle&_size=1&_shape=array`; + let res: Response; + try { + res = await fetch(url, { headers: { Accept: 'application/json' } }); + } catch { + return null; + } + if (res.status === 403) return 'denied'; // never a label here + if (!res.ok) return null; + let j: unknown; + try { + j = await res.json(); + } catch { + return null; + } + return rowsOf(j)[0] ?? null; +} + +// --- types mirrored from paper's embed provider interface -------------------- + +type ResolveResult = + | { status: 'ok'; kind: string; label: string; href: string; icon?: string } + | { status: 'denied' } + | { status: 'not_found' } + | null; + +interface PaperEmbedContext { + ref: string; + mode: string; +} + +interface PickerSource { + id: string; + label: string; + icon?: string; + mode?: string; +} + +interface SearchHit { + ref: string; + label: string; + kind?: string; + detail?: string; +} + +// --- read-only card ---------------------------------------------------------- + +function fact(label: string, value: string): HTMLElement { + const row = document.createElement('div'); + row.style.cssText = 'display:flex;gap:0.5rem;font-size:0.85rem;line-height:1.5;'; + const k = document.createElement('span'); + k.textContent = label; + k.style.cssText = 'color:#6b7280;min-width:5.5rem;flex:0 0 auto;'; + const v = document.createElement('span'); + v.textContent = value; // text node — XSS-safe + v.style.cssText = 'color:#111827;font-weight:500;'; + row.append(k, v); + return row; +} + +function renderCard(host: HTMLElement, row: CandidateRow, p: Parsed): void { + host.replaceChildren(); + const card = document.createElement('div'); + card.style.cssText = 'padding:0.5rem 0.25rem;display:flex;flex-direction:column;gap:0.25rem;'; + + const name = document.createElement('div'); + name.textContent = row.name || p.candidateId; + name.style.cssText = 'font-size:1rem;font-weight:600;color:#111827;'; + card.appendChild(name); + + const office = officeLabel(row); + if (office) card.appendChild(fact('Office', office)); + if (row.party_affiliation) card.appendChild(fact('Party', row.party_affiliation)); + if (row.cycle != null) card.appendChild(fact('Cycle', String(row.cycle))); + if (row.principal_campaign_committee) + card.appendChild(fact('Committee', row.principal_campaign_committee)); + card.appendChild(fact('Candidate ID', row.candidate_id || p.candidateId)); + + host.appendChild(card); +} + +function renderMessage(host: HTMLElement, text: string): void { + host.replaceChildren(); + const el = document.createElement('div'); + el.textContent = text; + el.style.cssText = 'padding:0.5rem 0.25rem;color:#6b7280;font-size:0.9rem;'; + host.appendChild(el); +} + +// --- Paper embed provider (paper import()s this bundle + registers it) -------- + +const provider = { + kind: 'libfec-candidate', + + /** Claim a stored ref (checked before paper's native .json resolution). */ + matchRef(ref: string): boolean { + return REF_RE.test(ref); + }, + + /** Claim a pasted same-origin candidate page URL → the reordered ref. */ + matchUrl(url: URL): string | null { + const m = URL_RE.exec(url.pathname); + if (!m) return null; + const [, db, cid] = m; + if (db === undefined || cid === undefined) return null; + return `/-/libfec/candidate/${db}/${cid}`; + }, + + /** Inline-pill identity. Leak discipline: denied/missing never yield a label. */ + async resolve(ref: string): Promise { + const p = parseRef(ref); + if (!p) return { status: 'not_found' }; + const row = await fetchCandidate(p); + if (row === 'denied') return { status: 'denied' }; // never a label here + if (!row) return { status: 'not_found' }; + return { + status: 'ok', + kind: 'libfec-candidate', + label: row.name || p.candidateId, + href: pageHref(p, row.cycle), + icon: VCARD_ICON, + }; + }, + + /** Block card body. Paper owns the header; we fill `host` with the card. */ + mount(host: HTMLElement, ctx: PaperEmbedContext): () => void { + let cancelled = false; + const p = parseRef(ctx.ref); + if (!p) { + renderMessage(host, 'Invalid candidate reference'); + return () => {}; + } + renderMessage(host, 'Loading candidate…'); + void (async () => { + const row = await fetchCandidate(p); + if (cancelled) return; + if (row === 'denied') { + renderMessage(host, "You don't have access to this candidate"); + return; + } + if (!row) { + renderMessage(host, 'Candidate not found'); + return; + } + renderCard(host, row, p); + })(); + return () => { + cancelled = true; + host.replaceChildren(); + }; + }, + + /** Browsable `/`-menu source — mirrored by `sources` in paper.py so it shows + * before this bundle loads. */ + picker(): PickerSource { + return { id: 'libfec-candidate', label: 'FEC candidate', icon: 'person-vcard', mode: 'block' }; + }, + + /** + * Viewer-filtered candidates matching `q`, fanned out across the databases + * the viewer can see. Datasette's per-table permissions gate each db, so a + * db/table the viewer can't read just yields no hits (no leak). + */ + async search(q: string, limit: number): Promise { + const query = (q || '').trim(); + if (!query) return []; + let dbs: string[]; + try { + const res = await fetch('/-/databases.json', { + headers: { Accept: 'application/json' }, + }); + if (!res.ok) return []; + const list = (await res.json()) as Array<{ name?: string }>; + dbs = list.map((d) => d.name).filter((n): n is string => !!n && !n.startsWith('_')); + } catch { + return []; + } + + const perDb = Math.max(limit, 5); + const hits: SearchHit[] = []; + await Promise.all( + dbs.map(async (db) => { + const url = + `/${encodeURIComponent(db)}/libfec_candidates.json` + + `?name__contains=${encodeURIComponent(query)}` + + `&_sort=name&_size=${perDb}&_shape=array`; + let res: Response; + try { + res = await fetch(url, { headers: { Accept: 'application/json' } }); + } catch { + return; + } + if (!res.ok) return; // 403/404 (no perm / no table) → skip, no leak + let j: unknown; + try { + j = await res.json(); + } catch { + return; + } + const seen = new Set(); + for (const row of rowsOf(j)) { + const cid = row.candidate_id; + if (!cid || seen.has(cid)) continue; // de-dup across cycles + seen.add(cid); + const office = officeLabel(row); + hits.push({ + ref: `/-/libfec/candidate/${encodeURIComponent(db)}/${encodeURIComponent(cid)}`, + kind: 'libfec-candidate', + label: row.name || cid, + detail: [office, row.party_affiliation].filter(Boolean).join(' · '), + }); + } + }) + ); + + const qLow = query.toLowerCase(); + hits.sort((a, b) => { + const al = a.label.toLowerCase(); + const bl = b.label.toLowerCase(); + const aStarts = al.startsWith(qLow) ? 0 : 1; + const bStarts = bl.startsWith(qLow) ? 0 : 1; + return aStarts - bStarts || al.localeCompare(bl); + }); + return hits.slice(0, limit); + }, +}; + +export default provider; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 661e9d3..dc811ba 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -42,6 +42,12 @@ export default defineConfig({ outDir: "../datasette_libfec", assetsDir: "static/gen", rollupOptions: { + // App builds default to `preserveEntrySignatures: false`, which strips an + // entry's exports — that tree-shakes away the paper-embed provider's + // `export default` (paper reads `import(url).default`). Keep signatures so + // the provider survives. The view entries have no exports, so this is a + // no-op for them. + preserveEntrySignatures: "allow-extension", input: { index: "src/index_view.ts", import: "src/import_view.ts", @@ -54,6 +60,8 @@ export default defineConfig({ filing_day: "src/filing_day_view.ts", alerts: "src/alerts_view.ts", alert_detail: "src/alert_detail_view.ts", + // datasette-paper embed provider (FEC candidate card + inline pill). + "paper-embed": "src/paper_embed.ts", }, }, }, diff --git a/pyproject.toml b/pyproject.toml index a795065..85ec022 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,18 @@ alerts = [ "datasette-alerts>=0.0.1a8", ] +[project.optional-dependencies] +# `datasette-libfec[paper]` adds datasette-paper so an FEC candidate can be +# pasted/embedded as a rich block inside a paper document (see paper.py + the +# frontend paper-embed target). datasette-paper isn't on PyPI yet, so it's +# resolved from the sibling checkout's built wheel via [tool.uv.sources] below. +paper = [ + "datasette-paper>=0.0.2a3", +] + +[tool.uv.sources] +datasette-paper = { path = "../datasette-paper/dist/datasette_paper-0.0.2a3-py3-none-any.whl" } + [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" From f5642ea620fc8ba0fbb3f2bb68208bf82b160e0f Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Mon, 6 Jul 2026 16:46:59 -0700 Subject: [PATCH 2/3] Don't reference local datasette-paper wheel in committed config The [tool.uv.sources] path pointed at a sibling checkout's wheel that doesn't exist in CI (or fresh clones), so `uv run pytest` failed at resolve time before any test ran. The paper extra is optional and the plugin is a no-op without datasette-paper, so drop the local path source and document how to supply the wheel via --find-links for local dev. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 85ec022..83d8f47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,15 +33,19 @@ alerts = [ [project.optional-dependencies] # `datasette-libfec[paper]` adds datasette-paper so an FEC candidate can be # pasted/embedded as a rich block inside a paper document (see paper.py + the -# frontend paper-embed target). datasette-paper isn't on PyPI yet, so it's -# resolved from the sibling checkout's built wheel via [tool.uv.sources] below. +# frontend paper-embed target). The plugin itself is a no-op without it, so the +# default install (and CI) never needs this extra. +# +# datasette-paper isn't on PyPI yet, so this extra only resolves once it's +# published or supplied locally, e.g. from a sibling checkout's built wheel: +# uv sync --extra paper \ +# --find-links ../datasette-paper/dist +# Keep the local wheel path out of committed config so it doesn't break +# checkouts / CI that lack the sibling directory. paper = [ "datasette-paper>=0.0.2a3", ] -[tool.uv.sources] -datasette-paper = { path = "../datasette-paper/dist/datasette_paper-0.0.2a3-py3-none-any.whl" } - [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" From 2eb6f6d11e24344ea062df1a4088ddfee02256f5 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Mon, 6 Jul 2026 17:07:03 -0700 Subject: [PATCH 3/3] Fix paper extra comment: datasette-paper is on PyPI The extra resolves from PyPI (datasette-paper 0.0.2a3+ is published); it's a prerelease, so a fresh checkout may need --prerelease=allow. No local wheel. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 83d8f47..da43192 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,14 +34,9 @@ alerts = [ # `datasette-libfec[paper]` adds datasette-paper so an FEC candidate can be # pasted/embedded as a rich block inside a paper document (see paper.py + the # frontend paper-embed target). The plugin itself is a no-op without it, so the -# default install (and CI) never needs this extra. -# -# datasette-paper isn't on PyPI yet, so this extra only resolves once it's -# published or supplied locally, e.g. from a sibling checkout's built wheel: -# uv sync --extra paper \ -# --find-links ../datasette-paper/dist -# Keep the local wheel path out of committed config so it doesn't break -# checkouts / CI that lack the sibling directory. +# default install (and CI) never needs this extra; it resolves from PyPI when +# requested (`uv sync --extra paper`). datasette-paper is a prerelease, so a +# fresh checkout may need `--prerelease=allow`. paper = [ "datasette-paper>=0.0.2a3", ]