Workspace transfer: custom themes ride the export/import envelope

This commit is contained in:
2026-06-12 21:45:44 +03:00
parent 80d13c9b6f
commit dbb4522d78
11 changed files with 405 additions and 173 deletions
+42 -17
View File
@@ -1,11 +1,12 @@
import { describe, it, expect } from 'vitest';
import { createCustomTheme, type CustomTheme } from './custom-theme';
import { createDataset, type Dataset } from './dataset';
import {
EXPORT_ENVELOPE_VERSION,
buildExportEnvelope,
exportFilename,
exportSummaryMessage,
transferSummaryMessage,
type ExportEnvelope,
} from './export-envelope';
import { createSnippet, type Snippet } from './snippet';
@@ -16,6 +17,14 @@ function makeSnippet(overrides: Partial<Snippet> = {}): Snippet {
return { ...createSnippet({ now: FIXED_NOW, id: 's1' }), ...overrides };
}
function makeTheme(overrides: Partial<CustomTheme> = {}): CustomTheme {
return {
...createCustomTheme({ name: 'T1', config: { background: '#fff' }, now: FIXED_NOW }),
id: 1,
...overrides,
};
}
function makeDataset(overrides: Partial<Dataset> = {}): Dataset {
return {
...createDataset({
@@ -38,7 +47,7 @@ describe('EXPORT_ENVELOPE_VERSION', () => {
describe('buildExportEnvelope', () => {
it('stamps version, ISO timestamp, and the fixed exporter tag', () => {
const env = buildExportEnvelope([], [], { now: FIXED_NOW });
const env = buildExportEnvelope([], [], [], { now: FIXED_NOW });
expect(env.version).toBe('1.0');
expect(env.exportedAt).toBe('2026-06-03T12:00:00.000Z');
expect(env.exportedBy).toBe('Astrolabe');
@@ -47,13 +56,15 @@ describe('buildExportEnvelope', () => {
it('produces the full envelope shape (matches spec §08 example fields)', () => {
const snippet = makeSnippet();
const dataset = makeDataset();
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
const theme = makeTheme();
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
const expected: ExportEnvelope = {
version: '1.0',
exportedAt: '2026-06-03T12:00:00.000Z',
exportedBy: 'Astrolabe',
snippets: [snippet],
datasets: [dataset],
themes: [theme],
};
expect(env).toEqual(expected);
});
@@ -61,17 +72,21 @@ describe('buildExportEnvelope', () => {
it('carries the records through unchanged', () => {
const snippet = makeSnippet();
const dataset = makeDataset();
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
const theme = makeTheme();
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
expect(env.snippets[0]).toBe(snippet);
expect(env.datasets[0]).toBe(dataset);
expect(env.themes[0]).toBe(theme);
});
it("preserves each record's own version field", () => {
const snippet = makeSnippet({ version: 1 });
const dataset = makeDataset({ version: 1 });
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
const theme = makeTheme({ version: 1 });
const env = buildExportEnvelope([snippet], [dataset], [theme], { now: FIXED_NOW });
expect(env.snippets[0].version).toBe(1);
expect(env.datasets[0].version).toBe(1);
expect(env.themes[0].version).toBe(1);
// The per-record version is distinct from the envelope's file-format version.
expect(env.version).toBe('1.0');
});
@@ -79,19 +94,23 @@ describe('buildExportEnvelope', () => {
it("does not alias the caller's arrays (mutating inputs afterward is inert)", () => {
const snippets = [makeSnippet()];
const datasets = [makeDataset()];
const env = buildExportEnvelope(snippets, datasets, { now: FIXED_NOW });
const themes = [makeTheme()];
const env = buildExportEnvelope(snippets, datasets, themes, { now: FIXED_NOW });
snippets.push(makeSnippet({ id: 's2' }));
datasets.push(makeDataset({ id: 2, name: 'D2' }));
themes.push(makeTheme({ id: 2, name: 'T2' }));
expect(env.snippets).toHaveLength(1);
expect(env.datasets).toHaveLength(1);
expect(env.themes).toHaveLength(1);
});
it('handles empty arrays', () => {
const env = buildExportEnvelope([], [], { now: FIXED_NOW });
const env = buildExportEnvelope([], [], [], { now: FIXED_NOW });
expect(env.snippets).toEqual([]);
expect(env.datasets).toEqual([]);
expect(env.themes).toEqual([]);
});
});
@@ -106,24 +125,30 @@ describe('exportFilename', () => {
});
});
describe('exportSummaryMessage', () => {
it('reports both counts (plural)', () => {
expect(exportSummaryMessage(4, 2)).toBe('Exported 4 snippets and 2 datasets');
describe('transferSummaryMessage (shared by export and import feedback)', () => {
it('reports snippet and dataset counts (plural)', () => {
expect(transferSummaryMessage('Exported', 4, 2, 0)).toBe('Exported 4 snippets and 2 datasets');
});
it('omits the dataset clause when there are no datasets', () => {
expect(exportSummaryMessage(4, 0)).toBe('Exported 4 snippets');
it('omits the dataset and theme clauses when their counts are zero', () => {
expect(transferSummaryMessage('Exported', 4, 0, 0)).toBe('Exported 4 snippets');
});
it('uses singular wording for counts of 1', () => {
expect(exportSummaryMessage(1, 1)).toBe('Exported 1 snippet and 1 dataset');
expect(transferSummaryMessage('Imported', 1, 1, 0)).toBe('Imported 1 snippet and 1 dataset');
});
it('singular snippet with omitted dataset clause', () => {
expect(exportSummaryMessage(1, 0)).toBe('Exported 1 snippet');
it('pluralizes zero counts (and omits the other clauses)', () => {
expect(transferSummaryMessage('Imported', 0, 0, 0)).toBe('Imported 0 snippets');
});
it('pluralizes zero counts (and omits the dataset clause)', () => {
expect(exportSummaryMessage(0, 0)).toBe('Exported 0 snippets');
it('reports all three counts with comma-and joining', () => {
expect(transferSummaryMessage('Exported', 4, 2, 1)).toBe(
'Exported 4 snippets, 2 datasets and 1 theme',
);
});
it('joins snippets and themes with "and" when there are no datasets', () => {
expect(transferSummaryMessage('Imported', 4, 0, 3)).toBe('Imported 4 snippets and 3 themes');
});
});
+24 -9
View File
@@ -12,6 +12,7 @@
* migration target — see snippet.ts / dataset.ts).
*/
import type { CustomTheme } from './custom-theme';
import type { Dataset } from './dataset';
import type { Snippet } from './snippet';
@@ -24,8 +25,9 @@ export const EXPORT_ENVELOPE_VERSION = '1.0';
/**
* The downloaded file's top-level shape (spec §08 → Export envelope shape): format
* metadata plus the two complete-record arrays. Each record keeps its own `version`
* field unchanged.
* metadata plus the complete-record arrays. Each record keeps its own `version`
* field unchanged. `themes` is additive (always written, optional on read) so
* pre-theme envelopes and importers remain compatible without a format bump.
*/
export interface ExportEnvelope {
/** Export format version (currently `"1.0"`). */
@@ -38,6 +40,8 @@ export interface ExportEnvelope {
snippets: Snippet[];
/** All datasets, as complete records (each including its record `version`). */
datasets: Dataset[];
/** All custom chart themes, as complete records (each including its record `version`). */
themes: CustomTheme[];
}
/**
@@ -50,6 +54,7 @@ export interface ExportEnvelope {
export function buildExportEnvelope(
snippets: ReadonlyArray<Snippet>,
datasets: ReadonlyArray<Dataset>,
themes: ReadonlyArray<CustomTheme>,
opts: { now: Date },
): ExportEnvelope {
return {
@@ -58,6 +63,7 @@ export function buildExportEnvelope(
exportedBy: 'Astrolabe',
snippets: [...snippets],
datasets: [...datasets],
themes: [...themes],
};
}
@@ -82,12 +88,21 @@ function countClause(count: number, noun: string): string {
}
/**
* Success-toast message reporting the export counts (spec §08 → Feedback), e.g.
* "Exported 4 snippets and 2 datasets". The dataset clause is omitted entirely when
* there are no datasets; singular/plural wording adapts to the counts.
* Success-toast message reporting transfer counts (spec §08 → Feedback) for both
* directions, e.g. "Exported 4 snippets, 2 datasets and 1 theme" / "Imported 1
* snippet". The dataset and theme clauses are omitted entirely when their counts
* are zero; singular/plural wording adapts. One builder for export and import so
* a new record kind or wording change lands in both messages at once.
*/
export function exportSummaryMessage(snippetCount: number, datasetCount: number): string {
const snippets = countClause(snippetCount, 'snippet');
if (datasetCount === 0) return `Exported ${snippets}`;
return `Exported ${snippets} and ${countClause(datasetCount, 'dataset')}`;
export function transferSummaryMessage(
verb: 'Exported' | 'Imported',
snippetCount: number,
datasetCount: number,
themeCount: number,
): string {
const clauses = [countClause(snippetCount, 'snippet')];
if (datasetCount > 0) clauses.push(countClause(datasetCount, 'dataset'));
if (themeCount > 0) clauses.push(countClause(themeCount, 'theme'));
const last = clauses.pop()!;
return clauses.length === 0 ? `${verb} ${last}` : `${verb} ${clauses.join(', ')} and ${last}`;
}
+97 -45
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest';
import { CURRENT_THEME_VERSION } from './custom-theme';
import { CURRENT_DATASET_VERSION, type Dataset } from './dataset';
import {
applyDatasetRenamesToSnippets,
dedupeIncomingDatasetNames,
importSummaryMessage,
dedupeIncomingNames,
normalizeImport,
reassignCollidingSnippetIds,
} from './import-normalize';
@@ -107,10 +107,11 @@ describe('normalizeImport — shape detection', () => {
});
it('returns empty for null / non-object / non-array junk', () => {
expect(normalizeImport(null)).toEqual({ snippets: [], datasets: [] });
expect(normalizeImport(42)).toEqual({ snippets: [], datasets: [] });
expect(normalizeImport('hello')).toEqual({ snippets: [], datasets: [] });
expect(normalizeImport(undefined)).toEqual({ snippets: [], datasets: [] });
const empty = { snippets: [], datasets: [], themes: [] };
expect(normalizeImport(null)).toEqual(empty);
expect(normalizeImport(42)).toEqual(empty);
expect(normalizeImport('hello')).toEqual(empty);
expect(normalizeImport(undefined)).toEqual(empty);
});
it('ignores a non-array datasets field on the envelope', () => {
@@ -118,6 +119,26 @@ describe('normalizeImport — shape detection', () => {
const result = normalizeImport(parsed, { now: FIXED_NOW });
expect(result.datasets).toEqual([]);
});
it('reads an optional themes array on the envelope (absent → empty)', () => {
const withThemes = {
version: '1.0',
snippets: [currentSnippetRecord()],
themes: [{ id: 7, name: 'Corporate', config: { background: '#fff' } }],
};
expect(normalizeImport(withThemes, { now: FIXED_NOW }).themes).toHaveLength(1);
const without = { version: '1.0', snippets: [currentSnippetRecord()] };
expect(normalizeImport(without, { now: FIXED_NOW }).themes).toEqual([]);
const junkThemes = { version: '1.0', snippets: [currentSnippetRecord()], themes: 'nope' };
expect(normalizeImport(junkThemes, { now: FIXED_NOW }).themes).toEqual([]);
});
it('does not read themes from bare-array or single-object shapes', () => {
expect(normalizeImport([currentSnippetRecord()], { now: FIXED_NOW }).themes).toEqual([]);
expect(normalizeImport(currentSnippetRecord(), { now: FIXED_NOW }).themes).toEqual([]);
});
});
describe('normalizeImport — snippet normalization', () => {
@@ -287,31 +308,74 @@ describe('normalizeImport — dataset normalization', () => {
});
});
describe('dedupeIncomingDatasetNames', () => {
describe('normalizeImport — custom themes', () => {
const envelope = (themes: unknown[]) => ({
version: '1.0',
snippets: [currentSnippetRecord()],
themes,
});
it('preserves a complete record and stamps the current version', () => {
const [t] = normalizeImport(
envelope([
{
id: 7,
version: 99,
name: 'Corporate',
config: { background: '#fff', font: 'Georgia' },
created: '2025-03-01T00:00:00.000Z',
modified: '2025-03-02T00:00:00.000Z',
},
]),
{ now: FIXED_NOW },
).themes;
expect(t.id).toBe(7);
expect(t.version).toBe(CURRENT_THEME_VERSION);
expect(t.name).toBe('Corporate');
expect(t.config).toEqual({ background: '#fff', font: 'Georgia' });
expect(t.created).toBe('2025-03-01T00:00:00.000Z');
expect(t.modified).toBe('2025-03-02T00:00:00.000Z');
});
it('fills gaps with defaults (junk record)', () => {
const [t] = normalizeImport(envelope(['junk']), { now: FIXED_NOW }).themes;
expect(t.id).toBe(0);
expect(t.version).toBe(CURRENT_THEME_VERSION);
expect(t.name).toBe('Untitled');
expect(t.config).toEqual({});
expect(t.created).toBe(FIXED_NOW_ISO);
expect(t.modified).toBe(FIXED_NOW_ISO);
});
it('coerces a non-object config to {} and derives modified from created', () => {
const [t] = normalizeImport(
envelope([{ name: 'Odd', config: [1, 2], created: '2025-03-01T00:00:00.000Z' }]),
{ now: FIXED_NOW },
).themes;
expect(t.config).toEqual({});
expect(t.modified).toBe('2025-03-01T00:00:00.000Z');
});
});
describe('dedupeIncomingNames', () => {
it('returns names unchanged when there are no collisions', () => {
const { datasets, renames } = dedupeIncomingDatasetNames(
['Other'],
[datasetRecord({ name: 'Sales' })],
);
expect(datasets[0].name).toBe('Sales');
const { records, renames } = dedupeIncomingNames(['Other'], [datasetRecord({ name: 'Sales' })]);
expect(records[0].name).toBe('Sales');
expect(renames).toEqual([]);
});
it('suffixes a collision with an existing library name', () => {
const { datasets, renames } = dedupeIncomingDatasetNames(
['Sales'],
[datasetRecord({ name: 'Sales' })],
);
expect(datasets[0].name).toBe('Sales 2');
const { records, renames } = dedupeIncomingNames(['Sales'], [datasetRecord({ name: 'Sales' })]);
expect(records[0].name).toBe('Sales 2');
expect(renames).toEqual([{ from: 'Sales', to: 'Sales 2' }]);
});
it('reserves names as-you-go so intra-batch dupes become Sales 2, Sales 3', () => {
const { datasets, renames } = dedupeIncomingDatasetNames(
const { records, renames } = dedupeIncomingNames(
['Sales'],
[datasetRecord({ name: 'Sales' }), datasetRecord({ name: 'Sales' })],
);
expect(datasets.map((d) => d.name)).toEqual(['Sales 2', 'Sales 3']);
expect(records.map((d) => d.name)).toEqual(['Sales 2', 'Sales 3']);
expect(renames).toEqual([
{ from: 'Sales', to: 'Sales 2' },
{ from: 'Sales', to: 'Sales 3' },
@@ -319,18 +383,24 @@ describe('dedupeIncomingDatasetNames', () => {
});
it('matches collisions case-insensitively (Sales vs sales)', () => {
const { datasets, renames } = dedupeIncomingDatasetNames(
['Sales'],
[datasetRecord({ name: 'sales' })],
);
expect(datasets[0].name).toBe('sales 2'); // preserves incoming casing
const { records, renames } = dedupeIncomingNames(['Sales'], [datasetRecord({ name: 'sales' })]);
expect(records[0].name).toBe('sales 2'); // preserves incoming casing
expect(renames).toEqual([{ from: 'sales', to: 'sales 2' }]);
});
it('only clones datasets whose name changed', () => {
it('only clones records whose name changed', () => {
const keep = datasetRecord({ name: 'Untouched' });
const { datasets } = dedupeIncomingDatasetNames(['Sales'], [keep]);
expect(datasets[0]).toBe(keep);
const { records } = dedupeIncomingNames(['Sales'], [keep]);
expect(records[0]).toBe(keep);
});
it('works for any named record (custom themes)', () => {
const { records, renames } = dedupeIncomingNames(
['Corporate'],
[{ name: 'Corporate', config: {} }],
);
expect(records[0].name).toBe('Corporate 2');
expect(renames).toEqual([{ from: 'Corporate', to: 'Corporate 2' }]);
});
});
@@ -457,21 +527,3 @@ describe('applyDatasetRenamesToSnippets', () => {
expect(out.datasetRefs).toEqual(['Regions 2', 'Sales 2']);
});
});
describe('importSummaryMessage', () => {
it('pluralizes snippets and includes the dataset clause', () => {
expect(importSummaryMessage(4, 2)).toBe('Imported 4 snippets and 2 datasets');
});
it('uses singular wording for counts of 1', () => {
expect(importSummaryMessage(1, 1)).toBe('Imported 1 snippet and 1 dataset');
});
it('omits the dataset clause when datasetCount is 0', () => {
expect(importSummaryMessage(3, 0)).toBe('Imported 3 snippets');
});
it('pluralizes zero counts correctly', () => {
expect(importSummaryMessage(0, 0)).toBe('Imported 0 snippets');
});
});
+55 -42
View File
@@ -6,18 +6,21 @@
* data in, plain data out. The app's ImportService orchestrates the file read,
* store reads (for existing names/ids), and the commit; everything *deterministic*
* about an import — recognizing the file shape, coercing each record onto the
* current `Snippet`/`Dataset` shape, de-duping names, reassigning colliding ids,
* and propagating renames into specs — lives here so it can be unit-tested hardest.
* current `Snippet`/`Dataset`/`CustomTheme` shape, de-duping names, reassigning
* colliding ids, and propagating renames into specs — lives here so it can be
* unit-tested hardest.
*
* `crypto.randomUUID` is a platform global (like in snippet.ts), allowed in core.
* Both id generators are injectable so tests can assert deterministically.
*/
import { CURRENT_THEME_VERSION, type CustomTheme } from './custom-theme';
import { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from './dataset';
import type { DataFormat } from './format-detection';
import { makeUniqueName } from './naming';
import type { ColumnStats } from './profile';
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
import { isJsonObject } from './spec-config';
import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from './spec-refs';
import type { ColumnType } from './type-inference';
@@ -28,10 +31,11 @@ const IMPORTED_TAG = 'imported';
export interface NormalizedImport {
snippets: Snippet[];
datasets: Dataset[];
themes: CustomTheme[];
}
/** A single dataset rename applied during dedupe (`from` original → `to` unique). */
export interface DatasetRename {
/** A single rename applied during name dedupe (`from` original → `to` unique). */
export interface NameRename {
from: string;
to: string;
}
@@ -198,6 +202,30 @@ function normalizeDataset(raw: unknown, nowIso: string): Dataset {
};
}
/**
* Normalize one raw custom-theme record onto the current `CustomTheme` shape
* (spec §08 → Accepted inputs). Themes only travel inside Astrolabe envelopes,
* so this is gap-filling rather than foreign-shape mapping: a non-object
* `config` becomes `{}`, missing timestamps derive like datasets, and `version`
* is stamped current. Ids are provisional — the store's id authority reassigns
* them on insert, so collisions need no handling here.
*/
function normalizeCustomTheme(raw: unknown, nowIso: string): CustomTheme {
const r = isPlainObject(raw) ? raw : {};
const created = asNonEmptyString(r.created) ?? nowIso;
const modified = asNonEmptyString(r.modified) ?? created;
return {
id: typeof r.id === 'number' ? r.id : Number(r.id) || 0,
version: CURRENT_THEME_VERSION,
name: typeof r.name === 'string' ? r.name : 'Untitled',
config: isJsonObject(r.config) ? r.config : {},
created,
modified,
};
}
// ----------------------------------------------------------------------------
// Shape detection (spec §08 "Accepted inputs")
// ----------------------------------------------------------------------------
@@ -206,9 +234,9 @@ function normalizeDataset(raw: unknown, nowIso: string): Dataset {
* Detect the import shape and normalize every record onto the current model.
*
* - Envelope: object with a `version` AND a `snippets` array → its snippets
* (+ optional `datasets` array).
* - Bare array: a top-level array → a list of snippets, no datasets.
* - Single object: any other object → one snippet, no datasets.
* (+ optional `datasets` and `themes` arrays).
* - Bare array: a top-level array → a list of snippets, no datasets/themes.
* - Single object: any other object → one snippet, no datasets/themes.
* - junk (null / non-object / non-array) → empty.
*/
export function normalizeImport(
@@ -220,6 +248,7 @@ export function normalizeImport(
let rawSnippets: unknown[] = [];
let rawDatasets: unknown[] = [];
let rawThemes: unknown[] = [];
if (Array.isArray(parsed)) {
// Bare array of snippets.
@@ -229,16 +258,18 @@ export function normalizeImport(
if (hasEnvelope) {
rawSnippets = parsed.snippets as unknown[];
if (Array.isArray(parsed.datasets)) rawDatasets = parsed.datasets;
if (Array.isArray(parsed.themes)) rawThemes = parsed.themes;
} else {
// Single snippet object.
rawSnippets = [parsed];
}
}
// else: null / non-object / non-array junk → both stay empty.
// else: null / non-object / non-array junk → all stay empty.
return {
snippets: rawSnippets.map((s) => normalizeSnippet(s, nowIso, makeId)),
datasets: rawDatasets.map((d) => normalizeDataset(d, nowIso)),
themes: rawThemes.map((t) => normalizeCustomTheme(t, nowIso)),
};
}
@@ -247,27 +278,28 @@ export function normalizeImport(
// ----------------------------------------------------------------------------
/**
* De-dupe incoming dataset names against the existing library and within the
* batch itself (architecture 07 §5). Names are reserved *as we go* so two incoming
* `Sales` become `Sales 2`, `Sales 3` — never two `Sales 2`. Existing datasets are
* never overwritten. Every rename is collected for reporting. Only datasets whose
* De-dupe incoming record names against the existing collection and within the
* batch itself (architecture 07 §5) — used for datasets and custom themes, both
* of which key on a unique name. Names are reserved *as we go* so two incoming
* `Sales` become `Sales 2`, `Sales 3` — never two `Sales 2`. Existing records are
* never overwritten. Every rename is collected for reporting. Only records whose
* name actually changed are cloned.
*/
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 };
}
/**
@@ -297,7 +329,7 @@ export function reassignCollidingSnippetIds(
}
/**
* Propagate dataset renames (from `dedupeIncomingDatasetNames`) into the imported
* Propagate dataset renames (from `dedupeIncomingNames`) into the imported
* snippets that reference them (architecture 07 §6): for each rename, rewrite both
* `spec` and `draftSpec` via `renameDatasetInSpec`, then recompute `datasetRefs`
* from the rewritten draft — the draft is the source of truth, `datasetRefs`
@@ -309,7 +341,7 @@ export function reassignCollidingSnippetIds(
*/
export function applyDatasetRenamesToSnippets(
snippets: ReadonlyArray<Snippet>,
renames: ReadonlyArray<DatasetRename>,
renames: ReadonlyArray<NameRename>,
): Snippet[] {
if (renames.length === 0) return snippets.slice();
@@ -344,22 +376,3 @@ export function applyDatasetRenamesToSnippets(
return { ...snippet, spec, draftSpec, datasetRefs: recomputeDatasetRefs(draftSpec) };
});
}
// ----------------------------------------------------------------------------
// Feedback
// ----------------------------------------------------------------------------
/** Pluralize a count: `1 thing`, `0 things`, `4 things`. */
function plural(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? '' : 's'}`;
}
/**
* The import success message (spec §08 "Feedback"), e.g.
* "Imported 4 snippets and 2 datasets". The dataset clause is omitted when
* `datasetCount` is 0; singular/plural adapts for any counts ≥ 0.
*/
export function importSummaryMessage(snippetCount: number, datasetCount: number): string {
const head = `Imported ${plural(snippetCount, 'snippet')}`;
return datasetCount > 0 ? `${head} and ${plural(datasetCount, 'dataset')}` : head;
}