mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add distributed settings and workspace import/export (M5)
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatCustom,
|
||||
formatDate,
|
||||
formatIso,
|
||||
formatSmart,
|
||||
type DateFormatMode,
|
||||
} from './date-format';
|
||||
|
||||
/** A fixed local reference "now" for relative-date tests. */
|
||||
const NOW = new Date(2026, 5, 7, 12, 0, 0); // 2026-06-07 12:00 local
|
||||
const at = (y: number, mo: number, d: number, h = 9, mi = 0, s = 0) => new Date(y, mo, d, h, mi, s);
|
||||
|
||||
describe('formatSmart', () => {
|
||||
it('renders the same calendar day as Today', () => {
|
||||
expect(formatSmart(at(2026, 5, 7, 0, 1), NOW)).toBe('Today');
|
||||
expect(formatSmart(at(2026, 5, 7, 23, 59), NOW)).toBe('Today');
|
||||
});
|
||||
|
||||
it('renders the previous calendar day as Yesterday', () => {
|
||||
expect(formatSmart(at(2026, 5, 6), NOW)).toBe('Yesterday');
|
||||
});
|
||||
|
||||
it('renders under a week as Nd ago', () => {
|
||||
expect(formatSmart(at(2026, 5, 4), NOW)).toBe('3d ago');
|
||||
expect(formatSmart(at(2026, 5, 1), NOW)).toBe('6d ago');
|
||||
});
|
||||
|
||||
it('falls back to a full locale date at a week or older', () => {
|
||||
const old = at(2026, 4, 1);
|
||||
expect(formatSmart(old, NOW)).toBe(old.toLocaleDateString());
|
||||
});
|
||||
|
||||
it('does not flip to Yesterday on the hour within the same day', () => {
|
||||
// 1 minute ago but same calendar day → still Today.
|
||||
expect(formatSmart(at(2026, 5, 7, 11, 59), NOW)).toBe('Today');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatIso', () => {
|
||||
it('returns a full ISO 8601 timestamp', () => {
|
||||
const d = new Date('2026-06-07T10:30:00.000Z');
|
||||
expect(formatIso(d)).toBe('2026-06-07T10:30:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCustom', () => {
|
||||
it('formats the placeholder pattern yyyy-MM-dd HH:mm', () => {
|
||||
expect(formatCustom(at(2026, 0, 5, 8, 4), 'yyyy-MM-dd HH:mm')).toBe('2026-01-05 08:04');
|
||||
});
|
||||
|
||||
it('supports month names and short year', () => {
|
||||
expect(formatCustom(at(2026, 11, 25), 'd MMMM yy')).toBe('25 December 26');
|
||||
expect(formatCustom(at(2026, 2, 9), 'MMM d, yyyy')).toBe('Mar 9, 2026');
|
||||
});
|
||||
|
||||
it('supports 12-hour clock with meridiem', () => {
|
||||
expect(formatCustom(at(2026, 5, 7, 0, 5), 'h:mm a')).toBe('12:05 AM');
|
||||
expect(formatCustom(at(2026, 5, 7, 13, 5), 'h:mm a')).toBe('1:05 PM');
|
||||
expect(formatCustom(at(2026, 5, 7, 12, 0), 'hh:mm a')).toBe('12:00 PM');
|
||||
});
|
||||
|
||||
it('preserves non-token literals verbatim', () => {
|
||||
expect(formatCustom(at(2026, 0, 1), 'yyyy/MM/dd')).toBe('2026/01/01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('dispatches by mode', () => {
|
||||
const iso = at(2026, 5, 6, 9, 0).toISOString();
|
||||
expect(formatDate(iso, 'smart', '', NOW)).toBe('Yesterday');
|
||||
expect(formatDate(iso, 'iso')).toBe(new Date(iso).toISOString());
|
||||
expect(formatDate(iso, 'custom', 'yyyy-MM-dd', NOW)).toBe('2026-06-06');
|
||||
});
|
||||
|
||||
it('falls back to ISO when a custom pattern is blank', () => {
|
||||
const iso = '2026-06-07T10:30:00.000Z';
|
||||
expect(formatDate(iso, 'custom', ' ')).toBe(new Date(iso).toISOString());
|
||||
expect(formatDate(iso, 'custom', '')).toBe(new Date(iso).toISOString());
|
||||
});
|
||||
|
||||
it('returns an unparseable timestamp verbatim rather than "Invalid Date"', () => {
|
||||
expect(formatDate('not-a-date', 'iso')).toBe('not-a-date');
|
||||
expect(formatDate('not-a-date', 'smart', '', NOW)).toBe('not-a-date');
|
||||
});
|
||||
|
||||
it('defaults unknown modes to smart', () => {
|
||||
const iso = at(2026, 5, 7).toISOString();
|
||||
expect(formatDate(iso, 'bogus' as DateFormatMode, '', NOW)).toBe('Today');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Date formatting — how timestamps render throughout the app (spec §07 →
|
||||
* Formatting). Governs the Snippet Library list dates and the metadata panel's
|
||||
* Created/Modified, driven by the user's `formatting.dateFormat` setting.
|
||||
*
|
||||
* Portable core: no browser APIs, no React. `Intl` is deliberately avoided for
|
||||
* the custom tokens so output is locale-stable and unit-testable; month/day
|
||||
* names are the fixed English set. Pure: an ISO string in, a display string out.
|
||||
*
|
||||
* - smart → relative, human-friendly ("Today", "Yesterday", "3d ago", then a
|
||||
* full locale date for older items).
|
||||
* - iso → a full ISO 8601 timestamp.
|
||||
* - custom → the user's format string (token grammar below); falls back to ISO
|
||||
* when the pattern is empty.
|
||||
*/
|
||||
|
||||
/** The three date-display modes (mirrors UserSettings.formatting.dateFormat). */
|
||||
export type DateFormatMode = 'smart' | 'iso' | 'custom';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const MONTHS_SHORT = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
const MONTHS_LONG = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
/** Two-digit zero-pad (mirrors snippet.ts → generateSnippetName). */
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative, human-friendly rendering (spec §07 → Smart). Same day → "Today",
|
||||
* one calendar day back → "Yesterday", under a week → "Nd ago", else the full
|
||||
* locale date. Comparison is by calendar day (local), so "Yesterday" doesn't
|
||||
* flip on the exact hour. `now` is injectable for deterministic tests.
|
||||
*/
|
||||
export function formatSmart(date: Date, now: Date): string {
|
||||
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const days = Math.floor((startOfDay(now) - startOfDay(date)) / DAY_MS);
|
||||
if (days <= 0) return 'Today';
|
||||
if (days === 1) return 'Yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
/** Full ISO 8601 timestamp (spec §07 → ISO 8601). */
|
||||
export function formatIso(date: Date): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
// Tokens longest-first so MMMM matches before MMM before MM before M, etc.
|
||||
const TOKEN = /yyyy|yy|MMMM|MMM|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s|a/g;
|
||||
|
||||
/**
|
||||
* Format a date with a date-fns-style token pattern, in **local** time
|
||||
* (spec §07 → Custom). Supported tokens: `yyyy yy MMMM MMM MM M dd d HH H hh h
|
||||
* mm m ss s a`. Text between tokens is preserved verbatim (no quoting), which is
|
||||
* enough for patterns like `yyyy-MM-dd HH:mm`; a stray literal letter that
|
||||
* happens to be a token (e.g. a `d` in prose) would be substituted — the field
|
||||
* is a power-user affordance, so this stays simple and predictable.
|
||||
*/
|
||||
export function formatCustom(date: Date, pattern: string): string {
|
||||
const h24 = date.getHours();
|
||||
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
|
||||
const map: Record<string, string> = {
|
||||
yyyy: String(date.getFullYear()),
|
||||
yy: pad(date.getFullYear() % 100),
|
||||
MMMM: MONTHS_LONG[date.getMonth()],
|
||||
MMM: MONTHS_SHORT[date.getMonth()],
|
||||
MM: pad(date.getMonth() + 1),
|
||||
M: String(date.getMonth() + 1),
|
||||
dd: pad(date.getDate()),
|
||||
d: String(date.getDate()),
|
||||
HH: pad(h24),
|
||||
H: String(h24),
|
||||
hh: pad(h12),
|
||||
h: String(h12),
|
||||
mm: pad(date.getMinutes()),
|
||||
m: String(date.getMinutes()),
|
||||
ss: pad(date.getSeconds()),
|
||||
s: String(date.getSeconds()),
|
||||
a: h24 < 12 ? 'AM' : 'PM',
|
||||
};
|
||||
return pattern.replace(TOKEN, (t) => map[t] ?? t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an ISO timestamp per the user's date-format setting (spec §07). An
|
||||
* unparseable timestamp is returned verbatim rather than rendered as "Invalid
|
||||
* Date", so a malformed stored value degrades gracefully. `customFormat` is used
|
||||
* only in `custom` mode and falls back to ISO when blank. `now` (for `smart`)
|
||||
* defaults to the current time; inject it in tests.
|
||||
*/
|
||||
export function formatDate(
|
||||
iso: string,
|
||||
mode: DateFormatMode,
|
||||
customFormat = '',
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return iso;
|
||||
|
||||
switch (mode) {
|
||||
case 'iso':
|
||||
return formatIso(date);
|
||||
case 'custom':
|
||||
return customFormat.trim() === '' ? formatIso(date) : formatCustom(date, customFormat);
|
||||
case 'smart':
|
||||
default:
|
||||
return formatSmart(date, now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { createDataset, type Dataset } from './dataset';
|
||||
import {
|
||||
EXPORT_ENVELOPE_VERSION,
|
||||
buildExportEnvelope,
|
||||
exportFilename,
|
||||
exportSummaryMessage,
|
||||
type ExportEnvelope,
|
||||
} from './export-envelope';
|
||||
import { createSnippet, type Snippet } from './snippet';
|
||||
|
||||
const FIXED_NOW = new Date('2026-06-03T12:00:00.000Z');
|
||||
|
||||
function makeSnippet(overrides: Partial<Snippet> = {}): Snippet {
|
||||
return { ...createSnippet({ now: FIXED_NOW, id: 's1' }), ...overrides };
|
||||
}
|
||||
|
||||
function makeDataset(overrides: Partial<Dataset> = {}): Dataset {
|
||||
return {
|
||||
...createDataset({
|
||||
name: 'D1',
|
||||
data: 'a,b\n1,2',
|
||||
format: 'csv',
|
||||
source: 'inline',
|
||||
now: FIXED_NOW,
|
||||
id: 1,
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('EXPORT_ENVELOPE_VERSION', () => {
|
||||
it('is the spec-mandated "1.0"', () => {
|
||||
expect(EXPORT_ENVELOPE_VERSION).toBe('1.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExportEnvelope', () => {
|
||||
it('stamps version, ISO timestamp, and the fixed exporter tag', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
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 expected: ExportEnvelope = {
|
||||
version: '1.0',
|
||||
exportedAt: '2026-06-03T12:00:00.000Z',
|
||||
exportedBy: 'Astrolabe',
|
||||
snippets: [snippet],
|
||||
datasets: [dataset],
|
||||
};
|
||||
expect(env).toEqual(expected);
|
||||
});
|
||||
|
||||
it('carries the records through unchanged', () => {
|
||||
const snippet = makeSnippet();
|
||||
const dataset = makeDataset();
|
||||
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
|
||||
expect(env.snippets[0]).toBe(snippet);
|
||||
expect(env.datasets[0]).toBe(dataset);
|
||||
});
|
||||
|
||||
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 });
|
||||
expect(env.snippets[0].version).toBe(1);
|
||||
expect(env.datasets[0].version).toBe(1);
|
||||
// The per-record version is distinct from the envelope's file-format version.
|
||||
expect(env.version).toBe('1.0');
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
snippets.push(makeSnippet({ id: 's2' }));
|
||||
datasets.push(makeDataset({ id: 2, name: 'D2' }));
|
||||
|
||||
expect(env.snippets).toHaveLength(1);
|
||||
expect(env.datasets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles empty arrays', () => {
|
||||
const env = buildExportEnvelope([], [], { now: FIXED_NOW });
|
||||
expect(env.snippets).toEqual([]);
|
||||
expect(env.datasets).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportFilename', () => {
|
||||
it('formats as astrolabe-project-YYYY-MM-DD.json with zero-padding', () => {
|
||||
// Local date parts; construct with local Y/M/D to avoid TZ ambiguity.
|
||||
expect(exportFilename(new Date(2026, 0, 5))).toBe('astrolabe-project-2026-01-05.json');
|
||||
});
|
||||
|
||||
it('zero-pads two-digit month and day', () => {
|
||||
expect(exportFilename(new Date(2026, 11, 25))).toBe('astrolabe-project-2026-12-25.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportSummaryMessage', () => {
|
||||
it('reports both counts (plural)', () => {
|
||||
expect(exportSummaryMessage(4, 2)).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('uses singular wording for counts of 1', () => {
|
||||
expect(exportSummaryMessage(1, 1)).toBe('Exported 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 dataset clause)', () => {
|
||||
expect(exportSummaryMessage(0, 0)).toBe('Exported 0 snippets');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Export envelope — the single JSON object Astrolabe downloads to back up or
|
||||
* transfer a whole workspace (spec §08 → Export, Export envelope shape).
|
||||
*
|
||||
* Portable core: no browser APIs, no React. Builds the in-memory envelope, the
|
||||
* download filename, and the success-toast message as plain data. The actual file
|
||||
* download (Blob/anchor/DOM) is browser code that lives in the app layer; this
|
||||
* module only shapes what that code serializes and reports.
|
||||
*
|
||||
* The envelope `version` is the **file-format** version, distinct from the
|
||||
* per-record `version` fields each snippet/dataset carries (their read-time
|
||||
* migration target — see snippet.ts / dataset.ts).
|
||||
*/
|
||||
|
||||
import type { Dataset } from './dataset';
|
||||
import type { Snippet } from './snippet';
|
||||
|
||||
/**
|
||||
* Current export file-format version (spec §08 → "currently `\"1.0\"`"). Bump when
|
||||
* the envelope shape changes in a way importers must branch on; this is NOT the
|
||||
* per-record schema version.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
export interface ExportEnvelope {
|
||||
/** Export format version (currently `"1.0"`). */
|
||||
version: string;
|
||||
/** ISO 8601 timestamp of the export. */
|
||||
exportedAt: string;
|
||||
/** Fixed exporter tag. */
|
||||
exportedBy: 'Astrolabe';
|
||||
/** All snippets, as complete records (each including its record `version`). */
|
||||
snippets: Snippet[];
|
||||
/** All datasets, as complete records (each including its record `version`). */
|
||||
datasets: Dataset[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the export envelope (spec §08 → Export). Stamps the format version, the
|
||||
* export timestamp (`now.toISOString()`), and the fixed exporter tag, and copies
|
||||
* the records into fresh arrays so the envelope does not alias the caller's arrays
|
||||
* (the records themselves are referenced as-is — they are the complete records to
|
||||
* serialize, each keeping its own `version`).
|
||||
*/
|
||||
export function buildExportEnvelope(
|
||||
snippets: ReadonlyArray<Snippet>,
|
||||
datasets: ReadonlyArray<Dataset>,
|
||||
opts: { now: Date },
|
||||
): ExportEnvelope {
|
||||
return {
|
||||
version: EXPORT_ENVELOPE_VERSION,
|
||||
exportedAt: opts.now.toISOString(),
|
||||
exportedBy: 'Astrolabe',
|
||||
snippets: [...snippets],
|
||||
datasets: [...datasets],
|
||||
};
|
||||
}
|
||||
|
||||
/** Two-digit zero-pad for the filename date (mirrors snippet.ts → generateSnippetName). */
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download filename for an export (spec §08 → Filename):
|
||||
* `astrolabe-project-YYYY-MM-DD.json`, where the date is today's date (export day).
|
||||
* Uses local date parts, like `generateSnippetName`.
|
||||
*/
|
||||
export function exportFilename(now: Date): string {
|
||||
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
return `astrolabe-project-${date}.json`;
|
||||
}
|
||||
|
||||
/** Pluralize a count's noun: "1 snippet" / "4 snippets". */
|
||||
function countClause(count: number, noun: string): string {
|
||||
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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')}`;
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CURRENT_DATASET_VERSION, type Dataset } from './dataset';
|
||||
import {
|
||||
applyDatasetRenamesToSnippets,
|
||||
dedupeIncomingDatasetNames,
|
||||
importSummaryMessage,
|
||||
normalizeImport,
|
||||
reassignCollidingSnippetIds,
|
||||
} from './import-normalize';
|
||||
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
|
||||
|
||||
/** Deterministic id generator returning id-1, id-2, … */
|
||||
function counterIds(prefix = 'id'): () => string {
|
||||
let n = 0;
|
||||
return () => `${prefix}-${++n}`;
|
||||
}
|
||||
|
||||
const FIXED_NOW = new Date('2026-06-07T10:00:00.000Z');
|
||||
const FIXED_NOW_ISO = FIXED_NOW.toISOString();
|
||||
|
||||
/** Parse a spec string and read its top-level `data.name` (typed for lint). */
|
||||
function dataName(spec: string): string {
|
||||
return (JSON.parse(spec) as { data: { name: string } }).data.name;
|
||||
}
|
||||
|
||||
/** A fully-current snippet record (carries an ISO `created`). */
|
||||
function currentSnippetRecord(over: Partial<Snippet> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 's-existing',
|
||||
version: CURRENT_SNIPPET_VERSION,
|
||||
name: 'Bar chart',
|
||||
created: '2025-01-02T03:04:05.000Z',
|
||||
modified: '2025-02-02T03:04:05.000Z',
|
||||
spec: '{"mark":"bar"}',
|
||||
draftSpec: '{"mark":"bar"}',
|
||||
comment: 'hi',
|
||||
tags: ['fav'],
|
||||
datasetRefs: ['Sales'],
|
||||
meta: { k: 1 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** A minimal current dataset record. */
|
||||
function datasetRecord(over: Partial<Dataset> = {}): Dataset {
|
||||
return {
|
||||
id: 1,
|
||||
version: CURRENT_DATASET_VERSION,
|
||||
name: 'Sales',
|
||||
data: 'a,b\n1,2',
|
||||
format: 'csv',
|
||||
source: 'inline',
|
||||
comment: '',
|
||||
rowCount: 1,
|
||||
columnCount: 2,
|
||||
columns: ['a', 'b'],
|
||||
columnTypes: [
|
||||
{ name: 'a', type: 'number' },
|
||||
{ name: 'b', type: 'number' },
|
||||
],
|
||||
size: 7,
|
||||
created: '2025-01-01T00:00:00.000Z',
|
||||
modified: '2025-01-01T00:00:00.000Z',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('normalizeImport — shape detection', () => {
|
||||
it('recognizes the Astrolabe export envelope (snippets + datasets)', () => {
|
||||
const parsed = {
|
||||
version: '1.0',
|
||||
exportedBy: 'Astrolabe',
|
||||
snippets: [currentSnippetRecord()],
|
||||
datasets: [datasetRecord()],
|
||||
};
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||
expect(result.snippets).toHaveLength(1);
|
||||
expect(result.datasets).toHaveLength(1);
|
||||
expect(result.datasets[0].name).toBe('Sales');
|
||||
});
|
||||
|
||||
it('treats a top-level array as a bare list of snippets (no datasets)', () => {
|
||||
const parsed = [currentSnippetRecord({ id: 'a' }), currentSnippetRecord({ id: 'b' })];
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||
expect(result.snippets).toHaveLength(2);
|
||||
expect(result.datasets).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a non-envelope object as a single snippet', () => {
|
||||
const parsed = currentSnippetRecord();
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||
expect(result.snippets).toHaveLength(1);
|
||||
expect(result.datasets).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats an object with version but no snippets array as a single snippet', () => {
|
||||
// `version` is a record field here, not an envelope marker — no snippets array.
|
||||
const parsed = currentSnippetRecord();
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||
expect(result.snippets).toHaveLength(1);
|
||||
expect(result.snippets[0].name).toBe('Bar chart');
|
||||
});
|
||||
|
||||
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: [] });
|
||||
});
|
||||
|
||||
it('ignores a non-array datasets field on the envelope', () => {
|
||||
const parsed = { version: '1.0', snippets: [currentSnippetRecord()], datasets: 'nope' };
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||
expect(result.datasets).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeImport — snippet normalization', () => {
|
||||
it('preserves an already-current snippet and does NOT add the imported tag', () => {
|
||||
const result = normalizeImport([currentSnippetRecord()], { now: FIXED_NOW });
|
||||
const s = result.snippets[0];
|
||||
expect(s.id).toBe('s-existing');
|
||||
expect(s.name).toBe('Bar chart');
|
||||
expect(s.created).toBe('2025-01-02T03:04:05.000Z');
|
||||
expect(s.modified).toBe('2025-02-02T03:04:05.000Z');
|
||||
expect(s.comment).toBe('hi');
|
||||
expect(s.tags).toEqual(['fav']);
|
||||
expect(s.datasetRefs).toEqual(['Sales']);
|
||||
expect(s.meta).toEqual({ k: 1 });
|
||||
expect(s.version).toBe(CURRENT_SNIPPET_VERSION);
|
||||
expect(s.tags).not.toContain('imported');
|
||||
});
|
||||
|
||||
it('fills missing fields on a current snippet with sensible fallbacks', () => {
|
||||
const parsed = [{ created: '2025-03-03T03:03:03.000Z', spec: '{"mark":"line"}' }];
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW, makeId: counterIds() });
|
||||
const s = result.snippets[0];
|
||||
expect(s.id).toBe('id-1');
|
||||
expect(s.name).toBe('Untitled');
|
||||
expect(s.modified).toBe('2025-03-03T03:03:03.000Z'); // falls back to created
|
||||
expect(s.draftSpec).toBe('{"mark":"line"}'); // falls back to spec
|
||||
expect(s.comment).toBe('');
|
||||
expect(s.tags).toEqual([]); // current shape → not tagged
|
||||
expect(s.datasetRefs).toEqual([]);
|
||||
expect(s.meta).toEqual({});
|
||||
});
|
||||
|
||||
it('maps foreign field names content→spec, draft→draftSpec, createdAt→created', () => {
|
||||
const parsed = [
|
||||
{
|
||||
id: 'foreign-1',
|
||||
content: '{"mark":"point"}',
|
||||
draft: '{"mark":"point","x":1}',
|
||||
createdAt: '2024-12-12T08:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||
const s = result.snippets[0];
|
||||
expect(s.spec).toBe('{"mark":"point"}');
|
||||
expect(s.draftSpec).toBe('{"mark":"point","x":1}');
|
||||
expect(s.created).toBe('2024-12-12T08:00:00.000Z'); // derived from source createdAt
|
||||
expect(s.modified).toBe('2024-12-12T08:00:00.000Z');
|
||||
});
|
||||
|
||||
it('tags foreign/older snippets "imported" and generates missing timestamps to now', () => {
|
||||
const parsed = [{ content: '{"mark":"area"}' }];
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW, makeId: counterIds() });
|
||||
const s = result.snippets[0];
|
||||
expect(s.tags).toContain('imported');
|
||||
expect(s.created).toBe(FIXED_NOW_ISO);
|
||||
expect(s.modified).toBe(FIXED_NOW_ISO);
|
||||
expect(s.id).toBe('id-1');
|
||||
});
|
||||
|
||||
it('does not duplicate the imported tag when already present', () => {
|
||||
const parsed = [{ content: '{}', tags: ['imported', 'x'] }];
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||
expect(result.snippets[0].tags).toEqual(['imported', 'x']);
|
||||
});
|
||||
|
||||
it('treats a bare YYYY-MM-DD created as foreign (tags imported)', () => {
|
||||
// A date-only stamp is not the ISO timestamp shape → foreign → tagged + regenerated.
|
||||
const parsed = [{ created: '2024-05-05', content: '{}' }];
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||
const s = result.snippets[0];
|
||||
expect(s.tags).toContain('imported');
|
||||
expect(s.created).toBe('2024-05-05'); // derived from the present source timestamp
|
||||
});
|
||||
|
||||
it('coerces an object spec/draftSpec into pretty JSON string form', () => {
|
||||
const parsed = [
|
||||
{
|
||||
created: '2025-01-01T00:00:00.000Z',
|
||||
spec: { mark: 'bar', encoding: { x: { field: 'a' } } },
|
||||
},
|
||||
];
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||
const s = result.snippets[0];
|
||||
expect(s.spec).toBe(JSON.stringify({ mark: 'bar', encoding: { x: { field: 'a' } } }, null, 2));
|
||||
expect(s.draftSpec).toBe(s.spec); // draft falls back to spec
|
||||
});
|
||||
|
||||
it('falls back spec to {} when entirely absent', () => {
|
||||
const parsed = [{ created: '2025-01-01T00:00:00.000Z' }];
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||
expect(result.snippets[0].spec).toBe('{}');
|
||||
expect(result.snippets[0].draftSpec).toBe('{}');
|
||||
});
|
||||
|
||||
it('is deterministic with injected now and makeId', () => {
|
||||
const parsed = [{ content: '{}' }, { content: '{}' }];
|
||||
const a = normalizeImport(parsed, { now: FIXED_NOW, makeId: counterIds() });
|
||||
const b = normalizeImport(parsed, { now: FIXED_NOW, makeId: counterIds() });
|
||||
expect(a).toEqual(b);
|
||||
expect(a.snippets.map((s) => s.id)).toEqual(['id-1', 'id-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeImport — dataset normalization', () => {
|
||||
it('preserves envelope dataset summary fields without re-profiling', () => {
|
||||
const parsed = { version: '1.0', snippets: [], datasets: [datasetRecord()] };
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||
const d = result.datasets[0];
|
||||
expect(d.rowCount).toBe(1);
|
||||
expect(d.columnCount).toBe(2);
|
||||
expect(d.columns).toEqual(['a', 'b']);
|
||||
expect(d.columnTypes).toHaveLength(2);
|
||||
expect(d.version).toBe(CURRENT_DATASET_VERSION);
|
||||
});
|
||||
|
||||
it('fills gaps with defaults and coerces id to a number', () => {
|
||||
const parsed = { version: '1.0', snippets: [], datasets: [{ id: '42', name: 'D' }] };
|
||||
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||
const d = result.datasets[0];
|
||||
expect(d.id).toBe(42);
|
||||
expect(d.columns).toEqual([]);
|
||||
expect(d.columnTypes).toEqual([]);
|
||||
expect(d.rowCount).toBeNull();
|
||||
expect(d.columnCount).toBeNull();
|
||||
expect(d.format).toBe('json');
|
||||
expect(d.source).toBe('inline');
|
||||
expect(d.comment).toBe('');
|
||||
expect(d.size).toBe(0);
|
||||
expect(d.created).toBe(FIXED_NOW_ISO);
|
||||
expect(d.modified).toBe(FIXED_NOW_ISO);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedupeIncomingDatasetNames', () => {
|
||||
it('returns names unchanged when there are no collisions', () => {
|
||||
const { datasets, renames } = dedupeIncomingDatasetNames(
|
||||
['Other'],
|
||||
[datasetRecord({ name: 'Sales' })],
|
||||
);
|
||||
expect(datasets[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');
|
||||
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(
|
||||
['Sales'],
|
||||
[datasetRecord({ name: 'Sales' }), datasetRecord({ name: 'Sales' })],
|
||||
);
|
||||
expect(datasets.map((d) => d.name)).toEqual(['Sales 2', 'Sales 3']);
|
||||
expect(renames).toEqual([
|
||||
{ from: 'Sales', to: 'Sales 2' },
|
||||
{ from: 'Sales', to: 'Sales 3' },
|
||||
]);
|
||||
});
|
||||
|
||||
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
|
||||
expect(renames).toEqual([{ from: 'sales', to: 'sales 2' }]);
|
||||
});
|
||||
|
||||
it('only clones datasets whose name changed', () => {
|
||||
const keep = datasetRecord({ name: 'Untouched' });
|
||||
const { datasets } = dedupeIncomingDatasetNames(['Sales'], [keep]);
|
||||
expect(datasets[0]).toBe(keep);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reassignCollidingSnippetIds', () => {
|
||||
const mk = (id: string): Snippet => ({
|
||||
id,
|
||||
version: CURRENT_SNIPPET_VERSION,
|
||||
name: id,
|
||||
created: FIXED_NOW_ISO,
|
||||
modified: FIXED_NOW_ISO,
|
||||
spec: '{}',
|
||||
draftSpec: '{}',
|
||||
comment: '',
|
||||
tags: [],
|
||||
datasetRefs: [],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
it('keeps a non-colliding id untouched (same object reference)', () => {
|
||||
const s = mk('fresh');
|
||||
const [out] = reassignCollidingSnippetIds(['existing'], [s], counterIds());
|
||||
expect(out).toBe(s);
|
||||
});
|
||||
|
||||
it('reassigns an incoming id that collides with an existing snippet', () => {
|
||||
const out = reassignCollidingSnippetIds(['dup'], [mk('dup')], counterIds());
|
||||
expect(out[0].id).toBe('id-1');
|
||||
expect(out[0].name).toBe('dup'); // other fields preserved
|
||||
});
|
||||
|
||||
it('gives two incoming snippets sharing one id distinct fresh ids', () => {
|
||||
const out = reassignCollidingSnippetIds(['dup'], [mk('dup'), mk('dup')], counterIds());
|
||||
expect(out.map((s) => s.id)).toEqual(['id-1', 'id-2']);
|
||||
});
|
||||
|
||||
it('skips a generated id that would itself collide with a reserved id', () => {
|
||||
// First generated id "id-1" is already reserved → must skip to "id-2".
|
||||
const out = reassignCollidingSnippetIds(['dup', 'id-1'], [mk('dup')], counterIds());
|
||||
expect(out[0].id).toBe('id-2');
|
||||
});
|
||||
|
||||
it('keeps all reassigned ids distinct even when a fresh id matches a later input id', () => {
|
||||
// Reservation is left-to-right: the first colliding snippet takes "id-1" and
|
||||
// reserves it; the second snippet's existing id is also "id-1", now reserved,
|
||||
// so it too is reassigned (→ "id-2"). No two snippets end up sharing an id.
|
||||
const out = reassignCollidingSnippetIds(['dup'], [mk('dup'), mk('id-1')], counterIds());
|
||||
expect(out[0].id).toBe('id-1');
|
||||
expect(out[1].id).toBe('id-2');
|
||||
expect(new Set(out.map((s) => s.id)).size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyDatasetRenamesToSnippets', () => {
|
||||
const referencing: Snippet = {
|
||||
id: 'r1',
|
||||
version: CURRENT_SNIPPET_VERSION,
|
||||
name: 'Uses Sales',
|
||||
created: FIXED_NOW_ISO,
|
||||
modified: FIXED_NOW_ISO,
|
||||
spec: JSON.stringify({ data: { name: 'Sales' }, mark: 'bar' }, null, 2),
|
||||
draftSpec: JSON.stringify({ data: { name: 'Sales' }, mark: 'line' }, null, 2),
|
||||
comment: '',
|
||||
tags: [],
|
||||
datasetRefs: ['Sales'],
|
||||
meta: {},
|
||||
};
|
||||
|
||||
it('rewrites spec, draftSpec, and datasetRefs for a referencing snippet', () => {
|
||||
const [out] = applyDatasetRenamesToSnippets([referencing], [{ from: 'Sales', to: 'Sales 2' }]);
|
||||
expect(dataName(out.spec)).toBe('Sales 2');
|
||||
expect(dataName(out.draftSpec)).toBe('Sales 2');
|
||||
expect(out.draftSpec).toContain('"line"'); // draft kept its own mark
|
||||
expect(out.datasetRefs).toEqual(['Sales 2']);
|
||||
});
|
||||
|
||||
it('matches the rename target case-insensitively', () => {
|
||||
const lower: Snippet = { ...referencing, datasetRefs: ['Sales'] };
|
||||
const [out] = applyDatasetRenamesToSnippets([lower], [{ from: 'sales', to: 'sales 2' }]);
|
||||
expect(dataName(out.spec)).toBe('sales 2');
|
||||
expect(out.datasetRefs).toEqual(['sales 2']);
|
||||
});
|
||||
|
||||
it('leaves a non-referencing snippet untouched (same reference)', () => {
|
||||
const unrelated: Snippet = {
|
||||
...referencing,
|
||||
id: 'u1',
|
||||
spec: JSON.stringify({ data: { name: 'Other' } }, null, 2),
|
||||
draftSpec: JSON.stringify({ data: { name: 'Other' } }, null, 2),
|
||||
datasetRefs: ['Other'],
|
||||
};
|
||||
const out = applyDatasetRenamesToSnippets([unrelated], [{ from: 'Sales', to: 'Sales 2' }]);
|
||||
expect(out[0]).toBe(unrelated);
|
||||
});
|
||||
|
||||
it('returns a fresh array (no aliasing) when there are no renames', () => {
|
||||
const input = [referencing];
|
||||
const out = applyDatasetRenamesToSnippets(input, []);
|
||||
expect(out).not.toBe(input);
|
||||
expect(out[0]).toBe(referencing);
|
||||
});
|
||||
|
||||
it('applies multiple renames to one snippet', () => {
|
||||
const twoRefs: Snippet = {
|
||||
...referencing,
|
||||
spec: JSON.stringify(
|
||||
{ layer: [{ data: { name: 'Sales' } }, { data: { name: 'Regions' } }] },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
draftSpec: JSON.stringify(
|
||||
{ layer: [{ data: { name: 'Sales' } }, { data: { name: 'Regions' } }] },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
datasetRefs: ['Regions', 'Sales'],
|
||||
};
|
||||
const [out] = applyDatasetRenamesToSnippets(
|
||||
[twoRefs],
|
||||
[
|
||||
{ from: 'Sales', to: 'Sales 2' },
|
||||
{ from: 'Regions', to: 'Regions 2' },
|
||||
],
|
||||
);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Import normalization — shape detection, per-record normalization to the current
|
||||
* model, and pure merge helpers (spec §08 → Import; docs/architecture/07 §5, §6).
|
||||
*
|
||||
* Portable core: no browser APIs, no React, no store/file/IndexedDB access. Plain
|
||||
* 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.
|
||||
*
|
||||
* `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_DATASET_VERSION, type DataSource, type Dataset } from './dataset';
|
||||
import type { DataFormat } from './format-detection';
|
||||
import { makeUniqueName } from './naming';
|
||||
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
|
||||
import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from './spec-refs';
|
||||
import type { ColumnType } from './type-inference';
|
||||
|
||||
/** The tag stamped onto foreign/older snippets so the user can find imports. */
|
||||
const IMPORTED_TAG = 'imported';
|
||||
|
||||
/** Result of normalizing a parsed import payload onto the current model. */
|
||||
export interface NormalizedImport {
|
||||
snippets: Snippet[];
|
||||
datasets: Dataset[];
|
||||
}
|
||||
|
||||
/** A single dataset rename applied during dedupe (`from` original → `to` unique). */
|
||||
export interface DatasetRename {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface NormalizeImportOptions {
|
||||
/** Clock injection for generated timestamps; defaults to the current time. */
|
||||
now?: Date;
|
||||
/** Id injection for generated snippet ids; defaults to `crypto.randomUUID`. */
|
||||
makeId?: () => string;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Small shape helpers (kept private; mirror migrateSnippet's fallback approach
|
||||
// without importing from app/infrastructure — core cannot depend on app).
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Coerce a stored spec field (object or string) into the canonical string form. */
|
||||
function asSpecText(value: unknown, fallback: string): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value == null) return fallback;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ISO-ish creation timestamp marks an already-current Astrolabe snippet. We
|
||||
* accept the common ISO 8601 lead-in `YYYY-MM-DDTHH:MM` (with optional seconds,
|
||||
* fractional seconds, and zone) — the shape `Date#toISOString` produces. We do
|
||||
* not require the trailing `Z`/offset so a hand-edited but clearly-current
|
||||
* timestamp still counts; we deliberately reject a bare `YYYY-MM-DD` date, which
|
||||
* older/foreign shapes use, so those still get normalized + tagged.
|
||||
*/
|
||||
function isIsoTimestamp(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value);
|
||||
}
|
||||
|
||||
/** A trimmed, non-empty string, or undefined — used to derive a source timestamp. */
|
||||
function asNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Per-record normalization
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Normalize one raw snippet record onto the current `Snippet` shape.
|
||||
*
|
||||
* - Already-current (carries an ISO `created`): preserve existing fields with
|
||||
* fallbacks; do NOT add the "imported" tag.
|
||||
* - Foreign/older: map alternative field names (`content` → spec, `draft` →
|
||||
* draftSpec, `createdAt` → created), generate missing timestamps, fill all
|
||||
* gaps with defaults, and ensure the "imported" tag is present.
|
||||
*
|
||||
* Specs are coerced to canonical JSON-string form; draftSpec falls back to spec.
|
||||
* `version` is always stamped to the current value (a missing/old version is the
|
||||
* earliest shape, migrated up here).
|
||||
*/
|
||||
function normalizeSnippet(raw: unknown, nowIso: string, makeId: () => string): Snippet {
|
||||
const r = isPlainObject(raw) ? raw : {};
|
||||
|
||||
// Field mapping for foreign shapes: prefer the current field, fall back to the
|
||||
// alternative name (`content` → spec, `draft` → draftSpec, `createdAt` → created).
|
||||
const specSource = r.spec ?? r.content;
|
||||
const draftSource = r.draftSpec ?? r.draft;
|
||||
const createdSource = asNonEmptyString(r.created) ?? asNonEmptyString(r.createdAt);
|
||||
|
||||
const isCurrent = isIsoTimestamp(r.created);
|
||||
|
||||
const spec = asSpecText(specSource, '{}');
|
||||
const draftSpec = asSpecText(draftSource, spec);
|
||||
|
||||
// Timestamps: a current record keeps its ISO `created`; otherwise derive from a
|
||||
// present source timestamp (created/createdAt), else fall back to `now`.
|
||||
const created = isCurrent ? (r.created as string) : (createdSource ?? nowIso);
|
||||
const modified = asNonEmptyString(r.modified) ?? created;
|
||||
|
||||
// Tags: preserve any existing tags; for foreign/older shapes ensure "imported"
|
||||
// is present (without duplicating it).
|
||||
const existingTags = Array.isArray(r.tags)
|
||||
? (r.tags as unknown[]).filter((t): t is string => typeof t === 'string')
|
||||
: [];
|
||||
const tags = isCurrent
|
||||
? existingTags
|
||||
: existingTags.includes(IMPORTED_TAG)
|
||||
? existingTags
|
||||
: [...existingTags, IMPORTED_TAG];
|
||||
|
||||
const datasetRefs = Array.isArray(r.datasetRefs)
|
||||
? (r.datasetRefs as unknown[]).filter((d): d is string => typeof d === 'string')
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: typeof r.id === 'string' && r.id !== '' ? r.id : makeId(),
|
||||
version: CURRENT_SNIPPET_VERSION,
|
||||
name: typeof r.name === 'string' ? r.name : 'Untitled',
|
||||
created,
|
||||
modified,
|
||||
spec,
|
||||
draftSpec,
|
||||
comment: typeof r.comment === 'string' ? r.comment : '',
|
||||
tags,
|
||||
datasetRefs,
|
||||
meta: isPlainObject(r.meta) ? r.meta : {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one raw dataset record onto the current `Dataset` shape. Envelope
|
||||
* datasets already carry their derived summary fields (rowCount, columns, …); we
|
||||
* preserve those and only fill gaps. We do NOT re-profile here — that needs the
|
||||
* profiling pipeline and would be wasteful for already-summarized records.
|
||||
*/
|
||||
function normalizeDataset(raw: unknown, nowIso: string): Dataset {
|
||||
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_DATASET_VERSION,
|
||||
name: typeof r.name === 'string' ? r.name : 'Untitled',
|
||||
data: r.data,
|
||||
format: (typeof r.format === 'string' ? r.format : 'json') as DataFormat,
|
||||
source: (typeof r.source === 'string' ? r.source : 'inline') as DataSource,
|
||||
comment: typeof r.comment === 'string' ? r.comment : '',
|
||||
rowCount: typeof r.rowCount === 'number' ? r.rowCount : null,
|
||||
columnCount: typeof r.columnCount === 'number' ? r.columnCount : null,
|
||||
columns: Array.isArray(r.columns)
|
||||
? (r.columns as unknown[]).filter((c): c is string => typeof c === 'string')
|
||||
: [],
|
||||
columnTypes: Array.isArray(r.columnTypes)
|
||||
? (r.columnTypes as Array<{ name: string; type: ColumnType }>)
|
||||
: [],
|
||||
size: typeof r.size === 'number' ? r.size : 0,
|
||||
created,
|
||||
modified,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Shape detection (spec §08 "Accepted inputs")
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* - junk (null / non-object / non-array) → empty.
|
||||
*/
|
||||
export function normalizeImport(
|
||||
parsed: unknown,
|
||||
opts: NormalizeImportOptions = {},
|
||||
): NormalizedImport {
|
||||
const nowIso = (opts.now ?? new Date()).toISOString();
|
||||
const makeId = opts.makeId ?? (() => crypto.randomUUID());
|
||||
|
||||
let rawSnippets: unknown[] = [];
|
||||
let rawDatasets: unknown[] = [];
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
// Bare array of snippets.
|
||||
rawSnippets = parsed;
|
||||
} else if (isPlainObject(parsed)) {
|
||||
const hasEnvelope = 'version' in parsed && Array.isArray(parsed.snippets);
|
||||
if (hasEnvelope) {
|
||||
rawSnippets = parsed.snippets as unknown[];
|
||||
if (Array.isArray(parsed.datasets)) rawDatasets = parsed.datasets;
|
||||
} else {
|
||||
// Single snippet object.
|
||||
rawSnippets = [parsed];
|
||||
}
|
||||
}
|
||||
// else: null / non-object / non-array junk → both stay empty.
|
||||
|
||||
return {
|
||||
snippets: rawSnippets.map((s) => normalizeSnippet(s, nowIso, makeId)),
|
||||
datasets: rawDatasets.map((d) => normalizeDataset(d, nowIso)),
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Merge helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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
|
||||
* name actually changed are cloned.
|
||||
*/
|
||||
export function dedupeIncomingDatasetNames(
|
||||
existing: ReadonlyArray<string>,
|
||||
incoming: ReadonlyArray<Dataset>,
|
||||
): { datasets: Dataset[]; renames: DatasetRename[] } {
|
||||
const reserved = new Set(existing.map((n) => n.toLowerCase()));
|
||||
const renames: DatasetRename[] = [];
|
||||
|
||||
const datasets = incoming.map((d) => {
|
||||
const unique = makeUniqueName(d.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 };
|
||||
});
|
||||
|
||||
return { datasets, renames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign ids for incoming snippets whose id already exists (spec §08 "ID
|
||||
* collisions"). The existing snippet keeps its id; the incoming one gets a fresh
|
||||
* unique id. Ids are reserved *as we go* so two incoming snippets sharing one id
|
||||
* both get distinct fresh ids — and a freshly-minted id can't collide with another
|
||||
* incoming snippet either. Only snippets that get a new id are cloned.
|
||||
*/
|
||||
export function reassignCollidingSnippetIds(
|
||||
existingIds: ReadonlyArray<string>,
|
||||
incoming: ReadonlyArray<Snippet>,
|
||||
makeId: () => string = () => crypto.randomUUID(),
|
||||
): Snippet[] {
|
||||
const reserved = new Set(existingIds);
|
||||
|
||||
return incoming.map((s) => {
|
||||
if (!reserved.has(s.id)) {
|
||||
reserved.add(s.id);
|
||||
return s;
|
||||
}
|
||||
let fresh = makeId();
|
||||
while (reserved.has(fresh)) fresh = makeId();
|
||||
reserved.add(fresh);
|
||||
return { ...s, id: fresh };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate dataset renames (from `dedupeIncomingDatasetNames`) 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 spec — the spec is the source of truth, `datasetRefs` mirrors
|
||||
* it. Matching is case-insensitive (consistent with the naming helpers). Only
|
||||
* snippets that actually change are cloned.
|
||||
*
|
||||
* Applied to the imported set ONLY — existing snippets keep their references,
|
||||
* because existing datasets were never renamed (collisions suffix the incoming).
|
||||
*/
|
||||
export function applyDatasetRenamesToSnippets(
|
||||
snippets: ReadonlyArray<Snippet>,
|
||||
renames: ReadonlyArray<DatasetRename>,
|
||||
): Snippet[] {
|
||||
if (renames.length === 0) return snippets.slice();
|
||||
|
||||
return snippets.map((snippet) => {
|
||||
// The names this snippet actually references: what the specs literally
|
||||
// reference (spec casing first, so `renameDatasetInSpec`'s case-sensitive
|
||||
// match gets the right oldName) unioned with the stored `datasetRefs` mirror.
|
||||
// Consulting the spec — not only datasetRefs — makes propagation robust when a
|
||||
// hand-crafted/foreign import references a dataset in its spec without a
|
||||
// matching datasetRefs entry; the renderer resolves by spec, so a missed
|
||||
// rename would otherwise break rendering (arch 07 §3/§6).
|
||||
const referenced = [
|
||||
...extractDatasetRefs(snippet.spec),
|
||||
...extractDatasetRefs(snippet.draftSpec),
|
||||
...snippet.datasetRefs,
|
||||
];
|
||||
const targets: Array<{ oldName: string; to: string }> = [];
|
||||
for (const { from, to } of renames) {
|
||||
const ref = referenced.find((r) => r.toLowerCase() === from.toLowerCase());
|
||||
if (ref !== undefined) targets.push({ oldName: ref, to });
|
||||
}
|
||||
if (targets.length === 0) return snippet;
|
||||
|
||||
let spec = snippet.spec;
|
||||
let draftSpec = snippet.draftSpec;
|
||||
for (const { oldName, to } of targets) {
|
||||
spec = renameDatasetInSpec(spec, oldName, to);
|
||||
draftSpec = renameDatasetInSpec(draftSpec, oldName, to);
|
||||
}
|
||||
|
||||
return { ...snippet, spec, draftSpec, datasetRefs: recomputeDatasetRefs(spec) };
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CURRENT_SETTINGS_VERSION,
|
||||
DEFAULT_SETTINGS,
|
||||
defaultSettings,
|
||||
loadSettings,
|
||||
type UserSettings,
|
||||
} from './settings';
|
||||
|
||||
describe('defaultSettings', () => {
|
||||
it('returns the factory-default shape (spec §07)', () => {
|
||||
expect(defaultSettings()).toEqual({
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
editor: {
|
||||
fontSize: 12,
|
||||
theme: 'auto',
|
||||
minimap: false,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'on',
|
||||
tabSize: 2,
|
||||
},
|
||||
performance: { renderDebounce: 1500 },
|
||||
ui: { theme: 'light', previewFitMode: 'default' },
|
||||
formatting: { dateFormat: 'smart', customDateFormat: '' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns independent copies (mutating one does not affect another)', () => {
|
||||
const a = defaultSettings();
|
||||
const b = defaultSettings();
|
||||
a.editor.fontSize = 18;
|
||||
a.editor.wordWrap = 'off';
|
||||
a.formatting.customDateFormat = 'yyyy';
|
||||
expect(b.editor.fontSize).toBe(12);
|
||||
expect(b.editor.wordWrap).toBe('on');
|
||||
expect(b.formatting.customDateFormat).toBe('');
|
||||
});
|
||||
|
||||
it('matches DEFAULT_SETTINGS by value', () => {
|
||||
expect(defaultSettings()).toEqual(DEFAULT_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_SETTINGS', () => {
|
||||
it('is deeply frozen (read-only reference)', () => {
|
||||
expect(Object.isFrozen(DEFAULT_SETTINGS)).toBe(true);
|
||||
expect(Object.isFrozen(DEFAULT_SETTINGS.editor)).toBe(true);
|
||||
expect(Object.isFrozen(DEFAULT_SETTINGS.ui)).toBe(true);
|
||||
expect(Object.isFrozen(DEFAULT_SETTINGS.formatting)).toBe(true);
|
||||
expect(Object.isFrozen(DEFAULT_SETTINGS.performance)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSettings — valid records', () => {
|
||||
it('round-trips a full valid record (stamping current version)', () => {
|
||||
const record: UserSettings = {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
editor: {
|
||||
fontSize: 14,
|
||||
theme: 'vs-dark',
|
||||
minimap: true,
|
||||
wordWrap: 'off',
|
||||
lineNumbers: 'off',
|
||||
tabSize: 4,
|
||||
},
|
||||
performance: { renderDebounce: 2500 },
|
||||
ui: { theme: 'dark', previewFitMode: 'full' },
|
||||
formatting: { dateFormat: 'custom', customDateFormat: 'yyyy-MM-dd' },
|
||||
};
|
||||
expect(loadSettings(record)).toEqual(record);
|
||||
});
|
||||
|
||||
it('accepts any string for editor.theme and customDateFormat', () => {
|
||||
const out = loadSettings({
|
||||
editor: { theme: 'my-custom-theme' },
|
||||
formatting: { dateFormat: 'custom', customDateFormat: 'HH:mm' },
|
||||
});
|
||||
expect(out.editor.theme).toBe('my-custom-theme');
|
||||
expect(out.formatting.customDateFormat).toBe('HH:mm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSettings — partial records fill gaps', () => {
|
||||
it('keeps a single nested field and fills the rest with defaults', () => {
|
||||
const out = loadSettings({ editor: { fontSize: 14 } });
|
||||
expect(out.editor.fontSize).toBe(14);
|
||||
expect(out).toEqual({
|
||||
...defaultSettings(),
|
||||
editor: { ...defaultSettings().editor, fontSize: 14 },
|
||||
});
|
||||
});
|
||||
|
||||
it('fills entirely-missing groups from defaults', () => {
|
||||
const out = loadSettings({ ui: { theme: 'dark' } });
|
||||
expect(out.ui.theme).toBe('dark');
|
||||
expect(out.editor).toEqual(defaultSettings().editor);
|
||||
expect(out.performance).toEqual(defaultSettings().performance);
|
||||
expect(out.formatting).toEqual(defaultSettings().formatting);
|
||||
});
|
||||
|
||||
it('treats a non-object group as empty (uses defaults)', () => {
|
||||
const out = loadSettings({ editor: 'nope', performance: 42, ui: null });
|
||||
expect(out.editor).toEqual(defaultSettings().editor);
|
||||
expect(out.performance).toEqual(defaultSettings().performance);
|
||||
expect(out.ui).toEqual(defaultSettings().ui);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSettings — numeric coercion and clamping', () => {
|
||||
it('clamps and rounds fontSize to [10, 18]', () => {
|
||||
expect(loadSettings({ editor: { fontSize: 5 } }).editor.fontSize).toBe(10);
|
||||
expect(loadSettings({ editor: { fontSize: 99 } }).editor.fontSize).toBe(18);
|
||||
expect(loadSettings({ editor: { fontSize: 12.7 } }).editor.fontSize).toBe(13);
|
||||
expect(loadSettings({ editor: { fontSize: 14 } }).editor.fontSize).toBe(14);
|
||||
});
|
||||
|
||||
it('coerces tabSize to a positive integer', () => {
|
||||
expect(loadSettings({ editor: { tabSize: 0 } }).editor.tabSize).toBe(1);
|
||||
expect(loadSettings({ editor: { tabSize: -3 } }).editor.tabSize).toBe(1);
|
||||
expect(loadSettings({ editor: { tabSize: 2.9 } }).editor.tabSize).toBe(3);
|
||||
expect(loadSettings({ editor: { tabSize: 8 } }).editor.tabSize).toBe(8);
|
||||
});
|
||||
|
||||
it('clamps renderDebounce to [500, 5000]', () => {
|
||||
expect(loadSettings({ performance: { renderDebounce: 100 } }).performance.renderDebounce).toBe(
|
||||
500,
|
||||
);
|
||||
expect(loadSettings({ performance: { renderDebounce: 9999 } }).performance.renderDebounce).toBe(
|
||||
5000,
|
||||
);
|
||||
expect(loadSettings({ performance: { renderDebounce: 1500 } }).performance.renderDebounce).toBe(
|
||||
1500,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to default for non-number / non-finite numerics', () => {
|
||||
const d = defaultSettings();
|
||||
expect(loadSettings({ editor: { fontSize: '14' } }).editor.fontSize).toBe(d.editor.fontSize);
|
||||
expect(loadSettings({ editor: { fontSize: NaN } }).editor.fontSize).toBe(d.editor.fontSize);
|
||||
expect(loadSettings({ editor: { fontSize: Infinity } }).editor.fontSize).toBe(
|
||||
d.editor.fontSize,
|
||||
);
|
||||
expect(loadSettings({ editor: { tabSize: null } }).editor.tabSize).toBe(d.editor.tabSize);
|
||||
expect(
|
||||
loadSettings({ performance: { renderDebounce: 'fast' } }).performance.renderDebounce,
|
||||
).toBe(d.performance.renderDebounce);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSettings — enum validation', () => {
|
||||
it('falls back when an enum value is not allowed', () => {
|
||||
const d = defaultSettings();
|
||||
expect(loadSettings({ editor: { wordWrap: 'sometimes' } }).editor.wordWrap).toBe(
|
||||
d.editor.wordWrap,
|
||||
);
|
||||
expect(loadSettings({ editor: { lineNumbers: 1 } }).editor.lineNumbers).toBe(
|
||||
d.editor.lineNumbers,
|
||||
);
|
||||
expect(loadSettings({ ui: { theme: 'sepia' } }).ui.theme).toBe(d.ui.theme);
|
||||
expect(loadSettings({ ui: { previewFitMode: 'tall' } }).ui.previewFitMode).toBe(
|
||||
d.ui.previewFitMode,
|
||||
);
|
||||
expect(loadSettings({ formatting: { dateFormat: 'relative' } }).formatting.dateFormat).toBe(
|
||||
d.formatting.dateFormat,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts each allowed enum value', () => {
|
||||
expect(loadSettings({ editor: { wordWrap: 'off' } }).editor.wordWrap).toBe('off');
|
||||
expect(loadSettings({ editor: { lineNumbers: 'off' } }).editor.lineNumbers).toBe('off');
|
||||
expect(loadSettings({ ui: { theme: 'dark' } }).ui.theme).toBe('dark');
|
||||
expect(loadSettings({ ui: { previewFitMode: 'width' } }).ui.previewFitMode).toBe('width');
|
||||
expect(loadSettings({ ui: { previewFitMode: 'height' } }).ui.previewFitMode).toBe('height');
|
||||
expect(loadSettings({ formatting: { dateFormat: 'iso' } }).formatting.dateFormat).toBe('iso');
|
||||
});
|
||||
|
||||
it('falls back when minimap is not a boolean', () => {
|
||||
expect(loadSettings({ editor: { minimap: 'yes' } }).editor.minimap).toBe(false);
|
||||
expect(loadSettings({ editor: { minimap: true } }).editor.minimap).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSettings — junk and edge inputs', () => {
|
||||
it('returns defaults for null/undefined/array/string/number', () => {
|
||||
const d = defaultSettings();
|
||||
expect(loadSettings(null)).toEqual(d);
|
||||
expect(loadSettings(undefined)).toEqual(d);
|
||||
expect(loadSettings([])).toEqual(d);
|
||||
expect(loadSettings([{ editor: { fontSize: 14 } }])).toEqual(d);
|
||||
expect(loadSettings('settings')).toEqual(d);
|
||||
expect(loadSettings(42)).toEqual(d);
|
||||
expect(loadSettings(true)).toEqual(d);
|
||||
});
|
||||
|
||||
it('tolerates unknown extra keys without throwing', () => {
|
||||
const out = loadSettings({
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
futureFeature: { enabled: true },
|
||||
editor: { fontSize: 16, somethingNew: 'x' },
|
||||
});
|
||||
expect(out.editor.fontSize).toBe(16);
|
||||
expect(out).not.toHaveProperty('futureFeature');
|
||||
expect(out.editor).not.toHaveProperty('somethingNew');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSettings — version stamping', () => {
|
||||
it('always stamps the current version regardless of input version', () => {
|
||||
expect(loadSettings({ version: 0 }).version).toBe(CURRENT_SETTINGS_VERSION);
|
||||
expect(loadSettings({ version: 99 }).version).toBe(CURRENT_SETTINGS_VERSION);
|
||||
expect(loadSettings({ version: 'old' }).version).toBe(CURRENT_SETTINGS_VERSION);
|
||||
expect(loadSettings({}).version).toBe(CURRENT_SETTINGS_VERSION);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* UserSettings — persisted user preferences (spec §07, storage shape §09C).
|
||||
*
|
||||
* Portable core: no browser APIs, no React, no Monaco. Defines the settings
|
||||
* record shape, the current schema version, the factory defaults, and the
|
||||
* read-time load/normalize function. The localStorage wiring that actually
|
||||
* reads and writes the JSON lives in infrastructure — this module only knows
|
||||
* the shape and how to coerce arbitrary parsed input back into a valid record.
|
||||
*
|
||||
* Settings load at startup; per spec §07 "Startup load", any missing or
|
||||
* unrecognized value silently falls back to its factory default, so older or
|
||||
* partial saved records never break the app. `loadSettings` is the gate that
|
||||
* guarantees that (the read-time migration, mirroring `migrateSnippet`).
|
||||
*/
|
||||
|
||||
/** Current schema version for a UserSettings record (read-time migration target). */
|
||||
export const CURRENT_SETTINGS_VERSION = 1;
|
||||
|
||||
export interface UserSettings {
|
||||
/** Record schema version, for read-time migration (spec §09C). */
|
||||
version: number;
|
||||
/** Spec-editor configuration (spec §07 → Editor). Applies to the editing surface. */
|
||||
editor: {
|
||||
/** Editor font size, 10–18 px integer (spec §07; default 12). */
|
||||
fontSize: number;
|
||||
/**
|
||||
* Editor color theme id. The `'auto'` sentinel follows the app UI theme
|
||||
* (light app → light editor, dark → dark); any other value is an explicit
|
||||
* override (spec §07; default `'auto'`).
|
||||
*/
|
||||
theme: string;
|
||||
/** Whether the editor minimap (overview strip) is shown (spec §07; default false). */
|
||||
minimap: boolean;
|
||||
/** Soft-wrap long lines (spec §07; default `'on'`). */
|
||||
wordWrap: 'on' | 'off';
|
||||
/** Show the line-number gutter (spec §07; default `'on'`). */
|
||||
lineNumbers: 'on' | 'off';
|
||||
/** Indentation width in spaces, positive integer (spec §07; default 2). */
|
||||
tabSize: number;
|
||||
};
|
||||
/** Preview-performance tuning (spec §07 → Performance). */
|
||||
performance: {
|
||||
/**
|
||||
* Delay (ms) after the user stops typing before the preview re-renders,
|
||||
* 500–5000 (spec §07; default 1500). Lower feels snappier but re-renders
|
||||
* more often; higher is calmer but laggier.
|
||||
*/
|
||||
renderDebounce: number;
|
||||
};
|
||||
/** App-chrome appearance (spec §07 → Appearance; §09C). */
|
||||
ui: {
|
||||
/** Overall UI theme; flips the whole app chrome (spec §07; default `'light'`). */
|
||||
theme: 'light' | 'dark';
|
||||
/**
|
||||
* Preview sizing/fit mode (set by the preview's own Fit control, not a
|
||||
* settings cluster, but persisted in this record per §09C; see _Live
|
||||
* Preview_). Default `'default'`.
|
||||
*/
|
||||
previewFitMode: 'default' | 'width' | 'height' | 'full';
|
||||
};
|
||||
/** Date-rendering preferences (spec §07 → Formatting). */
|
||||
formatting: {
|
||||
/**
|
||||
* How dates render throughout the app (spec §07; default `'smart'`).
|
||||
* `'smart'` = relative/human-friendly; `'iso'` = full ISO 8601 timestamp;
|
||||
* `'custom'` = use `customDateFormat`.
|
||||
*/
|
||||
dateFormat: 'smart' | 'iso' | 'custom';
|
||||
/**
|
||||
* Free-text format string, used only when `dateFormat = 'custom'`
|
||||
* (spec §07; default empty string).
|
||||
*/
|
||||
customDateFormat: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh UserSettings with every field at its factory default
|
||||
* (spec §07). Returns an independent deep copy each call so callers may freely
|
||||
* mutate the result without affecting the shared `DEFAULT_SETTINGS` constant or
|
||||
* each other.
|
||||
*/
|
||||
export function defaultSettings(): UserSettings {
|
||||
return {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
editor: {
|
||||
fontSize: 12,
|
||||
theme: 'auto',
|
||||
minimap: false,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'on',
|
||||
tabSize: 2,
|
||||
},
|
||||
performance: {
|
||||
renderDebounce: 1500,
|
||||
},
|
||||
ui: {
|
||||
theme: 'light',
|
||||
previewFitMode: 'default',
|
||||
},
|
||||
formatting: {
|
||||
dateFormat: 'smart',
|
||||
customDateFormat: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deeply-frozen factory defaults, for read-only reference (e.g. comparing a
|
||||
* form against defaults). To get a mutable copy, call `defaultSettings()`.
|
||||
*/
|
||||
export const DEFAULT_SETTINGS: UserSettings = deepFreeze(defaultSettings());
|
||||
|
||||
/** Recursively freeze an object and its nested plain-object members. */
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (value !== null && typeof value === 'object') {
|
||||
for (const member of Object.values(value)) deepFreeze(member);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Round to an integer and clamp to `[min, max]`. */
|
||||
function clampInt(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, Math.round(value)));
|
||||
}
|
||||
|
||||
/** A finite number, or `null` for `NaN`/`Infinity`/non-numbers. */
|
||||
function asNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** Return `value` if it is one of `allowed`, else `fallback`. */
|
||||
function asEnum<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
|
||||
? (value as T)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/** Safely read a nested settings group as a record (tolerates non-objects). */
|
||||
function group(raw: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const value = raw[key];
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an arbitrary parsed value into a fully-valid UserSettings — the
|
||||
* read-time migration (spec §07 "Startup load"; §09C). Accepts `null`, junk, a
|
||||
* partial object, or a record from an older version. Every missing or
|
||||
* wrong-typed field silently falls back to its default; numeric fields are
|
||||
* coerced and clamped to their ranges; enum fields are validated against their
|
||||
* allowed values; unknown extra keys are ignored. The output `version` is
|
||||
* always stamped to `CURRENT_SETTINGS_VERSION` (like `migrateSnippet`).
|
||||
*
|
||||
* Tolerates partial nesting: `{ editor: { fontSize: 14 } }` keeps 14 and fills
|
||||
* the rest of `editor` (and every other group) from defaults.
|
||||
*/
|
||||
export function loadSettings(raw: unknown): UserSettings {
|
||||
const d = defaultSettings();
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return d;
|
||||
|
||||
const r = raw as Record<string, unknown>;
|
||||
const editor = group(r, 'editor');
|
||||
const performance = group(r, 'performance');
|
||||
const ui = group(r, 'ui');
|
||||
const formatting = group(r, 'formatting');
|
||||
|
||||
const fontSize = asNumber(editor.fontSize);
|
||||
const tabSize = asNumber(editor.tabSize);
|
||||
const renderDebounce = asNumber(performance.renderDebounce);
|
||||
|
||||
return {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
editor: {
|
||||
fontSize: fontSize === null ? d.editor.fontSize : clampInt(fontSize, 10, 18),
|
||||
theme: typeof editor.theme === 'string' ? editor.theme : d.editor.theme,
|
||||
minimap: typeof editor.minimap === 'boolean' ? editor.minimap : d.editor.minimap,
|
||||
wordWrap: asEnum(editor.wordWrap, ['on', 'off'] as const, d.editor.wordWrap),
|
||||
lineNumbers: asEnum(editor.lineNumbers, ['on', 'off'] as const, d.editor.lineNumbers),
|
||||
tabSize: tabSize === null ? d.editor.tabSize : clampInt(tabSize, 1, Number.MAX_SAFE_INTEGER),
|
||||
},
|
||||
performance: {
|
||||
renderDebounce:
|
||||
renderDebounce === null
|
||||
? d.performance.renderDebounce
|
||||
: clampInt(renderDebounce, 500, 5000),
|
||||
},
|
||||
ui: {
|
||||
theme: asEnum(ui.theme, ['light', 'dark'] as const, d.ui.theme),
|
||||
previewFitMode: asEnum(
|
||||
ui.previewFitMode,
|
||||
['default', 'width', 'height', 'full'] as const,
|
||||
d.ui.previewFitMode,
|
||||
),
|
||||
},
|
||||
formatting: {
|
||||
dateFormat: asEnum(
|
||||
formatting.dateFormat,
|
||||
['smart', 'iso', 'custom'] as const,
|
||||
d.formatting.dateFormat,
|
||||
),
|
||||
customDateFormat:
|
||||
typeof formatting.customDateFormat === 'string'
|
||||
? formatting.customDateFormat
|
||||
: d.formatting.customDateFormat,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user