velxio/frontend/src/utils/vlxFile.ts

232 lines
7.3 KiB
TypeScript

/**
* .vlx file format — portable project export/import for OSS Velxio.
*
* Phase 4 of the OSS / pro split. The OSS image has no auth, no DB, no
* server-side project persistence. The user's work is otherwise ephemeral
* (lost on tab refresh). `.vlx` is a single-file JSON snapshot that
* round-trips everything the server-side Save flow captures:
*
* {
* "format": "velxio-project",
* "version": 1,
* "exportedAt": "ISO timestamp",
* "name": "project name (optional)",
* "boards": [...],
* "fileGroups": { "<groupId>": [{ name, content }, ...] },
* "components": [...],
* "wires": [...],
* "activeBoardId": "..." | null
* }
*
* Reading: `parseVlxFile(File) → payload` validates and returns a shape
* directly consumable by `useSimulatorStore.loadProjectState(...)`.
*
* Writing: `buildVlxBlob()` snapshots the current store state into a
* `Blob`, ready to feed an `<a download>` link.
*
* The format is INTENTIONALLY identical to the server's POST/PUT body
* for `/api/projects/`, so a pro user can export-from-Pro / import-into-
* OSS (and vice-versa) without surprises.
*/
import type { BoardInstance } from '../types/board';
import type { Component } from '../types/component';
import type { Wire } from '../types/wire';
import { useEditorStore } from '../store/useEditorStore';
import { useSimulatorStore } from '../store/useSimulatorStore';
const VLX_FORMAT = 'velxio-project';
const VLX_VERSION = 1;
export interface VlxPayload {
format: typeof VLX_FORMAT;
version: number;
exportedAt: string;
name?: string;
boards: Array<{
id: string;
boardKind: string;
x: number;
y: number;
activeFileGroupId: string;
languageMode?: string;
serialBaudRate?: number;
}>;
fileGroups: Record<string, Array<{ name: string; content: string }>>;
components: Component[];
wires: Wire[];
activeBoardId: string | null;
}
function serialisableBoard(b: BoardInstance) {
return {
id: b.id,
boardKind: b.boardKind,
x: b.x,
y: b.y,
activeFileGroupId: b.activeFileGroupId,
languageMode: b.languageMode,
serialBaudRate: b.serialBaudRate,
};
}
/**
* Snapshot the current editor + simulator state into a VlxPayload object.
* Pure function: no side effects.
*/
export function buildVlxPayload(opts: { name?: string } = {}): VlxPayload {
const sim = useSimulatorStore.getState();
const editor = useEditorStore.getState();
// Only persist file groups that are actually referenced by a board.
// Stray groups left over from deleted boards don't need to round-trip.
const referencedGroupIds = new Set(sim.boards.map((b) => b.activeFileGroupId));
const fileGroups: VlxPayload['fileGroups'] = {};
for (const gid of referencedGroupIds) {
fileGroups[gid] = (editor.fileGroups[gid] ?? []).map((f) => ({
name: f.name,
content: f.content,
}));
}
return {
format: VLX_FORMAT,
version: VLX_VERSION,
exportedAt: new Date().toISOString(),
name: opts.name,
boards: sim.boards.map(serialisableBoard),
fileGroups,
components: sim.components,
wires: sim.wires,
activeBoardId: sim.activeBoardId,
};
}
/** Build a Blob (MIME: application/json) carrying the current state. */
export function buildVlxBlob(opts: { name?: string } = {}): Blob {
const payload = buildVlxPayload(opts);
const json = JSON.stringify(payload, null, 2);
return new Blob([json], { type: 'application/json' });
}
/** Sanitise a filename: keep letters, digits, dashes, dots, underscores. */
function safeFilename(name?: string): string {
const base = (name ?? 'velxio-project').trim() || 'velxio-project';
const cleaned = base
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return `${cleaned || 'velxio-project'}.vlx`;
}
/**
* Trigger a browser download of the current state as `<name>.vlx`.
* Returns the filename actually used (for UI feedback).
*/
export function triggerDownloadVlx(opts: { name?: string } = {}): string {
const blob = buildVlxBlob(opts);
const filename = safeFilename(opts.name);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
// The browser starts the download immediately. Revoke the URL on the
// next tick so the click handler has time to settle before GC.
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 0);
return filename;
}
/** Thrown by parseVlxFile when the file isn't a valid .vlx payload. */
export class VlxParseError extends Error {
constructor(message: string) {
super(message);
this.name = 'VlxParseError';
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Validate that `data` is shaped like a VlxPayload. Throws VlxParseError
* on mismatch with a human-readable reason. Keep the checks defensive —
* users may edit .vlx files by hand or feed us a wrong file by accident.
*/
function validatePayload(data: unknown): VlxPayload {
if (!isPlainObject(data)) {
throw new VlxParseError('File is not a JSON object.');
}
if (data.format !== VLX_FORMAT) {
throw new VlxParseError(
`Not a Velxio project file (expected format="${VLX_FORMAT}", got ${JSON.stringify(
data.format,
)}).`,
);
}
if (typeof data.version !== 'number') {
throw new VlxParseError('Missing or invalid "version" field.');
}
if (data.version > VLX_VERSION) {
throw new VlxParseError(
`This file uses .vlx format version ${data.version}, but this Velxio supports up to v${VLX_VERSION}. Update Velxio to open it.`,
);
}
if (!Array.isArray(data.boards)) {
throw new VlxParseError('Missing or invalid "boards" array.');
}
if (!isPlainObject(data.fileGroups)) {
throw new VlxParseError('Missing or invalid "fileGroups" object.');
}
if (!Array.isArray(data.components)) {
throw new VlxParseError('Missing or invalid "components" array.');
}
if (!Array.isArray(data.wires)) {
throw new VlxParseError('Missing or invalid "wires" array.');
}
return data as unknown as VlxPayload;
}
/**
* Read a File object (from a `<input type="file">` change event or a
* drop), parse it as JSON, validate, and return the payload. Throws
* VlxParseError on any failure.
*/
export async function parseVlxFile(file: File): Promise<VlxPayload> {
let text: string;
try {
text = await file.text();
} catch (err) {
throw new VlxParseError(`Could not read file: ${(err as Error).message}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (err) {
throw new VlxParseError(`Invalid JSON: ${(err as Error).message}`);
}
return validatePayload(parsed);
}
/**
* Convenience wrapper: parse the file AND load its contents into the
* simulator stores via `loadProjectState`. Returns the parsed payload
* so the caller can show a confirmation toast or similar.
*/
export async function importVlxFile(file: File): Promise<VlxPayload> {
const payload = await parseVlxFile(file);
useSimulatorStore.getState().loadProjectState({
boards: payload.boards as unknown as BoardInstance[],
fileGroups: payload.fileGroups,
components: payload.components,
wires: payload.wires,
activeBoardId: payload.activeBoardId,
});
return payload;
}