Snapshot URL datasets locally on add; preview tabular data as a table

This commit is contained in:
2026-06-10 10:24:42 +03:00
parent eb5e7ac53a
commit 2410c6e965
23 changed files with 1239 additions and 148 deletions
+141 -19
View File
@@ -19,8 +19,14 @@
*/
import { create } from 'zustand';
import { detectFormat, detectFormatFromUrl, type DataFormat } from '@core/format-detection';
import { createDataset, computeDatasetProfile, type DataSource, type Dataset } from '@core/dataset';
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';
@@ -67,6 +73,21 @@ export interface DatasetState {
*/
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
@@ -144,7 +165,12 @@ function validateForm(
return null;
}
/** Validate a form into a saveable shape, or return an error message. */
/**
* 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[],
@@ -153,20 +179,12 @@ function resolveForm(
const error = validateForm(form, datasets, excludeId);
if (error) return { error };
const name = form.name.trim();
const input = form.input.trim();
if (form.source === 'url') {
// Format is inferred from the extension; default to JSON when unknown (§05).
return { name, data: input, format: detectFormatFromUrl(input) ?? 'json', source: 'url' };
}
// 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, data, format, source: 'inline' };
return { name: form.name.trim(), data, format, source: 'inline' };
}
export const useDatasetStore = create<DatasetState>((set, get) => ({
@@ -201,12 +219,14 @@ export const useDatasetStore = create<DatasetState>((set, get) => ({
name: ds.name,
source: ds.source,
comment: ds.comment,
// Re-render the stored payload as editable text: raw for csv/tsv/url,
// pretty-printed JSON for json/topojson.
// 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.format === 'csv' || ds.format === 'tsv'
? String(ds.data)
: JSON.stringify(ds.data, null, 2),
ds.source === 'url'
? (ds.url ?? '')
: ds.format === 'csv' || ds.format === 'tsv'
? String(ds.data)
: JSON.stringify(ds.data, null, 2),
},
});
},
@@ -225,6 +245,30 @@ export const useDatasetStore = create<DatasetState>((set, 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 });
@@ -234,11 +278,12 @@ export const useDatasetStore = create<DatasetState>((set, get) => ({
if (editing && selectedId !== null) {
const existing = datasets.find((d) => d.id === selectedId);
if (!existing) return false;
const profile = computeDatasetProfile(resolved.data, resolved.format, resolved.source);
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.
// 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,
{
@@ -246,6 +291,13 @@ export const useDatasetStore = create<DatasetState>((set, get) => ({
data: resolved.data,
format: resolved.format,
source: resolved.source,
// TODO: `update` shallow-merges, so these set the keys to `undefined`
// rather than removing them — a URL→inline record keeps `url`/`fetchedAt`
// present-but-undefined. Invisible today (rendering/profiling key off
// `source`/`data == null`, and JSON export drops undefined), but it breaks
// the "inline records carry no url/fetchedAt keys" invariant on this path.
url: undefined,
fetchedAt: undefined,
comment: form.comment,
...profile,
},
@@ -275,6 +327,76 @@ export const useDatasetStore = create<DatasetState>((set, get) => ({
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) };