-
-
Notifications
You must be signed in to change notification settings - Fork 185
Expand file tree
/
Copy pathvlxFile.ts
More file actions
231 lines (216 loc) · 7.33 KB
/
vlxFile.ts
File metadata and controls
231 lines (216 loc) · 7.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
/**
* .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;
}