feat: add Wokwi zip import/export functionality and update dependencies

pull/10/head
David Montero Crespo 2026-03-07 00:14:35 -03:00
parent 44293ba000
commit 88c5f3b19f
5 changed files with 438 additions and 6 deletions

3
.gitignore vendored
View File

@ -76,4 +76,5 @@ wokwi-libs/*/.cache/
!wokwi-libs/wokwi-features/
.claude/settings.local.json
.history/*
.daveagent/*
.daveagent/*
example_zip/*

View File

@ -1,17 +1,18 @@
{
"name": "frontend",
"version": "0.0.0",
"name": "velxio",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.0.0",
"name": "velxio",
"version": "1.0.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@wokwi/elements": "file:../wokwi-libs/wokwi-elements",
"avr8js": "file:../wokwi-libs/avr8js",
"axios": "^1.13.6",
"jszip": "^3.10.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
@ -19,6 +20,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/jszip": "^3.4.0",
"@types/node": "^24.11.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@ -1796,6 +1798,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jszip": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
"integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"jszip": "*"
}
},
"node_modules/@types/node": {
"version": "24.11.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
@ -2638,6 +2650,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3486,6 +3504,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -3513,6 +3537,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -3543,6 +3573,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -3697,6 +3733,18 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -3721,6 +3769,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -3990,6 +4047,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -4102,6 +4165,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -4187,6 +4256,21 @@
"react-dom": ">=18"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -4207,6 +4291,12 @@
"node": ">=4"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@ -4242,6 +4332,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -4317,6 +4413,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -4572,6 +4677,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

@ -23,6 +23,7 @@
"@wokwi/elements": "file:../wokwi-libs/wokwi-elements",
"avr8js": "file:../wokwi-libs/avr8js",
"axios": "^1.13.6",
"jszip": "^3.10.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
@ -30,6 +31,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/jszip": "^3.4.0",
"@types/node": "^24.11.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",

View File

@ -1,10 +1,11 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef } from 'react';
import { useEditorStore } from '../../store/useEditorStore';
import { useSimulatorStore, BOARD_FQBN, BOARD_LABELS } from '../../store/useSimulatorStore';
import { compileCode } from '../../services/compilation';
import { LibraryManagerModal } from '../simulator/LibraryManagerModal';
import { parseCompileResult } from '../../utils/compilationLogger';
import type { CompilationLog } from '../../utils/compilationLogger';
import { exportToWokwiZip, importFromWokwiZip } from '../../utils/wokwiZip';
import './EditorToolbar.css';
interface EditorToolbarProps {
@ -29,6 +30,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
const [compiling, setCompiling] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [libManagerOpen, setLibManagerOpen] = useState(false);
const importInputRef = useRef<HTMLInputElement>(null);
const addLog = useCallback((log: CompilationLog) => {
setCompileLogs((prev: CompilationLog[]) => [...prev, log]);
@ -93,6 +95,36 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
setMessage(null);
};
const handleExport = async () => {
try {
const { components, wires } = useSimulatorStore.getState();
const projectName = files.find((f) => f.name.endsWith('.ino'))?.name.replace('.ino', '') || 'velxio-project';
await exportToWokwiZip(files, components, wires, boardType, projectName);
} catch (err) {
setMessage({ type: 'error', text: 'Export failed.' });
}
};
const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!importInputRef.current) return;
importInputRef.current.value = '';
if (!file) return;
try {
const result = await importFromWokwiZip(file);
const { loadFiles } = useEditorStore.getState();
const { setComponents, setWires, setBoardType, stopSimulation } = useSimulatorStore.getState();
stopSimulation();
if (result.boardType) setBoardType(result.boardType);
setComponents(result.components);
setWires(result.wires);
if (result.files.length > 0) loadFiles(result.files);
setMessage({ type: 'success', text: `Imported ${file.name}` });
} catch (err: any) {
setMessage({ type: 'error', text: err?.message || 'Import failed.' });
}
};
return (
<>
<div className="editor-toolbar">
@ -174,6 +206,39 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
</span>
)}
{/* Import Wokwi zip */}
<input
ref={importInputRef}
type="file"
accept=".zip"
style={{ display: 'none' }}
onChange={handleImportFile}
/>
<button
onClick={() => importInputRef.current?.click()}
className="tb-btn tb-btn-lib"
title="Import Wokwi zip"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
{/* Export Wokwi zip */}
<button
onClick={handleExport}
className="tb-btn tb-btn-lib"
title="Export as Wokwi zip"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</button>
{/* Libraries */}
<button
onClick={() => setLibManagerOpen(true)}

