/** * Extract-embedded-data → Dataset state (spec §03F). * * Backs the Extract modal: the reverse of a named reference. It captures the * embedded data of the **view the cursor sits in**, takes a dataset name, and on * confirm saves the data as a new library dataset and rewrites the spec so the * embedded data is replaced by a by-name reference. * * Two shapes of embedded data, captured as a `target` (spec §03F; multi-view scope * doc M3): * - **inline** — a view's `data.values`. Confirm rewrites that view's `data` * block at its anchor path to `{ name }`. * - **self-defined** — a `{ name: X }` reference to the spec's own top-level * `datasets.X`. Confirm removes the `datasets` entry (and the map when it * empties); the reference resolves to the new library dataset, renamed when the * name changes (`core/spec-refs` → `promoteSelfDefinedDataset`). * * The store is the editor-free side of the flow: it never reads the cursor or the * Monaco model — `services/extract-action` resolves the focused binding and seeds * it via `begin`, so this stays a plain, testable store. */ import { create } from 'zustand'; import { createDataset } from '@core/dataset'; import { formatSpec } from '@core/json-format'; import { isNameTaken } from '@core/naming'; import { setDataBindingAtPath } from '@core/spec-data'; import { type InlinePayload, specHasExtractableData } from '@core/spec-inline-data'; import { promoteSelfDefinedDataset } from '@core/spec-refs'; import { useDatasetStore } from './DatasetStore'; import { notify } from './NotificationStore'; import { useSnippetStore } from './SnippetStore'; /** The path of the view whose `data` block Extract rewrites (`[]` = root). */ type AnchorPath = ReadonlyArray; /** What confirm rewrites — an inline `data` block, or a self-defined `datasets` entry. */ export type ExtractTarget = | { kind: 'inline'; anchorPath: AnchorPath } | { kind: 'self-defined'; datasetName: string }; /** True when the active snippet's draft has data Extract can lift, in any view. */ export function hasExtractableData(draftText: string): boolean { try { return specHasExtractableData(JSON.parse(draftText)); } catch { return false; } } export interface ExtractState { /** Proposed dataset name (required, unique). */ name: string; /** The focused view's embedded data captured at open, for the read-only preview. */ source: InlinePayload | null; /** Where `confirm` writes the by-name reference. */ target: ExtractTarget | null; /** Inline validation message, or null. */ error: string | null; /** Seed the form with the focused view's captured data (extract-action). */ begin: (captured: { source: InlinePayload; target: ExtractTarget; name?: string }) => void; setName: (name: string) => void; /** * Validate, create the dataset, and rewrite the spec to reference it by name. * Returns whether it committed; on failure `error` is set. `now` injectable for * tests. */ confirm: (now?: Date) => boolean; reset: () => void; } const INITIAL = { name: '', source: null as InlinePayload | null, target: null as ExtractTarget | null, error: null as string | null, }; export const useExtractStore = create((set, get) => ({ ...INITIAL, begin: ({ source, target, name = '' }) => set({ name, source, target, error: null }), setName: (name) => set({ name, error: null }), confirm: (now) => { const name = get().name.trim(); if (name === '') { set({ error: 'Enter a dataset name.' }); return false; } if (isNameTaken(name, useDatasetStore.getState().datasets)) { set({ error: `A dataset named "${name}" already exists. Choose a different name.` }); return false; } const { source, target } = get(); if (!source || !target) { set({ error: 'No data to extract.' }); return false; } // Resolve the rewrite before any side effect: parse the live draft and apply the // target's rewrite to a fresh copy. Both can fail (the draft is no longer valid // JSON, or its shape changed under the modal); bail with a message and create // nothing rather than leave a half-done extraction. const draftText = useSnippetStore.getState().draftText; let spec: unknown; try { spec = JSON.parse(draftText); } catch { set({ error: 'The spec is no longer valid JSON. Close and reopen Extract.' }); return false; } let rewritten: unknown; if (target.kind === 'inline') { if (!setDataBindingAtPath(spec, target.anchorPath, { name })) { set({ error: 'Could not locate the data to replace. Close and reopen Extract.' }); return false; } rewritten = spec; } else { const next = promoteSelfDefinedDataset(spec, target.datasetName, name); if (!next) { set({ error: 'Could not locate the data to replace. Close and reopen Extract.' }); return false; } rewritten = next; } // JSON/TopoJSON store the parsed value; CSV/TSV keep raw text — but the captured // `values` is already the right runtime shape for each, so store it directly. const dataset = createDataset({ name, data: source.values, format: source.format, source: 'inline', now, }); useDatasetStore.getState().add(dataset); // Re-serialize in the app's house style (json-format), preserving the spec and // only swapping the captured data for the reference. useSnippetStore.getState().replaceActiveDraft(formatSpec(rewritten), now); // Success confirmation (spec §03F). Title states the action; the message adds // the consequence — the spec was rewritten to reference the new dataset by name // (council toast-copy rule, docs/architecture/10 → Toast copy). notify({ kind: 'success', title: 'Dataset created', message: `The spec now references "${name}" instead of its embedded data.`, }); set(INITIAL); return true; }, reset: () => set(INITIAL), }));