mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Snippet naming: content-derived on publish, frozen on explicit rename
This commit is contained in:
@@ -28,6 +28,14 @@ export interface Snippet {
|
||||
version: number;
|
||||
/** Human-readable title shown in the library. */
|
||||
name: string;
|
||||
/**
|
||||
* Name provenance — the naming hierarchy's gate. `'user'`: explicitly chosen
|
||||
* (rename / metadata panel) — frozen, never rewritten. `'auto'`: app-picked
|
||||
* (timestamp default, builder-generated, or publish-derived) — keeps tracking
|
||||
* the spec's content on publish. Absent on records from before the field
|
||||
* existed; `isAutoNamed` then falls back to recognizing the timestamp shape.
|
||||
*/
|
||||
nameSource?: 'auto' | 'user';
|
||||
/** ISO timestamp — when first created. */
|
||||
created: string;
|
||||
/** ISO timestamp — when last saved. */
|
||||
@@ -92,9 +100,117 @@ export function generateSnippetName(now: Date): string {
|
||||
return `Snippet ${date} ${time}`;
|
||||
}
|
||||
|
||||
/** Whether `name` is (still) an untouched `generateSnippetName` auto-default. */
|
||||
export function isDefaultSnippetName(name: string): boolean {
|
||||
return /^Snippet \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(name.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the snippet's name is app-picked (rewritable on publish) rather than
|
||||
* user-chosen (frozen). Records predating `nameSource` carry no provenance, so
|
||||
* the rule is: never rewrite a name we can't prove the user didn't choose. Two
|
||||
* shapes are provable — the timestamp default, and a name identical to what
|
||||
* the app derives from the snippet's own published spec (only the generator
|
||||
* produces that string for that spec; a user typing it verbatim is naming the
|
||||
* content, which is what tracking preserves).
|
||||
*/
|
||||
export function isAutoNamed(snippet: Pick<Snippet, 'name' | 'nameSource' | 'spec'>): boolean {
|
||||
if (snippet.nameSource !== undefined) return snippet.nameSource === 'auto';
|
||||
if (isDefaultSnippetName(snippet.name)) return true;
|
||||
return deriveSnippetName(snippet.spec) === snippet.name;
|
||||
}
|
||||
|
||||
/** A channel's encoding definition, as far as naming cares about it. */
|
||||
interface EncodingDef {
|
||||
field?: unknown;
|
||||
aggregate?: unknown;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
/** A human phrase for what an encoding channel shows, e.g. "sum of revenue". */
|
||||
function describeEncoding(def: EncodingDef): string | null {
|
||||
if (def.value !== undefined) return null; // a constant — nothing to name
|
||||
const aggregate = typeof def.aggregate === 'string' ? def.aggregate : undefined;
|
||||
if (aggregate === 'count') return 'count';
|
||||
const field = typeof def.field === 'string' ? def.field.trim() : '';
|
||||
if (field === '') return null; // repeat refs / missing field — not nameable
|
||||
if (aggregate === 'distinct') return `unique ${field}`;
|
||||
if (aggregate) return `${aggregate} of ${field}`;
|
||||
return field;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a descriptive name from a spec's content — `"Bar chart of <y> by <x>"`
|
||||
* (the same dialect as the Chart Builder's `generateChartName`, so manually
|
||||
* authored and builder-built snippets read alike in the library; NN/g #6
|
||||
* recognition-over-recall). A spec-level `title` wins verbatim. Returns `null`
|
||||
* when the spec is unparseable or carries too little to describe (no usable
|
||||
* mark/encodings) — callers keep the existing name then.
|
||||
*/
|
||||
export function deriveSnippetName(specText: string): string | null {
|
||||
let spec: unknown;
|
||||
try {
|
||||
spec = JSON.parse(specText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) return null;
|
||||
const s = spec as Record<string, unknown>;
|
||||
|
||||
// Vega-Lite titles are a string, an array of lines, or a params object whose
|
||||
// `text` is either; all collapse to one line here.
|
||||
const titleText = (value: unknown): string => {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (Array.isArray(value))
|
||||
return value
|
||||
.filter((line): line is string => typeof line === 'string')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
if (typeof value === 'object' && value !== null)
|
||||
return titleText((value as { text?: unknown }).text);
|
||||
return '';
|
||||
};
|
||||
const title = titleText(s.title);
|
||||
if (title) return title;
|
||||
|
||||
const markRaw =
|
||||
typeof s.mark === 'string'
|
||||
? s.mark
|
||||
: typeof s.mark === 'object' && s.mark !== null
|
||||
? (s.mark as { type?: unknown }).type
|
||||
: undefined;
|
||||
if (typeof markRaw !== 'string' || markRaw === '') return null;
|
||||
const mark = markRaw.charAt(0).toUpperCase() + markRaw.slice(1);
|
||||
|
||||
const encoding =
|
||||
typeof s.encoding === 'object' && s.encoding !== null
|
||||
? (s.encoding as Record<string, unknown>)
|
||||
: {};
|
||||
const phraseFor = (channel: string): string | null => {
|
||||
const def = encoding[channel];
|
||||
if (typeof def !== 'object' || def === null) return null;
|
||||
return describeEncoding(def);
|
||||
};
|
||||
|
||||
const x = phraseFor('x');
|
||||
const y = phraseFor('y');
|
||||
if (x && y) return `${mark} chart of ${y} by ${x}`;
|
||||
const only = x ?? y ?? phraseFor('theta') ?? phraseFor('color');
|
||||
if (only) return `${mark} chart of ${only}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface CreateSnippetOptions {
|
||||
/** Override the auto-generated name. */
|
||||
name?: string;
|
||||
/**
|
||||
* Name provenance override. Defaults to `'user'` when `name` is given (an
|
||||
* explicit name is presumed chosen) and `'auto'` for the timestamp default;
|
||||
* generators passing a derived `name` (the Chart Builder) say `'auto'` so the
|
||||
* name keeps tracking the spec until the user renames.
|
||||
*/
|
||||
nameSource?: 'auto' | 'user';
|
||||
/** Override the starting spec text (defaults to the sample template). */
|
||||
spec?: string;
|
||||
/** Clock injection for deterministic tests; defaults to the current time. */
|
||||
@@ -117,6 +233,7 @@ export function createSnippet(options: CreateSnippetOptions = {}): Snippet {
|
||||
id: options.id ?? crypto.randomUUID(),
|
||||
version: CURRENT_SNIPPET_VERSION,
|
||||
name: options.name ?? generateSnippetName(now),
|
||||
nameSource: options.nameSource ?? (options.name !== undefined ? 'user' : 'auto'),
|
||||
created: iso,
|
||||
modified: iso,
|
||||
spec,
|
||||
|
||||
Reference in New Issue
Block a user