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/docs/screenshots/paper.png b/docs/screenshots/paper.png new file mode 100644 index 0000000..94071d4 Binary files /dev/null and b/docs/screenshots/paper.png differ diff --git a/frontend/scripts/screenshots.mjs b/frontend/scripts/screenshots.mjs index 3175217..38ba5fb 100644 --- a/frontend/scripts/screenshots.mjs +++ b/frontend/scripts/screenshots.mjs @@ -12,18 +12,23 @@ // datasette-paper / datasette-places convention): // * shots/config.mjs — constants + out(name) // * shots/server.mjs — boot/teardown of the throwaway datasette +// * shots/cookie.mjs — signed ds_actor cookie (itsdangerous via uv) +// * shots/seed.mjs — seed() the paper-embed doc (paper shot only) // * shots/helpers.mjs — STABILITY_CSS / freezeVolatile / makeContext // * shots/defineShot.mjs — per-shot context → goto → wait → freeze → capture // * shots/defs/.mjs — ONE FILE PER SHOT, auto-discovered below. // -// libfec's pages are anonymous, read-only views over the fixture db — no cookies -// or seeding, just open the access gate and browse. +// libfec specifics: its own pages are anonymous, read-only views over the +// fixture db (no cookies/seed). The one exception is the `paper` shot, which +// boots the server with datasette-paper and seeds a document embedding FEC +// candidates as cards, viewed as a signed-in owner — see shots/seed.mjs. import { chromium } from '@playwright/test'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { mkdir, readdir } from 'node:fs/promises'; import { BASE, OUT, out } from './shots/config.mjs'; import { startServer, stopServer } from './shots/server.mjs'; +import { seed } from './shots/seed.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -60,10 +65,12 @@ async function main() { throw new Error(`unknown shot(s): ${unknown.join(', ')} (have: ${names.join(', ')})`); } const todo = requested.size ? shots.filter((s) => requested.has(s.name)) : shots; + // Only the paper shot needs datasette-paper booted + a seeded doc. + const needsPaper = todo.some((s) => s.name === 'paper'); await mkdir(OUT, { recursive: true }); - console.log(`booting datasette on ${BASE} …`); - const server = await startServer(); + console.log(`booting datasette on ${BASE}${needsPaper ? ' (with paper)' : ''} …`); + const server = await startServer(needsPaper); const onSignal = () => { stopServer(server); process.exit(130); @@ -73,8 +80,9 @@ async function main() { const browser = await chromium.launch(); try { + const ids = await seed(needsPaper); for (const { name, run } of todo) { - await run(browser, {}); + await run(browser, ids); console.log(`✓ ${name} → ${out(name)}`); } } finally { diff --git a/frontend/scripts/shot-plugins/profiles.py b/frontend/scripts/shot-plugins/profiles.py new file mode 100644 index 0000000..76b960b --- /dev/null +++ b/frontend/scripts/shot-plugins/profiles.py @@ -0,0 +1,27 @@ +"""Throwaway actor display names for the screenshot script. + +Loaded via ``datasette --plugins-dir`` from shots/server.mjs (only for the +``paper`` shot) so the seeded paper document shows a friendly author name +("Alice Ada") instead of the raw ``alice`` id in its header. NOT shipped — +dev/screenshot use only. +""" + +from datasette import hookimpl + +DISPLAY_NAMES = { + "alice": "Alice Ada", + "bob": "Bob Babbage", + "carol": "Carol Shaw", +} + + +@hookimpl +def actors_from_ids(actor_ids): + out = {} + for aid in actor_ids: + sid = str(aid) + actor = {"id": sid} + if sid in DISPLAY_NAMES: + actor["name"] = DISPLAY_NAMES[sid] + out[sid] = actor + return out diff --git a/frontend/scripts/shots/config.mjs b/frontend/scripts/shots/config.mjs index 4e04e82..5cbd170 100644 --- a/frontend/scripts/shots/config.mjs +++ b/frontend/scripts/shots/config.mjs @@ -11,8 +11,11 @@ export const BASE = `http://localhost:${PORT}`; export const DB_NAME = 'data'; // The plugin's base UI path; each shot builds its per-page URL off this. export const APP = `${BASE}/${DB_NAME}/-/libfec`; +// datasette-paper's base path (only the `paper` shot touches it). +export const PAPER = `${BASE}/-/paper`; -// Fixed --secret for the throwaway server; stable so runs stay reproducible. +// Fixed signing secret — lets us mint a signed actor cookie for the paper doc +// owner. Must match datasette's --secret. NOT a real secret. export const SECRET = 'screenshots-secret-not-for-prod'; const HERE = dirname(fileURLToPath(import.meta.url)); // frontend/scripts/shots @@ -22,14 +25,28 @@ const HERE = dirname(fileURLToPath(import.meta.url)); // frontend/scripts/shots export const FIXTURE_DB = resolve(HERE, '../shot-data/libfec.db'); export const DATA_DIR = '/tmp/datasette-libfec-shots-data'; // stem == DB_NAME export const DATA_DB = `${DATA_DIR}/${DB_NAME}.db`; +// datasette-paper stores docs in the internal DB; a throwaway one, wiped each +// run, keeps the seeded doc's id deterministic. Only used for the `paper` shot. +export const INTERNAL_DB = `${DATA_DIR}/internal.db`; +// Throwaway plugin dir: friendly display name for the paper doc author. +export const PLUGINS_DIR = resolve(HERE, '../shot-plugins'); export const OUT = resolve(HERE, '../../../docs/screenshots'); export const VIEWPORT = { width: 1200, height: 900 }; +// Cropped to the paper editor chrome + heading + the two stacked embed cards. +export const VIEWPORT_TALL = { width: 1200, height: 800 }; + +// The paper doc owner — create seeds an owner acl grant to this actor, and +// shot-plugins/profiles.py maps it to a friendly display name. +export const PAPER_ACTOR = 'alice'; // --- Concrete demo entities (from the fixture: 2024 Ohio Senate + big PACs) --- export const SHERROD_BROWN = 'S6OH00163'; // candidate id +export const BERNIE_MORENO = 'S4OH00192'; // his 2024 Senate opponent export const FAIRSHAKE = 'C00835959'; // crypto super PAC committee id export const BROWN_YE_FILING = '1870297'; // Friends of Sherrod Brown, F3 YE 2024 +// Candidates embedded as cards in the seeded paper doc. +export const PAPER_EMBED_CANDIDATES = [SHERROD_BROWN, BERNIE_MORENO]; // Absolute path of a shot's output PNG. export const out = (name) => resolve(OUT, `${name}.png`); diff --git a/frontend/scripts/shots/cookie.mjs b/frontend/scripts/shots/cookie.mjs new file mode 100644 index 0000000..ba7cc58 --- /dev/null +++ b/frontend/scripts/shots/cookie.mjs @@ -0,0 +1,29 @@ +// Signed ds_actor cookie for an actor id. itsdangerous has no maintained Node +// port, so shell out to the same one-liner datasette signs with. Cached per id. +import { execFileSync } from 'node:child_process'; +import { SECRET } from './config.mjs'; + +const _signed = new Map(); + +export function signActorCookie(actorId) { + let v = _signed.get(actorId); + if (!v) { + const out = execFileSync( + 'uv', + [ + 'run', + '--prerelease=allow', + 'python', + '-c', + 'import sys, json; from itsdangerous import URLSafeSerializer; ' + + 'print(URLSafeSerializer(sys.argv[1]).dumps(json.loads(sys.argv[2]), salt="actor"))', + SECRET, + JSON.stringify({ a: { id: actorId } }), + ], + { encoding: 'utf-8' } + ); + v = out.trim(); + _signed.set(actorId, v); + } + return v; +} diff --git a/frontend/scripts/shots/defineShot.mjs b/frontend/scripts/shots/defineShot.mjs index 7298542..9a9145f 100644 --- a/frontend/scripts/shots/defineShot.mjs +++ b/frontend/scripts/shots/defineShot.mjs @@ -1,18 +1,23 @@ // defineShot turns a declarative descriptor into the `async (browser, ids) => -// {…}` the runner calls, owning the per-shot boilerplate: open a fresh context → -// navigate → wait for readiness → interact → freeze → capture → close. +// {…}` the runner calls, owning the per-shot boilerplate: open a fresh context +// (with the right actor + viewport) → navigate → wait for readiness → interact → +// freeze → capture → close. // // Descriptor fields: -// name (required) — output PNG base name; MUST equal the shot's file name -// (asserted by the runner). -// order — run sequence (ascending, then by name). libfec shots are -// independent, so this just fixes a stable order. -// url (required) — (ids) => string, the page to screenshot. -// viewport — context viewport (default VIEWPORT). -// ready — a selector waited on after navigation (the common case). -// prepare — async (page, {ids}) for custom waits / interaction. -// capture — async (page, file, {ids}); default = full-page screenshot. -// freeze — default true; runs freezeVolatile before capture. +// name (required) — output PNG base name; MUST equal the shot's file name +// (asserted by the runner). +// order — run sequence (ascending, then by name). libfec shots are +// independent, so this just fixes a stable order. +// url (required) — (ids) => string, the page to screenshot. +// actor — actor id to sign a ds_actor cookie for (default null = anon). +// viewport — context viewport (default VIEWPORT). +// waitUntil — page.goto wait condition (default "networkidle"; the paper shot +// uses "domcontentloaded", as its editor holds a live SSE +// connection that never goes idle). +// ready — a selector waited on after navigation (the common case). +// prepare — async (page, {ids}) for custom waits / interaction. +// capture — async (page, file, {ids}); default = full-page screenshot. +// freeze — default true; runs freezeVolatile before capture. import { makeContext, freezeVolatile } from './helpers.mjs'; import { VIEWPORT, out } from './config.mjs'; @@ -21,7 +26,9 @@ export function defineShot(desc) { name, order = 0, url, + actor = null, viewport = VIEWPORT, + waitUntil = 'networkidle', ready, prepare, capture, @@ -33,10 +40,10 @@ export function defineShot(desc) { } const run = async (browser, ids) => { - const ctx = await makeContext(browser, { viewport }); + const ctx = await makeContext(browser, { actor, viewport }); try { const page = await ctx.newPage(); - await page.goto(url(ids), { waitUntil: 'networkidle' }); + await page.goto(url(ids), { waitUntil }); if (ready) await page.locator(ready).first().waitFor({ timeout: 15_000 }); if (prepare) await prepare(page, { ids }); if (freeze) await freezeVolatile(page); diff --git a/frontend/scripts/shots/defs/paper.mjs b/frontend/scripts/shots/defs/paper.mjs new file mode 100644 index 0000000..3f2cc69 --- /dev/null +++ b/frontend/scripts/shots/defs/paper.mjs @@ -0,0 +1,31 @@ +import { defineShot } from '../defineShot.mjs'; +import { PAPER, PAPER_ACTOR, PAPER_EMBED_CANDIDATES, VIEWPORT_TALL } from '../config.mjs'; + +// An FEC candidate embedded as a live card inside a datasette-paper document +// (the paper embed provider — datasette_libfec/paper.py + src/paper_embed.ts). +// Needs `--extra paper` + the doc seeded by shots/seed.mjs. +// +// The editor holds a live SSE connection that never goes network-idle, so wait +// on domcontentloaded + explicit readiness: the editor surface, then both cards +// resolved (each card's mount() swaps its "Loading candidate…" placeholder for +// a card carrying a "Candidate ID" row). +export default defineShot({ + name: 'paper', + order: 10, + actor: PAPER_ACTOR, + viewport: VIEWPORT_TALL, + waitUntil: 'domcontentloaded', + url: (ids) => `${PAPER}/doc/${ids.paperDocId}`, + prepare: async (page) => { + await page.locator('.ProseMirror').first().waitFor({ timeout: 15_000 }); + await page.waitForFunction( + (n) => + [...document.querySelectorAll('span')].filter((s) => s.textContent === 'Candidate ID') + .length >= n, + PAPER_EMBED_CANDIDATES.length, + { timeout: 15_000 } + ); + // Let the cards' final layout settle before capture. + await new Promise((r) => setTimeout(r, 300)); + }, +}); diff --git a/frontend/scripts/shots/helpers.mjs b/frontend/scripts/shots/helpers.mjs index 0c71432..0df8787 100644 --- a/frontend/scripts/shots/helpers.mjs +++ b/frontend/scripts/shots/helpers.mjs @@ -1,5 +1,6 @@ // Page-stabilization + capture helpers shared by every shot. -import { VIEWPORT } from './config.mjs'; +import { BASE, VIEWPORT } from './config.mjs'; +import { signActorCookie } from './cookie.mjs'; // Injected on the context (survives navigation) so re-runs produce no binary // diff: kill the caret, transitions and animations, and hide the debug bar. @@ -10,17 +11,20 @@ export const STABILITY_CSS = `*, *::before, *::after { } #datasette-debug-bar { display: none !important; }`; -// Rewrite any moving text to fixed strings just before each capture. libfec's -// pages carry none, so this only removes the dev debug bar if present. +// Rewrite the moving text to fixed strings just before each capture. libfec's +// own pages carry none, but the paper editor header shows a relative "edited …" +// time — pin it so the paper shot doesn't diff on the clock. export async function freezeVolatile(page) { await page.evaluate(() => { document.getElementById('datasette-debug-bar')?.remove(); + document.querySelectorAll('.updated-at').forEach((el) => (el.textContent = 'edited just now')); }); } -// New context: viewport + retina, with the stability stylesheet injected on -// every navigation. -export async function makeContext(browser, { viewport = VIEWPORT } = {}) { +// New context: viewport + retina, the stability stylesheet on every navigation, +// and (for the paper shot) a signed owner cookie. libfec's own pages browse +// anonymously — only `actor` shots get a cookie. +export async function makeContext(browser, { actor = null, viewport = VIEWPORT } = {}) { const ctx = await browser.newContext({ viewport, deviceScaleFactor: 2 }); await ctx.addInitScript((css) => { const inject = () => { @@ -33,5 +37,8 @@ export async function makeContext(browser, { viewport = VIEWPORT } = {}) { inject(); document.addEventListener('DOMContentLoaded', inject); }, STABILITY_CSS); + if (actor) { + await ctx.addCookies([{ name: 'ds_actor', value: signActorCookie(actor), url: BASE }]); + } return ctx; } diff --git a/frontend/scripts/shots/seed.mjs b/frontend/scripts/shots/seed.mjs new file mode 100644 index 0000000..9df98c0 --- /dev/null +++ b/frontend/scripts/shots/seed.mjs @@ -0,0 +1,43 @@ +// Seed the deterministic paper document the `paper` shot screenshots, over +// paper's real create API. Returns an `ids` object (`{ paperDocId }`) the shot +// reads via its `url(ids)`. Only creates anything when the paper shot is in the +// run set — the server is booted with datasette-paper then. +import { PAPER, DB_NAME, PAPER_ACTOR, PAPER_EMBED_CANDIDATES } from './config.mjs'; +import { signActorCookie } from './cookie.mjs'; + +// A block embed is a fenced ```paper-embed code block whose body is +// {config, mode, ref}; paper renders it via the provider that owns the ref's +// prefix (libfec, here), mounting our read-only FEC-candidate card. +function embedBlock(candidateId) { + const body = JSON.stringify({ + config: {}, + mode: 'block', + ref: `/-/libfec/candidate/${DB_NAME}/${candidateId}`, + }); + return '```paper-embed\n' + body + '\n```'; +} + +export async function seed(needsPaper) { + if (!needsPaper) return {}; + + const blocks = PAPER_EMBED_CANDIDATES.map(embedBlock).join('\n\n'); + const content = + '# 2024 Ohio Senate\n\n' + + 'The two campaigns in the race, embedded as live FEC-candidate cards:\n\n' + + blocks + + '\n'; + + const resp = await fetch(`${PAPER}/api/docs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `ds_actor=${signActorCookie(PAPER_ACTOR)}`, + }, + body: JSON.stringify({ name: '2024 Ohio Senate', content }), + }); + if (!resp.ok) { + throw new Error(`create paper doc → ${resp.status}: ${await resp.text()}`); + } + const { id } = await resp.json(); + return { paperDocId: id }; +} diff --git a/frontend/scripts/shots/server.mjs b/frontend/scripts/shots/server.mjs index f04a20a..be18abe 100644 --- a/frontend/scripts/shots/server.mjs +++ b/frontend/scripts/shots/server.mjs @@ -4,9 +4,23 @@ // so datasette opens it writable (the plugin's startup hook adds its alert-queue // table) without touching the committed fixture. The instance permission gates // every libfec page checks are opened globally. -import { mkdir, copyFile, access } from 'node:fs/promises'; +// +// The `paper` shot additionally boots with datasette-paper (`--extra paper`, +// from PyPI) + its permission gates + the actor-name plugin — only when that +// shot is in the run set, so the other shots stay lightweight. +import { mkdir, copyFile, access, rm } from 'node:fs/promises'; import { spawn } from 'node:child_process'; -import { APP, BASE, SECRET, FIXTURE_DB, DATA_DIR, DATA_DB, PORT } from './config.mjs'; +import { + APP, + BASE, + SECRET, + FIXTURE_DB, + DATA_DIR, + DATA_DB, + INTERNAL_DB, + PLUGINS_DIR, + PORT, +} from './config.mjs'; export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -23,8 +37,9 @@ export async function reachable() { } } -// Fresh mutable copy of the fixture db. -async function setupDataDb() { +// Fresh mutable copy of the fixture db. `needsPaper` also wipes the internal DB +// so the seeded paper doc's id stays deterministic across runs. +async function setupDataDb(needsPaper) { try { await access(FIXTURE_DB); } catch { @@ -32,10 +47,33 @@ async function setupDataDb() { } await mkdir(DATA_DIR, { recursive: true }); await copyFile(FIXTURE_DB, DATA_DB); + if (needsPaper) await rm(INTERNAL_DB, { force: true }); +} + +// Extra `uv run` / datasette args that pull in datasette-paper + open its gates. +// `--extra paper` resolves it from PyPI; the `>=0.0.2a3` specifier is an alpha, +// so `--prerelease=allow` lets uv pick it (and any prerelease transitive deps). +// The plugin dir adds a friendly display name for the doc author. +function paperArgs() { + return { + uv: ['--prerelease=allow', '--extra', 'paper'], + ds: [ + '--internal', + INTERNAL_DB, + '--plugins-dir', + PLUGINS_DIR, + '-s', + 'permissions.datasette-paper-create', + 'true', + '-s', + 'permissions.paper-view', + 'true', + ], + }; } -export async function startServer() { - await setupDataDb(); +export async function startServer(needsPaper) { + await setupDataDb(needsPaper); // Refuse to start over an already-listening server rather than screenshot // whatever is there — the poll can't tell a stale server from ours. if (await reachable()) { @@ -43,12 +81,14 @@ export async function startServer() { `something is already serving on ${BASE}. Stop it (or set SHOTS_PORT) and retry.` ); } + const paper = needsPaper ? paperArgs() : { uv: [], ds: [] }; // `detached: true` puts datasette in its own process group. datasette is a // grandchild of `uv run`, so we kill the whole group in stopServer. const child = spawn( 'uv', [ 'run', + ...paper.uv, 'datasette', DATA_DB, '--secret', @@ -65,6 +105,7 @@ export async function startServer() { '-s', 'permissions.datasette-alerts-access', 'true', + ...paper.ds, '-p', String(PORT), ], 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..da43192 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,17 @@ 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). The plugin itself is a no-op without it, so the +# 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", +] + [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta"