mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Editor: view-scoped Extract-to-Dataset for inline and self-defined data
This commit is contained in:
@@ -1,92 +1,86 @@
|
||||
/**
|
||||
* Extract-inline-data → Dataset state (spec §03F).
|
||||
* Extract-embedded-data → Dataset state (spec §03F).
|
||||
*
|
||||
* Backs the Extract modal: the reverse of a named reference. It captures the
|
||||
* active snippet draft's inline data, takes a dataset name, and on confirm saves
|
||||
* the data as a new dataset and rewrites the draft so the inline data is replaced
|
||||
* by a by-name reference (`{ "data": { "name": … } }`).
|
||||
* 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.
|
||||
*
|
||||
* Scope (M3): the **top-level** `data` block of the draft spec — the common case
|
||||
* for a single-view chart. Inline data nested inside layers/concats is left for a
|
||||
* later pass; `hasInlineData` reflects exactly what `confirm` can lift, so the
|
||||
* editor only offers the action when this store can act on it.
|
||||
* 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 type { DataFormat } from '@core/format-detection';
|
||||
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 inline `data` block of a parsed spec, if it carries `values`. */
|
||||
interface InlineData {
|
||||
values: unknown;
|
||||
format: DataFormat;
|
||||
}
|
||||
/** The path of the view whose `data` block Extract rewrites (`[]` = root). */
|
||||
type AnchorPath = ReadonlyArray<string | number>;
|
||||
|
||||
/**
|
||||
* Read the top-level inline data from a draft spec's text, or null when there is
|
||||
* none (no snippet, unparseable, or no `data.values`). The format comes from an
|
||||
* explicit `data.format.type` when present (raw CSV/TSV strings), else JSON.
|
||||
*/
|
||||
function readInlineData(draftText: string): InlineData | null {
|
||||
let parsed: unknown;
|
||||
/** 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 {
|
||||
parsed = JSON.parse(draftText);
|
||||
return specHasExtractableData(JSON.parse(draftText));
|
||||
} catch {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const data = (parsed as Record<string, unknown>).data;
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const values = (data as Record<string, unknown>).values;
|
||||
if (values === undefined) return null;
|
||||
const declared = (data as Record<string, unknown>).format;
|
||||
const type =
|
||||
declared && typeof declared === 'object'
|
||||
? (declared as Record<string, unknown>).type
|
||||
: undefined;
|
||||
const format: DataFormat =
|
||||
type === 'csv' || type === 'tsv' || type === 'topojson' ? type : 'json';
|
||||
return { values, format };
|
||||
}
|
||||
|
||||
/** True when the active snippet's draft has top-level inline data to extract. */
|
||||
export function hasInlineData(draftText: string): boolean {
|
||||
return readInlineData(draftText) !== null;
|
||||
}
|
||||
|
||||
export interface ExtractState {
|
||||
/** Proposed dataset name (required, unique). */
|
||||
name: string;
|
||||
/** The inline data captured at open, for the read-only preview. */
|
||||
source: InlineData | null;
|
||||
/** 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;
|
||||
|
||||
/** Capture the active snippet's inline data and reset the form. */
|
||||
init: () => void;
|
||||
/** 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 active draft to reference it by
|
||||
* name. Returns whether it committed; on failure `error` is set. `now`
|
||||
* injectable for tests.
|
||||
* 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 InlineData | null, error: null as string | null };
|
||||
const INITIAL = {
|
||||
name: '',
|
||||
source: null as InlinePayload | null,
|
||||
target: null as ExtractTarget | null,
|
||||
error: null as string | null,
|
||||
};
|
||||
|
||||
export const useExtractStore = create<ExtractState>((set, get) => ({
|
||||
...INITIAL,
|
||||
|
||||
init: () => {
|
||||
const draft = useSnippetStore.getState().draftText;
|
||||
set({ name: '', source: readInlineData(draft), error: null });
|
||||
},
|
||||
begin: ({ source, target, name = '' }) => set({ name, source, target, error: null }),
|
||||
|
||||
setName: (name) => set({ name, error: null }),
|
||||
|
||||
@@ -100,13 +94,42 @@ export const useExtractStore = create<ExtractState>((set, get) => ({
|
||||
set({ error: `A dataset named "${name}" already exists. Choose a different name.` });
|
||||
return false;
|
||||
}
|
||||
const source = get().source;
|
||||
if (!source) {
|
||||
set({ error: 'No inline data to extract.' });
|
||||
const { source, target } = get();
|
||||
if (!source || !target) {
|
||||
set({ error: 'No data to extract.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON/TopoJSON store the parsed value; CSV/TSV keep raw text — but inline
|
||||
// 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,
|
||||
@@ -117,20 +140,17 @@ export const useExtractStore = create<ExtractState>((set, get) => ({
|
||||
});
|
||||
useDatasetStore.getState().add(dataset);
|
||||
|
||||
// Rewrite the top-level data block to a by-name reference, preserving the rest
|
||||
// of the spec and its pretty-printed text shape.
|
||||
const draftText = useSnippetStore.getState().draftText;
|
||||
const spec = JSON.parse(draftText) as Record<string, unknown>;
|
||||
spec.data = { name };
|
||||
useSnippetStore.getState().replaceActiveDraft(JSON.stringify(spec, null, 2), now);
|
||||
// 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).
|
||||
// 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 inline data.`,
|
||||
message: `The spec now references "${name}" instead of its embedded data.`,
|
||||
});
|
||||
set(INITIAL);
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user