View File

@ -0,0 +1,253 @@
/**
* Wokwi zip import/export
*
* Converts between Wokwi's diagram.json format and Velxio's internal
* component/wire format, bundling everything into a .zip file.
*
* Wokwi zip structure:
* diagram.json parts + connections
* sketch.ino main sketch (or projectname.ino)
* *.h / *.cpp additional files
* libraries.txt optional library list
* wokwi-project.txt optional metadata
*/
import JSZip from 'jszip';
import type { Wire } from '../types/wire';
// ── Type definitions ──────────────────────────────────────────────────────────
interface WokwiPart {
type: string;
id: string;
top: number;
left: number;
rotate?: number;
attrs: Record<string, unknown>;
}
interface WokwiDiagram {
version: number;
author: string;
editor: string;
parts: WokwiPart[];
connections: [string, string, string, string[]][];
}
export interface VelxioComponent {
id: string;
metadataId: string;
x: number;
y: number;
properties: Record<string, unknown>;
}
export interface ImportResult {
boardType: 'arduino-uno' | 'raspberry-pi-pico';
components: VelxioComponent[];
wires: Wire[];
files: Array<{ name: string; content: string }>;
}
// ── Board mappings ────────────────────────────────────────────────────────────
// Wokwi board type → Velxio boardType
const WOKWI_TYPE_TO_BOARD: Record<string, 'arduino-uno' | 'raspberry-pi-pico'> = {
'wokwi-arduino-uno': 'arduino-uno',
'wokwi-arduino-nano': 'arduino-uno',
'wokwi-arduino-mega': 'arduino-uno',
'wokwi-raspberry-pi-pico': 'raspberry-pi-pico',
};
// Velxio boardType → Wokwi type
const BOARD_TO_WOKWI_TYPE: Record<string, string> = {
'arduino-uno': 'wokwi-arduino-uno',
'raspberry-pi-pico': 'wokwi-raspberry-pi-pico',
};
// Velxio boardType → default Wokwi part id
const BOARD_TO_WOKWI_ID: Record<string, string> = {
'arduino-uno': 'uno',
'raspberry-pi-pico': 'pico',
};
// ── Color helpers ─────────────────────────────────────────────────────────────
const COLOR_NAME_TO_HEX: Record<string, string> = {
red: '#ff0000', black: '#000000', green: '#00c800', blue: '#0000ff',
yellow: '#ffff00', orange: '#ff8800', white: '#ffffff', gray: '#808080',
grey: '#808080', purple: '#800080', pink: '#ff69b4', cyan: '#00ffff',
gold: '#ffd700', brown: '#8b4513', magenta: '#ff00ff', lime: '#00ff00',
violet: '#ee82ee', maroon: '#800000', navy: '#000080', teal: '#008080',
};
const HEX_TO_COLOR_NAME: Record<string, string> = {
'#ff0000': 'red', '#000000': 'black', '#00ff00': 'green', '#00c800': 'green',
'#0000ff': 'blue', '#ffff00': 'yellow', '#ff8800': 'orange', '#ffffff': 'white',
'#808080': 'gray', '#800080': 'purple', '#00ffff': 'cyan', '#ffd700': 'gold',
};
function colorToHex(color: string): string {
if (!color) return '#888888';
if (color.startsWith('#')) return color.toLowerCase();
return COLOR_NAME_TO_HEX[color.toLowerCase()] ?? '#888888';
}
function hexToColorName(hex: string): string {
return HEX_TO_COLOR_NAME[hex.toLowerCase()] ?? hex;
}
// ── Type conversion ───────────────────────────────────────────────────────────
function wokwiTypeToMetadataId(type: string): string {
if (type.startsWith('wokwi-')) return type.slice(6);
if (type.startsWith('board-')) return type.slice(6);
return type;
}
function metadataIdToWokwiType(metadataId: string): string {
return `wokwi-${metadataId}`;
}
// ── Export ────────────────────────────────────────────────────────────────────
export async function exportToWokwiZip(
files: Array<{ name: string; content: string }>,
components: VelxioComponent[],
wires: Wire[],
boardType: string,
projectName: string,
): Promise<void> {
const zip = new JSZip();
const boardWokwiType = BOARD_TO_WOKWI_TYPE[boardType] ?? 'wokwi-arduino-uno';
const boardId = BOARD_TO_WOKWI_ID[boardType] ?? 'uno';
// Build parts — board first, then user components
const parts: WokwiPart[] = [
{ type: boardWokwiType, id: boardId, top: 0, left: 0, attrs: {} },
...components.map((c) => ({
type: metadataIdToWokwiType(c.metadataId),
id: c.id,
top: Math.round(c.y),
left: Math.round(c.x),
attrs: c.properties as Record<string, unknown>,
})),
];
// Build connections
const connections: [string, string, string, string[]][] = wires.map((w) => {
const startId = w.start.componentId === 'arduino-uno' ? boardId : w.start.componentId;
const endId = w.end.componentId === 'arduino-uno' ? boardId : w.end.componentId;
return [
`${startId}:${w.start.pinName}`,
`${endId}:${w.end.pinName}`,
hexToColorName(w.color ?? '#888888'),
[],
];
});
const diagram: WokwiDiagram = {
version: 1,
author: 'Velxio',
editor: 'wokwi',
parts,
connections,
};
zip.file('diagram.json', JSON.stringify(diagram, null, 2));
zip.file('wokwi-project.txt', `Exported from Velxio\n\nSimulate this project on https://velxio.dev\n`);
for (const f of files) {
zip.file(f.name, f.content);
}
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${(projectName || 'velxio-project').replace(/[^a-z0-9_-]/gi, '-')}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// ── Import ────────────────────────────────────────────────────────────────────
export async function importFromWokwiZip(file: File): Promise<ImportResult> {
const zip = await JSZip.loadAsync(file);
// diagram.json is required
const diagramEntry = zip.file('diagram.json');
if (!diagramEntry) throw new Error('No diagram.json found in the zip file.');
const diagramText = await diagramEntry.async('string');
const diagram: WokwiDiagram = JSON.parse(diagramText);
// Detect board
const boardPart = diagram.parts.find((p) => WOKWI_TYPE_TO_BOARD[p.type]);
const boardType = boardPart ? WOKWI_TYPE_TO_BOARD[boardPart.type] : 'arduino-uno';
const boardId = boardPart?.id ?? 'uno';
// Convert non-board parts to Velxio components
const components: VelxioComponent[] = diagram.parts
.filter((p) => !WOKWI_TYPE_TO_BOARD[p.type])
.map((p) => ({
id: p.id,
metadataId: wokwiTypeToMetadataId(p.type),
x: p.left,
y: p.top,
properties: { ...p.attrs },
}));
// Convert connections to Velxio wires
const wires: Wire[] = diagram.connections.map((conn, i) => {
const [startStr, endStr, color] = conn;
const colonA = startStr.indexOf(':');
const colonB = endStr.indexOf(':');
const startCompRaw = colonA >= 0 ? startStr.slice(0, colonA) : startStr;
const startPin = colonA >= 0 ? startStr.slice(colonA + 1) : '';
const endCompRaw = colonB >= 0 ? endStr.slice(0, colonB) : endStr;
const endPin = colonB >= 0 ? endStr.slice(colonB + 1) : '';
// Remap board part id → Velxio internal board id
const startId = startCompRaw === boardId ? 'arduino-uno' : startCompRaw;
const endId = endCompRaw === boardId ? 'arduino-uno' : endCompRaw;
return {
id: `wire-${i}-${Date.now()}`,
start: { componentId: startId, pinName: startPin, x: 0, y: 0 },
end: { componentId: endId, pinName: endPin, x: 0, y: 0 },
controlPoints: [],
color: colorToHex(color),
signalType: 'digital' as const,
isValid: true,
};
});
// Read code files (.ino, .h, .cpp, .c)
const CODE_EXTS = new Set(['.ino', '.h', '.cpp', '.c']);
const files: Array<{ name: string; content: string }> = [];
for (const [filename, entry] of Object.entries(zip.files)) {
if (entry.dir) continue;
const basename = filename.split('/').pop() ?? filename;
const ext = '.' + basename.split('.').pop()!.toLowerCase();
if (CODE_EXTS.has(ext)) {
const content = await entry.async('string');
files.push({ name: basename, content });
}
}
// Sort: .ino first, then alphabetically
files.sort((a, b) => {
const aIno = a.name.endsWith('.ino');
const bIno = b.name.endsWith('.ino');
if (aIno && !bIno) return -1;
if (!aIno && bIno) return 1;
return a.name.localeCompare(b.name);
});
return { boardType, components, wires, files };
}