velxio/frontend/src/utils/projectPayload.ts

154 lines
5.2 KiB
TypeScript

/**
* Shared helpers for serialising the simulator + editor state into a project
* save payload, and for hashing that state to detect dirty changes.
*
* Used by SaveProjectModal (manual save) and useAutoSaveProject (silent
* background updates).
*/
import type { ProjectSaveData } from '../services/projectService';
import type { BoardInstance } from '../types/board';
import type { Wire } from '../types/wire';
import { useEditorStore, chipFileGroupId } from '../store/useEditorStore';
import { useSimulatorStore } from '../store/useSimulatorStore';
/**
* Editor groups owned by programmable custom-chips on the canvas (those whose
* `group-chip-<id>` group exists). A chip's program (ROM source / C) lives in
* its own group, exactly like a board sketch, so it must be serialised and
* dirty-checked alongside the board groups — otherwise saving would drop it.
*/
function chipGroupIdsWithFiles(): string[] {
const sim = useSimulatorStore.getState();
const editor = useEditorStore.getState();
return sim.components
.filter((c) => c.metadataId === 'custom-chip')
.map((c) => chipFileGroupId(c.id))
.filter((gid) => (editor.fileGroups[gid]?.length ?? 0) > 0);
}
/** Strip BoardInstance down to JSON-safe fields (drop runtime state). */
function serialisableBoard(b: BoardInstance) {
return {
id: b.id,
name: b.name,
boardKind: b.boardKind,
x: b.x,
y: b.y,
activeFileGroupId: b.activeFileGroupId,
languageMode: b.languageMode,
serialBaudRate: b.serialBaudRate,
compiledProgram: b.compiledProgram,
// ESP32 board options + uploaded SPIFFS files. Optional, only present
// after the user has opened Board Options... at least once. Both ride
// inside boards_json so there's no DB migration.
boardOptions: b.boardOptions,
spiffsFiles: b.spiffsFiles,
};
}
interface SnapshotInputs {
name?: string;
description?: string;
isPublic?: boolean;
}
/**
* Build the payload sent to POST/PUT /api/projects. Includes everything the
* server cares about: boards_json, file_groups, components, wires, and the
* legacy `files` / `code` fields for backwards compat.
*/
export function buildSavePayload(meta: SnapshotInputs = {}): ProjectSaveData {
const sim = useSimulatorStore.getState();
const editor = useEditorStore.getState();
const activeBoard =
sim.boards.find((b) => b.id === sim.activeBoardId) ?? sim.boards[0];
const boardKind = activeBoard?.boardKind ?? 'arduino-uno';
const activeGroupId = activeBoard?.activeFileGroupId ?? '';
const activeFiles =
(editor.fileGroups[activeGroupId]?.length
? editor.fileGroups[activeGroupId]
: editor.files) ?? editor.files;
// Legacy `code` field — primary .ino content of the active board.
const code =
activeFiles.find((f) => f.name.endsWith('.ino'))?.content ??
activeFiles[0]?.content ??
'';
const boardGroups = sim.boards.map((b) => ({
groupId: b.activeFileGroupId,
files: (editor.fileGroups[b.activeFileGroupId] ?? []).map((f) => ({
name: f.name,
content: f.content,
})),
}));
// Programmable-chip program groups round-trip too — same shape, restored on
// load by replaceFileGroups (the group id is derived from the chip's id).
const chipGroups = chipGroupIdsWithFiles().map((gid) => ({
groupId: gid,
files: (editor.fileGroups[gid] ?? []).map((f) => ({
name: f.name,
content: f.content,
})),
}));
const fileGroups = [...boardGroups, ...chipGroups];
return {
name: meta.name ?? '',
description: meta.description,
is_public: meta.isPublic ?? true,
board_type: boardKind,
files: activeFiles.map((f) => ({ name: f.name, content: f.content })),
file_groups: fileGroups,
code,
components_json: JSON.stringify(sim.components),
wires_json: JSON.stringify(sim.wires),
boards_json: JSON.stringify(sim.boards.map(serialisableBoard)),
};
}
/**
* Compact deterministic fingerprint of the parts of the workspace that the
* server persists. Used for dirty-checking by the auto-save hook.
*
* Excludes meta fields (name/description/isPublic) — those only change via
* the manual save modal.
*/
export function computeProjectStateHash(): string {
const sim = useSimulatorStore.getState();
const editor = useEditorStore.getState();
// Group file contents only for groups referenced by an existing board, in
// a stable order (sorted by groupId).
const referencedGroups = Array.from(
new Set([...sim.boards.map((b) => b.activeFileGroupId), ...chipGroupIdsWithFiles()]),
).sort();
const groupsForHash = referencedGroups.map((gid) => ({
g: gid,
f: (editor.fileGroups[gid] ?? []).map((f) => [f.name, f.content]),
}));
// Wires/components: serialise as-is. Object key order is stable in JSON.stringify
// when the keys are insertion-ordered (which Zustand state respects).
const wiresHash = (sim.wires as Wire[]).map((w) => ({
id: w.id,
s: [w.start.componentId, w.start.pinName],
e: [w.end.componentId, w.end.pinName],
c: w.color,
sig: w.signalType,
wp: w.waypoints,
}));
const payload = {
boards: sim.boards.map(serialisableBoard),
activeId: sim.activeBoardId,
components: sim.components,
wires: wiresHash,
groups: groupsForHash,
};
return JSON.stringify(payload);
}