/** * Snippet — the primary user-authored entity (spec §09A). * * Portable core: no browser APIs, no React. Defines the record shape, the * current record schema version, and pure factories for creating new snippets * (with the sample bar-chart template and an auto-generated date/time name). * * Specs are stored as **JSON text** (string). The data model permits a spec to * be an object or a string; we standardize on the string form because it is what * the Monaco editor edits and what survives round-tripping without reformatting. * The preview parses the text into an object before rendering (see rendering.ts). */ /** Current schema version for a Snippet record (read-time migration target). */ export const CURRENT_SNIPPET_VERSION = 1; /** * The Vega-Lite schema URL stamped into generated specs (`$schema`). Shared so the * sample template and the Chart Builder agree on one version; the Monaco schema * service pins the same URI independently (infrastructure/monaco-schema.ts). */ export const VEGA_LITE_SCHEMA_URL = 'https://vega.github.io/schema/vega-lite/v6.json'; export interface Snippet { /** Unique, stable identifier. */ id: string; /** Record schema version, for read-time migration. */ version: number; /** Human-readable title shown in the library. */ name: string; /** * Name provenance — the naming hierarchy's gate. `'user'`: explicitly chosen * (rename / metadata panel) — frozen, never rewritten. `'auto'`: app-picked * (timestamp default, builder-generated, or publish-derived) — keeps tracking * the spec's content on publish. Absent on records from before the field * existed; `isAutoNamed` then falls back to recognizing the timestamp shape. */ nameSource?: 'auto' | 'user'; /** ISO timestamp — when first created. */ created: string; /** ISO timestamp — when last saved. */ modified: string; /** The published (stable) Vega-Lite spec, as JSON text. */ spec: string; /** The working-draft Vega-Lite spec being edited, as JSON text. */ draftSpec: string; /** Free-form user note. */ comment: string; /** User-assigned labels. */ tags: string[]; /** Names of datasets referenced by the draft spec; mirrors the version being * edited, recomputed on every draft change (create/auto-save/extract/revert) * and on publish (docs/architecture/07 §3). */ datasetRefs: string[]; /** Free-form, extensible metadata bag. */ meta: Record; } /** * The sample bar-chart template a fresh snippet starts from (spec §02 → Create * New: "a small sample Vega-Lite bar-chart template with a few inline rows"). * Inline data only — datasets arrive in M3. */ export const SAMPLE_SPEC = { $schema: VEGA_LITE_SCHEMA_URL, description: 'A simple bar chart.', data: { values: [ { category: 'A', value: 28 }, { category: 'B', value: 55 }, { category: 'C', value: 43 }, { category: 'D', value: 91 }, { category: 'E', value: 81 }, ], }, mark: 'bar', encoding: { x: { field: 'category', type: 'nominal', axis: { labelAngle: 0 } }, y: { field: 'value', type: 'quantitative' }, }, } as const; /** The sample template rendered as pretty-printed JSON text. */ export function sampleSpecText(): string { return JSON.stringify(SAMPLE_SPEC, null, 2); } /** Two-digit zero-pad for the date/time name. */ function pad(n: number): string { return String(n).padStart(2, '0'); } /** * Auto-generated default name from a timestamp, e.g. "Snippet 2026-06-04 14:30:07". * Including seconds keeps names unique for snippets created in quick succession. */ export function generateSnippetName(now: Date): string { const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; return `Snippet ${date} ${time}`; } /** Whether `name` is (still) an untouched `generateSnippetName` auto-default. */ export function isDefaultSnippetName(name: string): boolean { return /^Snippet \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(name.trim()); } /** * Whether the snippet's name is app-picked (rewritable on publish) rather than * user-chosen (frozen). Records predating `nameSource` carry no provenance, so * the rule is: never rewrite a name we can't prove the user didn't choose. Two * shapes are provable — the timestamp default, and a name identical to what * the app derives from the snippet's own published spec (only the generator * produces that string for that spec; a user typing it verbatim is naming the * content, which is what tracking preserves). */ export function isAutoNamed(snippet: Pick): boolean { if (snippet.nameSource !== undefined) return snippet.nameSource === 'auto'; if (isDefaultSnippetName(snippet.name)) return true; return deriveSnippetName(snippet.spec) === snippet.name; } /** A channel's encoding definition, as far as naming cares about it. */ interface EncodingDef { field?: unknown; aggregate?: unknown; value?: unknown; } /** A human phrase for what an encoding channel shows, e.g. "sum of revenue". */ function describeEncoding(def: EncodingDef): string | null { if (def.value !== undefined) return null; // a constant — nothing to name const aggregate = typeof def.aggregate === 'string' ? def.aggregate : undefined; if (aggregate === 'count') return 'count'; const field = typeof def.field === 'string' ? def.field.trim() : ''; if (field === '') return null; // repeat refs / missing field — not nameable if (aggregate === 'distinct') return `unique ${field}`; if (aggregate) return `${aggregate} of ${field}`; return field; } /** * Derive a descriptive name from a spec's content — `"Bar chart of by "` * (the same dialect as the Chart Builder's `generateChartName`, so manually * authored and builder-built snippets read alike in the library; NN/g #6 * recognition-over-recall). A spec-level `title` wins verbatim. Returns `null` * when the spec is unparseable or carries too little to describe (no usable * mark/encodings) — callers keep the existing name then. */ export function deriveSnippetName(specText: string): string | null { let spec: unknown; try { spec = JSON.parse(specText); } catch { return null; } if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) return null; const s = spec as Record; // Vega-Lite titles are a string, an array of lines, or a params object whose // `text` is either; all collapse to one line here. const titleText = (value: unknown): string => { if (typeof value === 'string') return value.trim(); if (Array.isArray(value)) return value .filter((line): line is string => typeof line === 'string') .map((line) => line.trim()) .filter(Boolean) .join(' '); if (typeof value === 'object' && value !== null) return titleText((value as { text?: unknown }).text); return ''; }; const title = titleText(s.title); if (title) return title; const markRaw = typeof s.mark === 'string' ? s.mark : typeof s.mark === 'object' && s.mark !== null ? (s.mark as { type?: unknown }).type : undefined; if (typeof markRaw !== 'string' || markRaw === '') return null; const mark = markRaw.charAt(0).toUpperCase() + markRaw.slice(1); const encoding = typeof s.encoding === 'object' && s.encoding !== null ? (s.encoding as Record) : {}; const phraseFor = (channel: string): string | null => { const def = encoding[channel]; if (typeof def !== 'object' || def === null) return null; return describeEncoding(def); }; const x = phraseFor('x'); const y = phraseFor('y'); if (x && y) return `${mark} chart of ${y} by ${x}`; const only = x ?? y ?? phraseFor('theta') ?? phraseFor('color'); if (only) return `${mark} chart of ${only}`; return null; } export interface CreateSnippetOptions { /** Override the auto-generated name. */ name?: string; /** * Name provenance override. Defaults to `'user'` when `name` is given (an * explicit name is presumed chosen) and `'auto'` for the timestamp default; * generators passing a derived `name` (the Chart Builder) say `'auto'` so the * name keeps tracking the spec until the user renames. */ nameSource?: 'auto' | 'user'; /** Override the starting spec text (defaults to the sample template). */ spec?: string; /** Clock injection for deterministic tests; defaults to the current time. */ now?: Date; /** Id injection for deterministic tests; defaults to a random UUID. */ id?: string; /** Seed the extensible metadata bag (e.g. Chart Builder provenance). */ meta?: Record; } /** * Create a new snippet. `spec` and `draftSpec` start identical (nothing to * publish yet); timestamps are equal at creation. */ export function createSnippet(options: CreateSnippetOptions = {}): Snippet { const now = options.now ?? new Date(); const iso = now.toISOString(); const spec = options.spec ?? sampleSpecText(); return { id: options.id ?? crypto.randomUUID(), version: CURRENT_SNIPPET_VERSION, name: options.name ?? generateSnippetName(now), nameSource: options.nameSource ?? (options.name !== undefined ? 'user' : 'auto'), created: iso, modified: iso, spec, draftSpec: spec, comment: '', tags: [], datasetRefs: [], meta: options.meta ?? {}, }; } export interface DuplicateSnippetOptions { /** Clock injection for deterministic tests; defaults to the current time. */ now?: Date; /** Id injection for deterministic tests; defaults to a random UUID. */ id?: string; } /** * Create an independent copy of a snippet (spec §02 → Duplicate). The copy carries * over the specification (both published and draft), comment, tags, and dataset * references, gets a new identity and fresh created/modified timestamps, and a name * suffixed "(copy)". Mutable members are cloned so the copy shares no references * with its source. */ export function duplicateSnippet(source: Snippet, options: DuplicateSnippetOptions = {}): Snippet { const iso = (options.now ?? new Date()).toISOString(); return { ...source, id: options.id ?? crypto.randomUUID(), version: CURRENT_SNIPPET_VERSION, name: `${source.name} (copy)`, created: iso, modified: iso, tags: [...source.tags], datasetRefs: [...source.datasetRefs], meta: { ...source.meta }, }; } /** True when the snippet's draft differs from its published spec (§03D). */ export function hasUnpublishedChanges(snippet: Snippet): boolean { return snippet.draftSpec !== snippet.spec; } /** * Approximate payload size of a snippet, in bytes (spec §09 `size`). Measured on * the working draft — the version the editor and preview currently show — as its * UTF-8 byte length, so the figure tracks what the user is actually editing. * `TextEncoder` is a platform global (like `crypto.randomUUID` above), not a * browser/DOM API, so it stays within the portable core. */ export function snippetSizeBytes(snippet: Snippet): number { return new TextEncoder().encode(snippet.draftSpec).length; } /** Below this we omit the size in the library to reduce clutter (spec §02). */ const SIZE_DISPLAY_THRESHOLD = 1024; /** * Human-readable size for the library row, or `null` when the snippet is small * enough that the spec says to omit it (under ~1 KB). Rounded to whole KB/MB — * a list hint, not a precise measure. */ export function formatSnippetSize(bytes: number): string | null { if (bytes < SIZE_DISPLAY_THRESHOLD) return null; const kb = bytes / 1024; if (kb < 1024) return `${Math.round(kb)} KB`; return `${Math.round(kb / 1024)} MB`; }