mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Workspace transfer: custom themes ride the export/import envelope
This commit is contained in:
@@ -286,42 +286,44 @@ reactive code.
|
||||
|
||||
## 5. Import: auto-suffix collisions, then report
|
||||
|
||||
On import we never overwrite an existing dataset. A dataset whose name collides
|
||||
On import we never overwrite an existing record. A record whose name collides
|
||||
is renamed to a unique name via `makeUniqueName`, and **every rename is
|
||||
collected and reported to the user** (toast / summary) so the change is never
|
||||
silent. Crucially, names are reserved _as we go_ — within a single import, two
|
||||
incoming `Sales` datasets become `Sales 2` and `Sales 3`, not two `Sales 2`.
|
||||
The helper is generic over `{ name: string }` because two record kinds key on a
|
||||
unique name: datasets and custom chart themes (only dataset renames need
|
||||
propagation — nothing references a theme by name).
|
||||
|
||||
```ts
|
||||
// src/core/import-normalize.ts
|
||||
|
||||
import { makeUniqueName } from './naming';
|
||||
import type { Dataset } from './dataset';
|
||||
|
||||
export interface DatasetRename {
|
||||
export interface NameRename {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns incoming datasets with collision-free names, plus the renames applied.
|
||||
* `existing` are names already in the library; `incoming` are datasets to add.
|
||||
* Returns incoming records with collision-free names, plus the renames applied.
|
||||
* `existing` are names already in the collection; `incoming` are records to add.
|
||||
*/
|
||||
export function dedupeIncomingDatasetNames(
|
||||
export function dedupeIncomingNames<T extends { name: string }>(
|
||||
existing: ReadonlyArray<string>,
|
||||
incoming: ReadonlyArray<Dataset>,
|
||||
): { datasets: Dataset[]; renames: DatasetRename[] } {
|
||||
incoming: ReadonlyArray<T>,
|
||||
): { records: T[]; renames: NameRename[] } {
|
||||
const reserved = new Set(existing.map((n) => n.toLowerCase()));
|
||||
const renames: DatasetRename[] = [];
|
||||
const renames: NameRename[] = [];
|
||||
|
||||
const datasets = incoming.map((d) => {
|
||||
const unique = makeUniqueName(d.name, reserved);
|
||||
const records = incoming.map((r) => {
|
||||
const unique = makeUniqueName(r.name, reserved);
|
||||
reserved.add(unique.toLowerCase()); // reserve so later imports don't collide
|
||||
if (unique !== d.name) renames.push({ from: d.name, to: unique });
|
||||
return unique === d.name ? d : { ...d, name: unique };
|
||||
if (unique !== r.name) renames.push({ from: r.name, to: unique });
|
||||
return unique === r.name ? r : { ...r, name: unique };
|
||||
});
|
||||
|
||||
return { datasets, renames };
|
||||
return { records, renames };
|
||||
}
|
||||
```
|
||||
|
||||
@@ -471,7 +473,7 @@ user action — there is no separate coordinator module to call.
|
||||
| `renameDatasetInSpec` | `src/core/spec-refs.ts` | yes | unit |
|
||||
| `snippetsReferencingDataset`, `datasetUsageCounts` (reverse-lookup scan) | `src/core/relationships.ts` | yes | unit |
|
||||
| `renameDatasetRefs` → updated count (rename propagation) | `src/app/stores/SnippetStore.ts` | no (mutates stores) | integration |
|
||||
| `dedupeIncomingDatasetNames` | `src/core/import-normalize.ts` | yes | unit |
|
||||
| `dedupeIncomingNames` (datasets + custom themes) | `src/core/import-normalize.ts` | yes | unit |
|
||||
|
||||
The dividing line: anything that takes plain data and returns plain data is
|
||||
**core** and unit-tested in isolation; anything that reaches into a Zustand store
|
||||
|
||||
@@ -142,7 +142,7 @@ ships, it is an explicit per-font user action, never automatic.
|
||||
(spec §03G): bake the active theme into `spec.config` (existing keys win,
|
||||
render-identical), or lift `spec.config` out to the clipboard (copy before remove —
|
||||
a failed copy aborts).
|
||||
4. **Custom named themes** ✅ (2026-06-12, except export/import) — IndexedDB entity
|
||||
4. **Custom named themes** ✅ (2026-06-12) — IndexedDB entity
|
||||
`{ id, name, config }` (`core/custom-theme.ts`, themes store @ DB v2) + the **Theme
|
||||
Builder** modal: theme list, JSON config editor, a font control that populates one
|
||||
family across every font slot (`applyFontToConfig`), and a live multi-chart gallery
|
||||
@@ -151,8 +151,9 @@ ships, it is an explicit per-font user action, never automatic.
|
||||
theme (house/preset/custom) or via the editor's **Extract Config to New Theme**
|
||||
action (spec §03G); appears in the selector as `custom:<id>` (the "Edit themes…"
|
||||
action row sits right after the customs, before the preset roster); deleting the
|
||||
active one falls back to Astrolabe. **Remaining:** export/import as JSON alongside
|
||||
the library (touches the §08 envelope + import-normalize atomicity).
|
||||
active one falls back to Astrolabe. Custom themes travel in the §08 workspace
|
||||
export/import envelope (additive `themes` array, name auto-suffix on clash, ids
|
||||
reassigned by the store, rolled back with datasets on a failed import).
|
||||
5. **Shipped font roster** — fontsource packages, `@font-face` registration, selector
|
||||
metadata (which themes/fonts pair), `document.fonts.load` gate in the render path,
|
||||
precache strategy above. Roster finalized via visual specimen.
|
||||
@@ -166,6 +167,16 @@ it without a second mechanism).
|
||||
|
||||
## 5. Status log
|
||||
|
||||
- **2026-06-12 (slice 4 close-out)** — **custom themes in the §08 envelope.** The
|
||||
workspace export now writes a `themes` array (additive — no format bump; importers
|
||||
treat it as optional, so pre-theme envelopes stay valid). Import normalizes each
|
||||
record (`normalizeCustomTheme`), auto-suffixes name clashes via the generalized
|
||||
`dedupeIncomingNames` (the dataset dedupe, now shared), reassigns ids through
|
||||
`CustomThemeStore.addThemes` (selection untouched), and rolls themes back together
|
||||
with datasets when the atomic snippet write fails. Toast counts gain a theme
|
||||
clause. Spec §08 updated ("Dataset conflicts" → "Name conflicts"). Slice 4 is now
|
||||
fully done; next is slice 5 (shipped font roster).
|
||||
|
||||
- **2026-06-12 (slice 4)** — **custom named themes + Theme Builder shipped.**
|
||||
`CustomTheme` entity through the full stack (core → theme-store @ DB v2 →
|
||||
CustomThemeStore → theme-persistence → startup hydrate); selection model extended to
|
||||
|
||||
@@ -6,17 +6,17 @@ Separately, a single chart can be exported on its own — its spec or its render
|
||||
|
||||
## Export
|
||||
|
||||
Export produces one downloadable JSON file containing every snippet (see _Snippet Library_) and every dataset (see _Datasets_), wrapped in an envelope carrying format metadata.
|
||||
Export produces one downloadable JSON file containing every snippet (see _Snippet Library_), every dataset (see _Datasets_), and every custom chart theme (see _Live Preview → Chart theme_), wrapped in an envelope carrying format metadata.
|
||||
|
||||
- **Trigger**: the **Export** header control runs the export immediately (no intermediate dialog).
|
||||
- **Contents**: all snippets and all datasets currently stored, plus envelope metadata.
|
||||
- **Empty workspace**: if there are no snippets, the user is informed ("No snippets to export") and no file is downloaded — even if datasets exist.
|
||||
- **Contents**: all snippets, datasets, and custom chart themes currently stored, plus envelope metadata.
|
||||
- **Empty workspace**: if there are no snippets, the user is informed ("No snippets to export") and no file is downloaded — even if datasets or themes exist.
|
||||
- **Filename**: `astrolabe-project-YYYY-MM-DD.json`, where the date is today's date (export day).
|
||||
- **Feedback**: on success a toast reports the counts, e.g. "Exported 4 snippets and 2 datasets" (the dataset clause is omitted when there are no datasets; singular/plural wording adapts to the counts).
|
||||
- **Feedback**: on success a toast reports the counts, e.g. "Exported 4 snippets, 2 datasets and 1 theme" (the dataset and theme clauses are omitted when their counts are zero; singular/plural wording adapts to the counts).
|
||||
|
||||
### Export envelope shape
|
||||
|
||||
The downloaded file is a single JSON object: an envelope with a format `version`, an export timestamp, an exporter tag, and the two data arrays.
|
||||
The downloaded file is a single JSON object: an envelope with a format `version`, an export timestamp, an exporter tag, and the data arrays.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -28,6 +28,9 @@ The downloaded file is a single JSON object: an envelope with a format `version`
|
||||
],
|
||||
"datasets": [
|
||||
/* full dataset objects (see Data Model) */
|
||||
],
|
||||
"themes": [
|
||||
/* full custom chart theme objects (see Data Model) */
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -35,7 +38,7 @@ The downloaded file is a single JSON object: an envelope with a format `version`
|
||||
- `version` — export format version (currently `"1.0"`).
|
||||
- `exportedAt` — ISO 8601 timestamp of the export.
|
||||
- `exportedBy` — fixed identifier `"Astrolabe"`.
|
||||
- `snippets` / `datasets` — arrays of complete records as defined in _Data Model_, each including its record `version` field. (This is the per-record schema version, not the envelope `version` above.)
|
||||
- `snippets` / `datasets` / `themes` — arrays of complete records as defined in _Data Model_, each including its record `version` field. (This is the per-record schema version, not the envelope `version` above.) `themes` is additive: exports always write it, and importers treat it as optional, so pre-theme envelopes remain valid `"1.0"` files.
|
||||
|
||||
## Per-chart export
|
||||
|
||||
@@ -70,8 +73,8 @@ Import lets the user pick a JSON file from their device; its contents are normal
|
||||
|
||||
The importer recognizes several shapes so that both Astrolabe exports and looser snippet files work:
|
||||
|
||||
- **Astrolabe export envelope** — an object with a `version` and a `snippets` array; an optional `datasets` array is imported too.
|
||||
- **Bare array of snippets** — a top-level JSON array is treated as a list of snippets (no datasets).
|
||||
- **Astrolabe export envelope** — an object with a `version` and a `snippets` array; optional `datasets` and `themes` arrays are imported too.
|
||||
- **Bare array of snippets** — a top-level JSON array is treated as a list of snippets (no datasets or themes).
|
||||
- **Single snippet object** — any other object is treated as one snippet.
|
||||
- **Older / foreign snippet shapes** — snippets that do not match the current model are normalized onto it:
|
||||
- Alternative field names are mapped: `content` → spec, `draft` → draft spec, `createdAt` → creation timestamp.
|
||||
@@ -85,14 +88,16 @@ A snippet is treated as already in current Astrolabe format when it carries an I
|
||||
|
||||
- Imported snippets are **appended** to the existing library; nothing is overwritten or removed.
|
||||
- **ID collisions** (an incoming snippet whose id already exists) are resolved by assigning the incoming snippet a fresh unique id; the original snippet keeps its id.
|
||||
- Datasets are imported **before** snippets so that snippet dataset references can resolve.
|
||||
- Datasets and custom themes are imported **before** snippets so that snippet dataset references can resolve.
|
||||
- Imported custom themes always receive fresh ids from the theme library; an envelope's theme ids never displace existing records.
|
||||
|
||||
### Dataset conflicts
|
||||
### Name conflicts (datasets and themes)
|
||||
|
||||
When an imported dataset's name already exists in the library, it is auto-renamed to a unique name rather than overwriting the existing one (see _Datasets_).
|
||||
When an imported dataset's or custom theme's name already exists in the library, it is auto-renamed to a unique name rather than overwriting the existing one (see _Datasets_).
|
||||
|
||||
- A numeric suffix is appended to the original name; further suffixes are added until the name is unique.
|
||||
- The renamed datasets are reported to the user via a warning toast listing each `original -> new` rename.
|
||||
- The renamed records are reported to the user via a warning toast listing each `original -> new` rename.
|
||||
- A dataset rename is propagated into the imported snippets that reference it; theme renames need no propagation (nothing references a theme by name).
|
||||
- If a single dataset fails to import, it is skipped and the rest of the import continues.
|
||||
|
||||
### Storage limit handling
|
||||
@@ -105,8 +110,8 @@ Snippet storage has an approximate 5 MB budget (see _Snippet Library_ storage mo
|
||||
|
||||
### Feedback
|
||||
|
||||
- **Success**: a toast reports how many snippets (and datasets, when any) were imported, e.g. "Imported 4 snippets and 2 datasets".
|
||||
- **Renames**: when datasets were renamed, the success message is shown as a warning toast that also lists the renames.
|
||||
- **Empty file**: if no snippets are found in the file, the user is informed ("No snippets found in file") and nothing is imported.
|
||||
- **Success**: a toast reports how many snippets (and datasets and themes, when any) were imported, e.g. "Imported 4 snippets, 2 datasets and 1 theme".
|
||||
- **Renames**: when datasets or themes were renamed, the success message is shown as a warning toast that also lists the renames.
|
||||
- **Empty file**: if no snippets are found in the file, the user is informed ("No snippets found in file") and nothing is imported — even if the file carries datasets or themes.
|
||||
- **Quota failure**: a clear error advising the user to delete snippets and retry.
|
||||
- **Invalid file**: a non-JSON or unparseable file produces a clear error ("Failed to import. Please check that the file is valid JSON."); an unreadable file produces a read error. In all error cases the existing workspace is left unchanged.
|
||||
|
||||
Reference in New Issue
Block a user