mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Snapshot URL datasets locally on add; preview tabular data as a table
This commit is contained in:
+141
-19
@@ -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) };
|
||||
|
||||
Reference in New Issue
Block a user