feat(artifact): web UI for playbook/convention export & import (#757)

* 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
This commit is contained in:
xarmian
2026-06-22 13:07:22 -04:00
committed by GitHub
parent 285a58e40e
commit 02106a19b8
6 changed files with 395 additions and 5 deletions
+116 -1
View File
@@ -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<T>(path: string, options?: RequestInit): Promise<T> {
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*=<charset>'<lang>'<percent-encoded-value>`,
* 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, string | number | boolean | undefined>): string {
if (!params) return '';
const filtered: Record<string, string> = {};
@@ -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
* `<ref>.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<ImportArtifactResult> => {
const headers: Record<string, string> = { '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: {
+10
View File
@@ -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;
+47
View File
@@ -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 `<a download>` 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<void> {
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<ImportArtifactResult> {
const text = await file.text();
return api.importArtifact(ws, text);
}
@@ -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<string | null>(null);
let importing = $state(false);
let importInputEl = $state<HTMLInputElement | null>(null);
// Inline create form state
let newTitle = $state('');
let newCategory = $state<typeof CATEGORIES[number]>('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 @@
</div>
<div class="header-actions">
<a href="/{username}/{workspace}/library" class="btn btn-secondary">Browse Library</a>
<button
class="btn btn-secondary"
disabled={importing}
onclick={openImportPicker}
title="Import a convention or playbook from a .pad.md artifact"
>
{importing ? 'Importing…' : 'Import artifact'}
</button>
<button class="btn btn-primary" onclick={() => (showCreate = !showCreate)}>
{showCreate ? 'Cancel' : '+ New Convention'}
</button>
<input
bind:this={importInputEl}
type="file"
accept=".md,text/markdown"
class="visually-hidden-input"
onchange={onImportFileChange}
/>
</div>
</header>
@@ -602,6 +696,14 @@
{/if}
<div class="expanded-actions">
<button class="btn btn-small btn-secondary" onclick={() => startEditing(item)}>Edit</button>
<button
class="btn btn-small btn-secondary"
disabled={exportingSlug === item.slug}
onclick={() => handleExport(item)}
title="Download as a .pad.md artifact"
>
{exportingSlug === item.slug ? 'Exporting…' : 'Export'}
</button>
{#if confirmDelete === item.slug}
<span class="confirm-text">Delete this convention?</span>
<button class="btn btn-small btn-danger" onclick={() => deleteConvention(item)}>Confirm</button>
@@ -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; }
@@ -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<string | null>(null);
let togglingStatus = $state<string | null>(null);
let duplicating = $state<string | null>(null);
let exportingSlug = $state<string | null>(null);
let importing = $state(false);
let importInputEl = $state<HTMLInputElement | null>(null);
let searchQuery = $state('');
let filterTrigger = $state<string>('');
let filterScope = $state<string>('');
@@ -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 @@
<p class="subtitle">Multi-step workflows that agents follow for specific actions</p>
</div>
{#if !showNewForm}
<div style="display:flex;gap:var(--space-2);align-items:center;">
<div style="display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap;">
<a href="/{username}/{wsSlug}/library?tab=playbooks" class="new-btn" style="background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);">📚 Browse Library</a>
<button
class="new-btn"
style="background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);"
disabled={importing}
onclick={openImportPicker}
title="Import a playbook or convention from a .pad.md artifact"
>{importing ? 'Importing…' : 'Import artifact'}</button>
<button class="new-btn" onclick={() => (showNewForm = true)}>+ New Playbook</button>
<input
bind:this={importInputEl}
type="file"
accept=".md,text/markdown"
class="visually-hidden-input"
onchange={onImportFileChange}
/>
</div>
{/if}
</header>
@@ -461,6 +544,9 @@
<button class="action-btn" disabled={duplicating === item.slug} onclick={() => duplicatePlaybook(item)}>
{duplicating === item.slug ? '...' : 'Duplicate'}
</button>
<button class="action-btn" disabled={exportingSlug === item.slug} onclick={() => exportPlaybook(item)} title="Download as a .pad.md artifact">
{exportingSlug === item.slug ? '...' : 'Export'}
</button>
{#if confirmDeleteSlug === item.slug}
<span class="delete-confirm">
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); }
@@ -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<Item[]>([]);
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;
}
}
</script>
<div class="edit-page">
@@ -244,6 +262,15 @@
</div>
<div class="header-actions">
<button type="button" class="btn btn-secondary" onclick={cancel}>Cancel</button>
<button
type="button"
class="btn btn-secondary"
disabled={exporting}
onclick={handleExport}
title="Download this playbook as a .pad.md artifact"
>
{exporting ? 'Exporting…' : 'Export'}
</button>
<button
type="button"
class="btn btn-primary"