mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
456 lines
17 KiB
TypeScript
456 lines
17 KiB
TypeScript
/**
|
|
* Dataset library state (spec §05, docs/architecture/01 + 07).
|
|
*
|
|
* Holds the durable dataset collection plus the Datasets-manager view state: which
|
|
* dataset is selected, which pane is showing (list browse, detail, the create
|
|
* form, or the edit form), and the in-progress create/edit form with its inline
|
|
* validation error.
|
|
*
|
|
* Two layers of action live here:
|
|
* - Low-level mutators (`add`/`update`/`remove`) are the single place the
|
|
* `datasets` array changes; the persistence subscriber writes them through to
|
|
* IndexedDB, and services (e.g. the import flow in services/transfer) reuse them.
|
|
* - Form orchestration (`save`) validates the form (name required + unique,
|
|
* data detectable) and turns it into an `add`/`update`, keeping the modal
|
|
* component thin.
|
|
*
|
|
* Persistence is NOT done here — a startup subscriber observes this store and
|
|
* writes through to the IndexedDB adapter, so the store stays browser-free.
|
|
*/
|
|
|
|
import { create } from 'zustand';
|
|
import { detectFormat, type DataFormat } from '@core/format-detection';
|
|
import {
|
|
createDataset,
|
|
computeDatasetProfile,
|
|
snapshotFromText,
|
|
type DataSource,
|
|
type Dataset,
|
|
} from '@core/dataset';
|
|
import { isNameTaken } from '@core/naming';
|
|
import { useSnippetStore } from './SnippetStore';
|
|
|
|
/** Which pane the Datasets manager is showing (spec §05 → Layout). */
|
|
type DatasetView = 'list' | 'detail' | 'new' | 'edit';
|
|
|
|
/** The in-progress create/edit form. `input` is the paste area (inline) or URL field. */
|
|
export interface DatasetForm {
|
|
name: string;
|
|
source: DataSource;
|
|
input: string;
|
|
comment: string;
|
|
}
|
|
|
|
const EMPTY_FORM: DatasetForm = { name: '', source: 'inline', input: '', comment: '' };
|
|
|
|
export interface DatasetState {
|
|
datasets: Dataset[];
|
|
selectedId: number | null;
|
|
view: DatasetView;
|
|
form: DatasetForm;
|
|
/** Inline validation message for the create/edit form, or null when valid. */
|
|
formError: string | null;
|
|
|
|
/** Replace the library from storage. */
|
|
hydrate: (datasets: Dataset[]) => void;
|
|
/** Select a dataset (→ detail), or clear the selection (→ list). */
|
|
select: (id: number | null) => void;
|
|
/** Set the manager view directly (used by the modal's own navigation). */
|
|
setView: (view: DatasetView) => void;
|
|
/** Open the create form with an empty draft. */
|
|
startCreate: () => void;
|
|
/** Open the edit form pre-filled from the selected dataset. */
|
|
startEdit: () => void;
|
|
/** Patch the in-progress form (clears any stale error). */
|
|
updateForm: (patch: Partial<DatasetForm>) => void;
|
|
/** Leave the form, returning to the selected detail or the list. */
|
|
cancelForm: () => void;
|
|
/**
|
|
* Validate and commit the current form: creates a new dataset (view `new`) or
|
|
* updates the selected one (view `edit`), re-profiling its data and renaming
|
|
* referencing snippets when an edit changes the name. Returns whether it
|
|
* committed; on failure `formError` is set. `now` is injectable for tests.
|
|
*/
|
|
save: (now?: Date) => boolean;
|
|
|
|
/**
|
|
* Commit a URL dataset from an already-fetched body. The component performs the
|
|
* network fetch via the remote-data adapter and passes the result here, keeping
|
|
* this store browser-free. Validates the form, snapshots + profiles the body, and
|
|
* creates (view `new`) or updates (view `edit`) the dataset — including renaming
|
|
* referencing snippets on an edit. Returns whether it committed.
|
|
*/
|
|
commitUrlSnapshot: (fetched: { text: string }, now?: Date) => boolean;
|
|
/**
|
|
* Re-snapshot an existing URL dataset from a freshly-fetched body ("Refresh"):
|
|
* re-detect the format, re-profile, and advance `fetchedAt`/`modified`. Name,
|
|
* comment, and URL are preserved. Returns false for a non-URL dataset.
|
|
*/
|
|
refreshDataset: (id: number, fetched: { text: string }, now?: Date) => boolean;
|
|
|
|
/**
|
|
* Low-level: add a fully-formed dataset and select it. The id is (re)assigned
|
|
* via `nextDatasetId`, so a `createDataset` default id (`Date.now()`) can never
|
|
* reach storage — see `nextDatasetId` for why that matters.
|
|
*/
|
|
add: (dataset: Dataset) => void;
|
|
/**
|
|
* Append imported datasets (spec §08 → datasets imported before snippets). Each
|
|
* is given a fresh monotonic numeric id (`nextDatasetId`, the same authority as
|
|
* `add`) so a batch never collides on the `Date.now()` default nor with existing
|
|
* ids — safe because datasets are referenced by **name**, not id
|
|
* (docs/architecture/07 §1). Names are assumed already de-duped by the import
|
|
* service. Selection is unchanged.
|
|
*/
|
|
addDatasets: (incoming: Dataset[]) => void;
|
|
/** Low-level: merge a patch into a dataset, advancing `modified`. */
|
|
update: (id: number, patch: Partial<Dataset>, now?: Date) => void;
|
|
/** Low-level: remove a dataset; clears the selection if it was selected. */
|
|
remove: (id: number) => void;
|
|
|
|
/** Reset to initial state (tests, future "new workspace"). */
|
|
reset: () => void;
|
|
}
|
|
|
|
/** Newest-modified first — the manager's default ordering (spec §05 → Layout). */
|
|
export function byModifiedDesc(a: Dataset, b: Dataset): number {
|
|
return b.modified.localeCompare(a.modified);
|
|
}
|
|
|
|
/**
|
|
* Next free numeric id for a dataset: one past the current maximum. This store is
|
|
* the **single id authority** — both single (`add`) and batch (`addDatasets`)
|
|
* insertion route through it — so a `createDataset` default id (`Date.now()`)
|
|
* never reaches IndexedDB, where a tight creation loop (the documented import path)
|
|
* could repeat the same millisecond and collide on the numeric key. Dataset ids
|
|
* are an internal IndexedDB key only — snippets reference datasets by **name**
|
|
* (docs/architecture/07 §1) — so reusing an id freed by a deletion is harmless,
|
|
* which is why one-past-the-max suffices without a persistent counter.
|
|
*/
|
|
function nextDatasetId(datasets: ReadonlyArray<Dataset>): number {
|
|
return datasets.reduce((max, d) => Math.max(max, d.id), 0) + 1;
|
|
}
|
|
|
|
/**
|
|
* Cheap form validation: returns an error message, or `null` when the form is
|
|
* saveable. Deliberately does **not** `JSON.parse` the input — it only checks
|
|
* name/input presence, name uniqueness, and that a format is detectable — so it
|
|
* is safe to run on every render to drive the Save button's disabled state
|
|
* (spec §05). Error copy follows the council resolution (docs/architecture/10
|
|
* §error copy → GOV.UK error-message): action-oriented, specific, says how to fix.
|
|
*/
|
|
function validateForm(
|
|
form: DatasetForm,
|
|
datasets: Dataset[],
|
|
excludeId: number | undefined,
|
|
): string | null {
|
|
const name = form.name.trim();
|
|
if (name === '') return 'Enter a dataset name.';
|
|
if (isNameTaken(name, datasets, excludeId)) {
|
|
return `A dataset named "${name}" already exists. Choose a different name.`;
|
|
}
|
|
|
|
const input = form.input.trim();
|
|
if (input === '') {
|
|
return form.source === 'url' ? 'Enter a URL.' : 'Paste JSON, CSV, or TSV data to save.';
|
|
}
|
|
|
|
if (form.source === 'url') {
|
|
if (!/^https?:\/\//i.test(input)) return 'Enter a URL starting with http:// or https://.';
|
|
return null;
|
|
}
|
|
|
|
// Inline: the data must auto-detect to a known format (spec §05 → Auto-detection).
|
|
if (!detectFormat(form.input).format) return 'Enter valid JSON, CSV, or TSV data.';
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Validate an **inline** form into a saveable shape, or return an error message.
|
|
* URL datasets never reach here — they are fetched first and committed through
|
|
* `commitUrlSnapshot` (which snapshots the fetched body), so this only shapes the
|
|
* pasted-inline payload.
|
|
*/
|
|
function resolveForm(
|
|
form: DatasetForm,
|
|
datasets: Dataset[],
|
|
excludeId: number | undefined,
|
|
): { name: string; data: unknown; format: DataFormat; source: DataSource } | { error: string } {
|
|
const error = validateForm(form, datasets, excludeId);
|
|
if (error) return { error };
|
|
|
|
// validateForm guaranteed a detectable format above.
|
|
const format = detectFormat(form.input).format as DataFormat;
|
|
// JSON/TopoJSON are stored parsed; CSV/TSV keep their raw text (data model §09B).
|
|
const data =
|
|
format === 'json' || format === 'topojson' ? (JSON.parse(form.input) as unknown) : form.input;
|
|
return { name: form.name.trim(), data, format, source: 'inline' };
|
|
}
|
|
|
|
export const useDatasetStore = create<DatasetState>((set, get) => ({
|
|
datasets: [],
|
|
selectedId: null,
|
|
view: 'list',
|
|
form: EMPTY_FORM,
|
|
formError: null,
|
|
|
|
hydrate: (datasets) => set({ datasets }),
|
|
|
|
select: (id) =>
|
|
set({
|
|
selectedId: id,
|
|
view: id === null ? 'list' : 'detail',
|
|
form: EMPTY_FORM,
|
|
formError: null,
|
|
}),
|
|
|
|
setView: (view) => set({ view }),
|
|
|
|
startCreate: () => set({ view: 'new', form: EMPTY_FORM, formError: null }),
|
|
|
|
startEdit: () => {
|
|
const { datasets, selectedId } = get();
|
|
const ds = datasets.find((d) => d.id === selectedId);
|
|
if (!ds) return;
|
|
set({
|
|
view: 'edit',
|
|
formError: null,
|
|
form: {
|
|
name: ds.name,
|
|
source: ds.source,
|
|
comment: ds.comment,
|
|
// Re-render the editable text: the URL for url datasets, raw text for
|
|
// csv/tsv, pretty-printed JSON for json/topojson.
|
|
input:
|
|
ds.source === 'url'
|
|
? (ds.url ?? '')
|
|
: ds.format === 'csv' || ds.format === 'tsv'
|
|
? String(ds.data)
|
|
: JSON.stringify(ds.data, null, 2),
|
|
},
|
|
});
|
|
},
|
|
|
|
updateForm: (patch) => set((s) => ({ form: { ...s.form, ...patch }, formError: null })),
|
|
|
|
cancelForm: () =>
|
|
set((s) => ({
|
|
view: s.selectedId === null ? 'list' : 'detail',
|
|
form: EMPTY_FORM,
|
|
formError: null,
|
|
})),
|
|
|
|
save: (now) => {
|
|
const { view, form, datasets, selectedId } = get();
|
|
const editing = view === 'edit';
|
|
const excludeId = editing ? (selectedId ?? undefined) : undefined;
|
|
|
|
// URL datasets that need the network — a create, a URL change, or an inline→URL
|
|
// conversion — are routed through `commitUrlSnapshot` after the component
|
|
// fetches; save() never fetches. The one URL case it commits is a metadata-only
|
|
// edit of an existing snapshot (same URL): just update name/comment, no re-fetch.
|
|
if (form.source === 'url') {
|
|
const error = validateForm(form, datasets, excludeId);
|
|
if (error) {
|
|
set({ formError: error });
|
|
return false;
|
|
}
|
|
if (!editing || selectedId === null) return false;
|
|
const existing = datasets.find((d) => d.id === selectedId);
|
|
if (!existing || existing.source !== 'url' || form.input.trim() !== (existing.url ?? '')) {
|
|
return false;
|
|
}
|
|
const name = form.name.trim();
|
|
get().update(selectedId, { name, comment: form.comment }, now);
|
|
if (name !== existing.name) {
|
|
useSnippetStore.getState().renameDatasetRefs(existing.name, name, now);
|
|
}
|
|
set({ view: 'detail', form: EMPTY_FORM, formError: null });
|
|
return true;
|
|
}
|
|
|
|
const resolved = resolveForm(form, datasets, excludeId);
|
|
if ('error' in resolved) {
|
|
set({ formError: resolved.error });
|
|
return false;
|
|
}
|
|
|
|
if (editing && selectedId !== null) {
|
|
const existing = datasets.find((d) => d.id === selectedId);
|
|
if (!existing) return false;
|
|
const profile = computeDatasetProfile(resolved.data, resolved.format);
|
|
// Re-profile and update the record (including any new name), then propagate
|
|
// the rename across referencing snippets so each spec and its datasetRefs
|
|
// stay consistent (docs/architecture/07 §6). SnippetStore never imports this
|
|
// store, so the direct call is cycle-free. Clear any url/fetchedAt left over
|
|
// from a URL→inline conversion so the record carries no stale remote origin.
|
|
get().update(
|
|
selectedId,
|
|
{
|
|
name: resolved.name,
|
|
data: resolved.data,
|
|
format: resolved.format,
|
|
source: resolved.source,
|
|
// A URL→inline conversion must shed the remote origin; `update` deletes
|
|
// keys set to `undefined`, so these are removed rather than left
|
|
// present-but-undefined.
|
|
url: undefined,
|
|
fetchedAt: undefined,
|
|
comment: form.comment,
|
|
...profile,
|
|
},
|
|
now,
|
|
);
|
|
if (resolved.name !== existing.name) {
|
|
useSnippetStore.getState().renameDatasetRefs(existing.name, resolved.name, now);
|
|
}
|
|
set({ view: 'detail', form: EMPTY_FORM, formError: null });
|
|
return true;
|
|
}
|
|
|
|
const dataset = createDataset({
|
|
name: resolved.name,
|
|
data: resolved.data,
|
|
format: resolved.format,
|
|
source: resolved.source,
|
|
comment: form.comment,
|
|
now,
|
|
});
|
|
get().add(dataset);
|
|
// Switching to the detail view with the new dataset selected IS the success
|
|
// confirmation, so no toast is raised — the result is on-screen (spec §05;
|
|
// docs/architecture/10 → Toast copy). Extract-to-dataset, which creates a
|
|
// dataset off-screen, does toast (see ExtractStore).
|
|
set({ view: 'detail', form: EMPTY_FORM, formError: null });
|
|
return true;
|
|
},
|
|
|
|
commitUrlSnapshot: (fetched, now) => {
|
|
const { view, form, datasets, selectedId } = get();
|
|
const editing = view === 'edit';
|
|
const excludeId = editing ? (selectedId ?? undefined) : undefined;
|
|
|
|
const error = validateForm(form, datasets, excludeId);
|
|
if (error) {
|
|
set({ formError: error });
|
|
return false;
|
|
}
|
|
|
|
const name = form.name.trim();
|
|
const url = form.input.trim();
|
|
const iso = (now ?? new Date()).toISOString();
|
|
// The fetched body snapshots + profiles exactly like inline data (snapshot
|
|
// model): detect format from content, shape, and profile through core.
|
|
const { data, format } = snapshotFromText(fetched.text, url);
|
|
|
|
if (editing && selectedId !== null) {
|
|
const existing = datasets.find((d) => d.id === selectedId);
|
|
if (!existing) return false;
|
|
const profile = computeDatasetProfile(data, format);
|
|
get().update(
|
|
selectedId,
|
|
{
|
|
name,
|
|
data,
|
|
format,
|
|
source: 'url',
|
|
url,
|
|
fetchedAt: iso,
|
|
comment: form.comment,
|
|
...profile,
|
|
},
|
|
now,
|
|
);
|
|
if (name !== existing.name) {
|
|
useSnippetStore.getState().renameDatasetRefs(existing.name, name, now);
|
|
}
|
|
set({ view: 'detail', form: EMPTY_FORM, formError: null });
|
|
return true;
|
|
}
|
|
|
|
const dataset = createDataset({
|
|
name,
|
|
data,
|
|
format,
|
|
source: 'url',
|
|
url,
|
|
fetchedAt: iso,
|
|
comment: form.comment,
|
|
now,
|
|
});
|
|
get().add(dataset);
|
|
set({ view: 'detail', form: EMPTY_FORM, formError: null });
|
|
return true;
|
|
},
|
|
|
|
refreshDataset: (id, fetched, now) => {
|
|
const dataset = get().datasets.find((d) => d.id === id);
|
|
if (!dataset || dataset.source !== 'url' || !dataset.url) return false;
|
|
const iso = (now ?? new Date()).toISOString();
|
|
const { data, format } = snapshotFromText(fetched.text, dataset.url);
|
|
const profile = computeDatasetProfile(data, format);
|
|
// Name, comment, and url are preserved; only the snapshot + its profile and the
|
|
// fetch time change. `update` advances `modified`.
|
|
get().update(id, { data, format, fetchedAt: iso, ...profile }, now);
|
|
return true;
|
|
},
|
|
|
|
add: (dataset) =>
|
|
set((s) => {
|
|
const withId = { ...dataset, id: nextDatasetId(s.datasets) };
|
|
return { datasets: [withId, ...s.datasets], selectedId: withId.id };
|
|
}),
|
|
|
|
addDatasets: (incoming) => {
|
|
if (incoming.length === 0) return;
|
|
set((s) => {
|
|
let nextId = nextDatasetId(s.datasets);
|
|
const withIds = incoming.map((d) => ({ ...d, id: nextId++ }));
|
|
return { datasets: [...withIds, ...s.datasets] };
|
|
});
|
|
},
|
|
|
|
update: (id, patch, now) => {
|
|
const modified = patch.modified ?? (now ?? new Date()).toISOString();
|
|
set((s) => ({
|
|
datasets: s.datasets.map((d) => {
|
|
if (d.id !== id) return d;
|
|
const merged = { ...d, ...patch, modified };
|
|
// A patch key set to `undefined` removes it, rather than leaving a
|
|
// present-but-undefined key — so a URL→inline conversion drops `url`/
|
|
// `fetchedAt` cleanly, keeping the "inline records carry no remote-origin
|
|
// keys" invariant (docs/architecture/07 §6).
|
|
for (const key of Object.keys(patch) as (keyof Dataset)[]) {
|
|
if (patch[key] === undefined) delete merged[key];
|
|
}
|
|
return merged;
|
|
}),
|
|
}));
|
|
},
|
|
|
|
remove: (id) =>
|
|
set((s) => ({
|
|
datasets: s.datasets.filter((d) => d.id !== id),
|
|
selectedId: s.selectedId === id ? null : s.selectedId,
|
|
view: s.selectedId === id ? 'list' : s.view,
|
|
form: s.selectedId === id ? EMPTY_FORM : s.form,
|
|
})),
|
|
|
|
reset: () =>
|
|
set({ datasets: [], selectedId: null, view: 'list', form: EMPTY_FORM, formError: null }),
|
|
}));
|
|
|
|
/** Selector: the selected dataset record, or null. Derive — never store. */
|
|
export const selectSelectedDataset = (s: DatasetState): Dataset | null =>
|
|
s.datasets.find((d) => d.id === s.selectedId) ?? null;
|
|
|
|
/**
|
|
* Selector: whether the current form is saveable, powering the Save button's
|
|
* disabled state (spec §05 → Save is disabled until a name and valid data/URL
|
|
* are present). Mirrors the `save()` action's `excludeId` derivation so an edit
|
|
* can keep its own name.
|
|
*/
|
|
export const selectCanSave = (s: DatasetState): boolean =>
|
|
validateForm(s.form, s.datasets, s.view === 'edit' ? (s.selectedId ?? undefined) : undefined) ===
|
|
null;
|