From 02106a19b8da99f8a20377d43711fcd0a2dade6c Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 22 Jun 2026 13:07:22 -0400 Subject: [PATCH] feat(artifact): web UI for playbook/convention export & import (#757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(artifact): web UI for playbook/convention export & import Phase 4 of PLAN-1867. - API client: exportItemArtifact (GET, parses Content-Disposition filename) and importArtifact (POST raw text/markdown); ImportArtifactResult type. - Export buttons: playbook editor header, each playbook card, each convention row — client-side .pad.md download. - Import file-picker entry points on the playbooks list + conventions header; surfaces per-warning toasts + a deep link to the created draft item. Shared browser helpers in lib/utils/artifacts.ts. CONVE-1688/606 compliant. Implements TASK-1878, TASK-1879, TASK-1880. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ * fix(artifact): web import race + RFC5987 filename parse Addresses Codex Phase-4 review: - parseContentDispositionFilename now decodes general filename*=charset'lang'value (percent-decoded), not just a UTF-8'' prefix. - import handlers snapshot workspace/username before the first await, so a mid-import workspace switch can't misroute the follow-up get/link/refresh. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ --- web/src/lib/api/client.ts | 117 +++++++++++++++++- web/src/lib/types/index.ts | 10 ++ web/src/lib/utils/artifacts.ts | 47 +++++++ .../[workspace]/conventions/+page.svelte | 107 +++++++++++++++- .../[workspace]/playbooks/+page.svelte | 90 +++++++++++++- .../[workspace]/playbooks/[slug]/+page.svelte | 29 ++++- 6 files changed, 395 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/utils/artifacts.ts diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 56c43fd3..4a23d4f8 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -64,7 +64,8 @@ import type { AttachmentListFilters, AttachmentListResponse, ConnectedApp, - ClaimCodeResponse + ClaimCodeResponse, + ImportArtifactResult } from '$lib/types'; const BASE = '/api/v1'; @@ -288,6 +289,52 @@ async function request(path: string, options?: RequestInit): Promise { return resp.json(); } +/** + * Pull the filename out of a Content-Disposition header value. Handles both + * the RFC 5987 extended `filename*=charset'lang'value` form (preferred when + * present) and the plain quoted/unquoted `filename="..."` form. Returns null + * when no filename token is present so the caller can fall back to a computed + * default. + * + * The extended form is `filename*=''`, + * e.g. `filename*=UTF-8'en'r%C3%A9sum%C3%A9.pad.md`. Per RFC 5987 we drop the + * charset + language by splitting on the first two `'` characters, then + * percent-decode the remaining value. Malformed input (missing the two `'` + * delimiters, or a bad percent-encoding) falls through to the plain form. + */ +function parseContentDispositionFilename(disposition: string): string | null { + if (!disposition) return null; + // RFC 5987 extended form takes precedence — it's percent-encoded. + const extended = disposition.match(/filename\*=([^;]+)/i); + if (extended?.[1]) { + // Strip surrounding whitespace/quotes the producer may have added. + const raw = extended[1].trim().replace(/^"|"$/g, ''); + // Split on the first two `'` to drop `charset` and `lang`, leaving the + // percent-encoded value. A well-formed header has exactly two before + // the value (the value itself can't contain a bare `'`). + const firstQuote = raw.indexOf("'"); + const secondQuote = firstQuote >= 0 ? raw.indexOf("'", firstQuote + 1) : -1; + if (secondQuote >= 0) { + const value = raw.slice(secondQuote + 1); + try { + return decodeURIComponent(value); + } catch { + // Malformed percent-encoding — fall through to the plain form. + } + } else { + // No charset'lang' prefix present — treat the whole token as the value. + try { + return decodeURIComponent(raw); + } catch { + // Fall through to the plain form on a malformed percent-encoding. + } + } + } + const plain = disposition.match(/filename="?([^";]+)"?/i); + if (plain?.[1]) return plain[1].trim(); + return null; +} + function qs(params?: Record): string { if (!params) return ''; const filtered: Record = {}; @@ -1631,6 +1678,74 @@ export const api = { body: JSON.stringify({ url }) }), + // ── Artifact Export / Import ─────────────────────────────────────────────── + // + // Round-trip an item as a Markdown+frontmatter artifact (the `.pad.md` + // shape). Both bypass the shared `request` helper: export consumes the + // raw response body as text (not JSON) and reads the filename out of the + // Content-Disposition header, and import sends raw Markdown bytes with a + // text/markdown Content-Type rather than JSON. + + /** + * GET the export endpoint and return the artifact text plus the filename + * the server suggested via Content-Disposition (falling back to + * `.pad.md` when the header is missing or unparseable). Auth is by + * item visibility; a 4xx surfaces as a PadApiError like every other call. + */ + exportItemArtifact: async ( + ws: string, + ref: string + ): Promise<{ filename: string; text: string }> => { + const resp = await fetch(`${BASE}/workspaces/${ws}/items/${ref}/export`, { + credentials: 'same-origin' + }); + if (resp.status === 401) { + if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { + window.location.href = '/login'; + } + throw new PadApiError({ code: 'unauthorized', message: 'Authentication required' }); + } + if (!resp.ok) { + const body = await resp.json().catch(() => null); + if (body?.error) throw new PadApiError(body.error); + throw new Error(`export failed: ${resp.status}`); + } + const text = await resp.text(); + const disposition = resp.headers.get('Content-Disposition') ?? ''; + const filename = parseContentDispositionFilename(disposition) ?? `${ref}.pad.md`; + return { filename, text }; + }, + + /** + * POST the raw artifact bytes (text/markdown) and parse the JSON result. + * Editor-gated server-side; oversized / malformed / over-quota artifacts + * 4xx with the standard `{ error: { code, message } }` envelope, surfaced + * here as a PadApiError so callers can show `err.message` cleanly. + */ + importArtifact: async (ws: string, body: string): Promise => { + const headers: Record = { 'Content-Type': 'text/markdown' }; + const csrf = getCSRFToken(); + if (csrf) headers['X-CSRF-Token'] = csrf; + const resp = await fetch(`${BASE}/workspaces/${ws}/import-artifact`, { + method: 'POST', + headers, + credentials: 'same-origin', + body + }); + if (resp.status === 401) { + if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { + window.location.href = '/login'; + } + throw new PadApiError({ code: 'unauthorized', message: 'Authentication required' }); + } + if (!resp.ok) { + const errBody = await resp.json().catch(() => null); + if (errBody?.error) throw new PadApiError(errBody.error); + throw new Error(`import failed: ${resp.status}`); + } + return (await resp.json()) as ImportArtifactResult; + }, + // ── Admin ──────────────────────────────────────────────────────────────── admin: { diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 687b1d53..83473da3 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -226,6 +226,16 @@ export interface ClaimCodeResponse { suppression_grant_name?: string; } +// ImportArtifactResult is the wire shape returned by +// POST /workspaces/{ws}/import-artifact — the new item's ref + slug plus +// any non-fatal warnings (coerced fields, renamed slug, forced-draft) the +// server surfaced while importing the Markdown+frontmatter artifact. +export interface ImportArtifactResult { + ref: string; + slug: string; + warnings: string[]; +} + export interface Workspace { id: string; name: string; diff --git a/web/src/lib/utils/artifacts.ts b/web/src/lib/utils/artifacts.ts new file mode 100644 index 00000000..7ec72aef --- /dev/null +++ b/web/src/lib/utils/artifacts.ts @@ -0,0 +1,47 @@ +// Artifact export/import helpers shared across the conventions + playbook +// surfaces (Phase 4 web UI). Centralizes the browser-only Blob/object-URL +// download dance and the file-read-then-import flow so each page only wires +// up its own buttons + toasts. + +import { api } from '$lib/api/client'; +import type { ImportArtifactResult } from '$lib/types'; + +/** + * Trigger a client-side download of `text` as `filename` using a Blob + + * object URL + a synthetic `` click, revoking the URL afterward. + * No-op outside the browser (guards against SSR). + */ +export function downloadTextFile(filename: string, text: string, mime = 'text/markdown'): void { + if (typeof document === 'undefined' || typeof URL === 'undefined') return; + const blob = new Blob([text], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * Export an item as a `.pad.md` artifact and immediately download it. Throws + * the underlying PadApiError so callers can surface `err.message` in a toast. + */ +export async function exportAndDownloadArtifact(ws: string, ref: string): Promise { + const { filename, text } = await api.exportItemArtifact(ws, ref); + downloadTextFile(filename, text); +} + +/** + * Read a picked artifact File as text and POST it to the import endpoint. + * Returns the server result (ref + slug + warnings); throws on read failure + * or a 4xx import error (PadApiError) for the caller to surface. + */ +export async function importArtifactFile( + ws: string, + file: File +): Promise { + const text = await file.text(); + return api.importArtifact(ws, text); +} diff --git a/web/src/routes/[username]/[workspace]/conventions/+page.svelte b/web/src/routes/[username]/[workspace]/conventions/+page.svelte index 494bd689..68c9343a 100644 --- a/web/src/routes/[username]/[workspace]/conventions/+page.svelte +++ b/web/src/routes/[username]/[workspace]/conventions/+page.svelte @@ -2,9 +2,10 @@ import { page } from '$app/state'; import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client'; import type { Collection, Item, ItemConventionMetadata, ItemCreate } from '$lib/types'; - import { parseFields, parseSchema } from '$lib/types'; + import { parseFields, parseSchema, itemUrlId } from '$lib/types'; import { toastStore } from '$lib/stores/toast.svelte'; import { createScrollRestoration } from '$lib/scroll/restore.svelte'; + import { exportAndDownloadArtifact, importArtifactFile } from '$lib/utils/artifacts'; import { SvelteSet, SvelteMap } from 'svelte/reactivity'; const TRIGGERS = ['always','on-task-start','on-task-complete','on-implement','on-commit','on-pr-create','on-plan-start','on-plan-complete','on-plan'] as const; @@ -62,6 +63,12 @@ let editContent = $state(''); let saving = $state(false); + // Artifact export/import state. `importing` gates the import button while a + // POST is in flight; `exportingSlug` tracks which row's Export is busy. + let exportingSlug = $state(null); + let importing = $state(false); + let importInputEl = $state(null); + // Inline create form state let newTitle = $state(''); let newCategory = $state('custom'); @@ -309,6 +316,78 @@ editContent = ''; } + async function handleExport(item: Item) { + if (!workspace || exportingSlug) return; + exportingSlug = item.slug; + try { + await exportAndDownloadArtifact(workspace, itemUrlId(item)); + toastStore.show('Convention exported', 'success'); + } catch (err: unknown) { + toastStore.show( + err instanceof Error ? err.message : 'Failed to export convention', + 'error' + ); + } finally { + exportingSlug = null; + } + } + + // Open the hidden file-picker. The selected file is handled entirely in + // `onImportFileChange` (the change-event handler) — we never route the + // file through a `$state` that an `$effect` reads (CONVE-1688). + function openImportPicker() { + importInputEl?.click(); + } + + async function onImportFileChange(e: Event) { + const input = e.currentTarget as HTMLInputElement; + const file = input.files?.[0]; + // Reset the input synchronously so picking the same file again still + // fires a change event next time. + input.value = ''; + if (!file || !workspace || importing) return; + // Snapshot the reactive workspace/username BEFORE the first await. If the + // user switches workspace mid-import, the follow-up get, the deep link, + // and the list refresh must target the workspace the import went to — + // not whatever `workspace`/`username` have since become. + const ws = workspace; + const user = username; + importing = true; + try { + const result = await importArtifactFile(ws, file); + // Surface each warning (coerced fields, renamed slug, forced-draft) + // as its own info toast so none get lost behind the success line. + for (const warning of result.warnings) { + toastStore.show(warning, 'info', 6000); + } + // The import endpoint returns ref + slug but not the destination + // collection, and item-detail routes are collection-scoped + // (/{user}/{ws}/{collection}/{slug}). Resolve the created item to + // build an accurate deep link; if that lookup fails, still report + // success without a link rather than 404-ing the user. + let link: string | undefined; + try { + const created = await api.items.get(ws, result.slug); + if (created.collection_slug) { + link = `/${user}/${ws}/${created.collection_slug}/${created.slug}`; + } + } catch { + link = undefined; + } + toastStore.show(`Imported ${result.ref} as a draft`, 'success', 6000, link); + // Refresh the list so a newly-imported convention shows up. + loadConventions(ws); + } catch (err: unknown) { + toastStore.show( + err instanceof Error ? err.message : 'Failed to import artifact', + 'error', + 6000 + ); + } finally { + importing = false; + } + } + async function bulkToggleGroup(group: { trigger: string; items: Item[] }, enable: boolean) { if (!workspace) return; const targetStatus = enable ? 'active' : 'disabled'; @@ -406,9 +485,24 @@
Browse Library + +
@@ -602,6 +696,14 @@ {/if}
+ {#if confirmDelete === item.slug} Delete this convention? @@ -632,7 +734,8 @@ .page-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); margin-bottom: var(--space-6); flex-wrap: wrap; } .header-text h1 { font-size: 1.6em; margin-bottom: var(--space-1); } .subtitle { color: var(--text-secondary); font-size: 0.9em; } - .header-actions { display: flex; gap: var(--space-2); flex-shrink: 0; } + .header-actions { display: flex; gap: var(--space-2); flex-shrink: 0; flex-wrap: wrap; } + .visually-hidden-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } /* Buttons */ .btn { padding: var(--space-1) var(--space-4); border-radius: var(--radius); font-size: 0.85em; font-weight: 600; cursor: pointer; border: none; white-space: nowrap; text-decoration: none; display: inline-flex; align-items: center; } diff --git a/web/src/routes/[username]/[workspace]/playbooks/+page.svelte b/web/src/routes/[username]/[workspace]/playbooks/+page.svelte index beb656e9..80c1df6e 100644 --- a/web/src/routes/[username]/[workspace]/playbooks/+page.svelte +++ b/web/src/routes/[username]/[workspace]/playbooks/+page.svelte @@ -5,6 +5,7 @@ import { parseFields, parseSchema, itemUrlId, type Collection, type Item } from '$lib/types'; import { toastStore } from '$lib/stores/toast.svelte'; import { createScrollRestoration } from '$lib/scroll/restore.svelte'; + import { exportAndDownloadArtifact, importArtifactFile } from '$lib/utils/artifacts'; import PlaybookFormFields from '$lib/components/playbooks/PlaybookFormFields.svelte'; import { PLAYBOOK_SKELETON_BODY, @@ -40,6 +41,9 @@ let confirmDeleteSlug = $state(null); let togglingStatus = $state(null); let duplicating = $state(null); + let exportingSlug = $state(null); + let importing = $state(false); + let importInputEl = $state(null); let searchQuery = $state(''); let filterTrigger = $state(''); let filterScope = $state(''); @@ -292,6 +296,71 @@ } finally { duplicating = null; } } + async function exportPlaybook(item: Item) { + if (exportingSlug) return; + exportingSlug = item.slug; + try { + await exportAndDownloadArtifact(wsSlug, itemUrlId(item)); + toastStore.show('Playbook exported', 'success'); + } catch (err: unknown) { + toastStore.show( + err instanceof Error ? err.message : 'Failed to export playbook', + 'error' + ); + } finally { + exportingSlug = null; + } + } + + // Open the hidden file-picker; the selected file is handled entirely in the + // change-event handler so the File never routes through a `$state` an + // `$effect` reads (CONVE-1688). + function openImportPicker() { + importInputEl?.click(); + } + + async function onImportFileChange(e: Event) { + const input = e.currentTarget as HTMLInputElement; + const file = input.files?.[0]; + // Reset synchronously so re-picking the same file fires change again. + input.value = ''; + if (!file || importing) return; + // Snapshot the reactive wsSlug/username BEFORE the first await. If the + // user switches workspace mid-import, the follow-up get, the deep link, + // and the list refresh must target the workspace the import went to — + // not whatever `wsSlug`/`username` have since become. + const ws = wsSlug; + const user = username; + importing = true; + try { + const result = await importArtifactFile(ws, file); + for (const warning of result.warnings) { + toastStore.show(warning, 'info', 6000); + } + // Resolve the created item to build a collection-scoped deep link + // (the import response carries ref + slug but not the collection). + let link: string | undefined; + try { + const created = await api.items.get(ws, result.slug); + if (created.collection_slug) { + link = `/${user}/${ws}/${created.collection_slug}/${created.slug}`; + } + } catch { + link = undefined; + } + toastStore.show(`Imported ${result.ref} as a draft`, 'success', 6000, link); + await loadPlaybooks(ws); + } catch (err: unknown) { + toastStore.show( + err instanceof Error ? err.message : 'Failed to import artifact', + 'error', + 6000 + ); + } finally { + importing = false; + } + } + function clearFilters() { searchQuery = ''; filterTrigger = ''; filterScope = ''; } function resetForm() { @@ -319,9 +388,23 @@

Multi-step workflows that agents follow for specific actions

{#if !showNewForm} -
+
📚 Browse Library + +
{/if} @@ -461,6 +544,9 @@ + {#if confirmDeleteSlug === item.slug} Delete? @@ -488,6 +574,8 @@ .subtitle { color: var(--text-secondary); font-size: 0.95em; } .new-btn { background: var(--accent-blue); color: #fff; padding: var(--space-2) var(--space-5); border-radius: var(--radius); font-size: 0.85em; font-weight: 600; white-space: nowrap; flex-shrink: 0; transition: opacity 0.15s; } .new-btn:hover { opacity: 0.85; } + .new-btn:disabled { opacity: 0.5; cursor: not-allowed; } + .visually-hidden-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } .empty-state { text-align: center; padding: var(--space-10) var(--space-6); color: var(--text-secondary); } .empty-icon { font-size: 3em; margin-bottom: var(--space-4); opacity: 0.6; } .empty-state h2 { font-size: 1.2em; font-weight: 600; margin-bottom: var(--space-2); color: var(--text-primary); } diff --git a/web/src/routes/[username]/[workspace]/playbooks/[slug]/+page.svelte b/web/src/routes/[username]/[workspace]/playbooks/[slug]/+page.svelte index 82da1241..ac8f1a0d 100644 --- a/web/src/routes/[username]/[workspace]/playbooks/[slug]/+page.svelte +++ b/web/src/routes/[username]/[workspace]/playbooks/[slug]/+page.svelte @@ -2,9 +2,10 @@ import { page } from '$app/state'; import { goto } from '$app/navigation'; import { api } from '$lib/api/client'; - import { parseFields, parseSchema, type Collection, type Item } from '$lib/types'; + import { parseFields, parseSchema, itemUrlId, type Collection, type Item } from '$lib/types'; import { toastStore } from '$lib/stores/toast.svelte'; import { createScrollRestoration } from '$lib/scroll/restore.svelte'; + import { exportAndDownloadArtifact } from '$lib/utils/artifacts'; import PlaybookFormFields from '$lib/components/playbooks/PlaybookFormFields.svelte'; import { argumentsFromJSON, @@ -35,6 +36,7 @@ let existingPlaybooks = $state([]); let loading = $state(true); let saving = $state(false); + let exporting = $state(false); // Scroll position restoration (BUG-1425). Form pages are usually short // enough that scroll position isn't critical, but if the body grows the @@ -223,6 +225,22 @@ function cancel() { goto(`/${username}/${wsSlug}/playbooks`); } + + async function handleExport() { + if (!item || exporting) return; + exporting = true; + try { + await exportAndDownloadArtifact(wsSlug, itemUrlId(item)); + toastStore.show('Playbook exported', 'success'); + } catch (err: unknown) { + toastStore.show( + err instanceof Error ? err.message : 'Failed to export playbook', + 'error' + ); + } finally { + exporting = false; + } + }
@@ -244,6 +262,15 @@
+