mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Per-chart export: copy/download spec and PNG/SVG from the preview header
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Chart export — pure helpers for the single-chart export affordance
|
||||
* (spec §08 → Per-chart export). Distinct from the workspace export envelope
|
||||
* (`export-envelope.ts`): that backs up the whole library as one JSON file; this
|
||||
* turns *one* snippet into a shareable artifact — its spec as `.vl.json`, or its
|
||||
* rendered image as PNG/SVG.
|
||||
*
|
||||
* The naming is deterministic, and so is producing a **self-contained** spec —
|
||||
* one with every saved-dataset reference replaced by its inline data, so the
|
||||
* exported file renders without Astrolabe. Both live here and are tested. The
|
||||
* clipboard write, the file download, and the image rasterization are browser-side
|
||||
* and stay in `infrastructure/file-transfer` and the chart renderer.
|
||||
*/
|
||||
|
||||
import { prepareSpecForRender, type ResolvableDataset } from './rendering';
|
||||
import { extractDatasetRefs } from './spec-refs';
|
||||
|
||||
/** The formats a single chart can be exported as (file extension == the value). */
|
||||
export type ChartExportFormat = 'vl.json' | 'png' | 'svg';
|
||||
|
||||
/** Cap on the derived base name so a very long snippet title can't blow up the
|
||||
* filename (filesystems and download shelves both balk past ~255 chars). */
|
||||
export const MAX_BASENAME_LEN = 60;
|
||||
|
||||
/** C0 control characters + DEL, built from a string so no literal control byte
|
||||
* ever lands in this source file. Stripped from filenames. */
|
||||
// eslint-disable-next-line no-control-regex -- intentional: scrub control chars from names
|
||||
const CONTROL_CHARS = new RegExp('[\\u0000-\\u001f\\u007f]', 'g');
|
||||
|
||||
/** Characters illegal on Windows or awkward across filesystems and URLs. */
|
||||
const ILLEGAL_CHARS = /[\\/:*?"<>|]/g;
|
||||
|
||||
/**
|
||||
* Turn a snippet's display name into a filesystem- and URL-safe base name.
|
||||
*
|
||||
* Keeps the user's words and **case**, and keeps letters of *any* script (a
|
||||
* Cyrillic or CJK title stays itself — we never ASCII-fold, matching the app's
|
||||
* full-script support). Swaps whitespace and dot runs for single dashes (so the
|
||||
* extension stays unambiguous), drops control + illegal characters, collapses and
|
||||
* trims dashes, and caps the length. Falls back to `"chart"` when nothing usable
|
||||
* remains (a name of only punctuation or whitespace).
|
||||
*/
|
||||
export function snippetFileBasename(name: string): string {
|
||||
const cleaned = name
|
||||
.normalize('NFC')
|
||||
.replace(CONTROL_CHARS, '')
|
||||
.replace(ILLEGAL_CHARS, '')
|
||||
.replace(/[.\s]+/g, '-') // dot and whitespace runs → one dash boundary
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
const capped = cleaned.slice(0, MAX_BASENAME_LEN).replace(/-+$/g, '');
|
||||
return capped || 'chart';
|
||||
}
|
||||
|
||||
/**
|
||||
* Download filename for a single chart export, e.g. `sales-by-region.png`. The
|
||||
* base is derived from the snippet name; `format` is both the extension and the
|
||||
* artifact kind. No date or `astrolabe-` prefix (unlike the workspace export) —
|
||||
* the user is exporting *one named chart* and wants its name on the file.
|
||||
*/
|
||||
export function chartExportFilename(name: string, format: ChartExportFormat): string {
|
||||
return `${snippetFileBasename(name)}.${format}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The saved-dataset names a spec references via `{ data: { name } }` (deduped).
|
||||
* A name the spec defines for itself via top-level `datasets` is excluded — those
|
||||
* are already self-contained. Returns `[]` for spec text that doesn't parse. Drives
|
||||
* whether the export offers an "inline referenced data" option at all.
|
||||
*/
|
||||
export function referencedDatasetNames(specText: string): string[] {
|
||||
// extractDatasetRefs already safe-parses a string (→ [] on bad JSON) and dedups.
|
||||
return extractDatasetRefs(specText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-serialize a spec with every saved-dataset reference replaced by its inline
|
||||
* data, so the exported file renders standalone (outside Astrolabe). Sizing is
|
||||
* left exactly as authored — unlike the preview, no fit-mode is applied. Throws
|
||||
* `DatasetNotFoundError` (from `prepareSpecForRender`) if the spec references a
|
||||
* name not present in `datasets`; the caller surfaces that. `specText` must be
|
||||
* valid JSON (it is the spec the editor is showing).
|
||||
*/
|
||||
export function inlineReferencedDatasets(
|
||||
specText: string,
|
||||
datasets: ReadonlyArray<ResolvableDataset>,
|
||||
): string {
|
||||
const parsed: unknown = JSON.parse(specText);
|
||||
const resolved = prepareSpecForRender(parsed, { datasets, fitMode: 'default' });
|
||||
return JSON.stringify(resolved, null, 2);
|
||||
}
|
||||
Reference in New Issue
Block a user