AES-256 encryption option for project files, progress on importing KiCad symbols

This commit is contained in:
Taras Greben 2026-05-23 11:18:19 +03:00
commit 0a0f9ff9fd
32 changed files with 493 additions and 394 deletions

View file

@ -81,7 +81,7 @@ For commercial licensing inquiries, please contact: Taras Greben <taras.greben@g
## 📦 Third-Party Libraries
* **OpenCV.js:** Computer Vision (Apache 2.0 License)
* **JSZip:** File compression/decompression (MIT License)
* **zip.js:** File compression/decompression (BSD 3-Clause License)
* **WireBender:** Schematic generation/routing library (AGPLv3 or Commercial)
* **Adaptagrams:** Used by WireBender via `libavoid`, `libcola`, and `libvpsc` (LGPL 2.1 or later)
* **KiCad symbol libraries:** Redistributed symbol data bundled as `assets/kicad-symbols-master.zip`

View file

@ -31,27 +31,34 @@ limitations under the License.
---
## JSZip
**License:** MIT License
**Copyright:** (c) 2009-2016 Stuart Knightley, David Duponchel, Franz Buchinger, António Afonso
## zip.js
**License:** BSD 3-Clause License
**Copyright:** (c) 2023, Gildas Lormeau
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
/* canvas-ui.js - Shared Canvas Logic (Mobile/Touch Ready + Resize Hook) */
class PanZoomCanvas {

View file

@ -1,9 +1,3 @@
<!--
~ Copyright (c) 2025-2026 Taras Greben
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
~ See LICENSE file for details.
-->
<!DOCTYPE html>
<html lang="en">
<head>

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
:root {
--border: #cbd5e1;
--text-muted: #94a3b8;

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
/* cv-core.js - Computer Vision Logic for PCB ReTrace Suite */
class CVManager {
constructor() {

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
/* db-core.js - Shared database initialization and schema definitions */
// Global UUID generator shared across all components

View file

@ -1,9 +1,3 @@
<!--
~ Copyright (c) 2025-2026 Taras Greben
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
~ See LICENSE file for details.
-->
<!DOCTYPE html>
<html lang="en">
<head>

View file

@ -1,9 +1,3 @@
<!--
~ Copyright (c) 2025-2026 Taras Greben
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
~ See LICENSE file for details.
-->
<!DOCTYPE html>
<html lang="en">
<head>

View file

@ -1,9 +1,3 @@
<!--
~ Copyright (c) 2025-2026 Taras Greben
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
~ See LICENSE file for details.
-->
<!DOCTYPE html>
<html lang="en">
<head>

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
/* inspector.js - Visual Trace Tracking (v6) */
class Inspector {

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
/* nets.js - Netlist Management (v3) */
class NetManager {

View file

@ -1,9 +1,3 @@
<!--
~ Copyright (c) 2025-2026 Taras Greben
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
~ See LICENSE file for details.
-->
<!DOCTYPE html>
<html lang="en">
<head>

View file

@ -1,9 +1,3 @@
<!--
~ Copyright (c) 2025-2026 Taras Greben
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
~ See LICENSE file for details.
-->
<!DOCTYPE html>
<html lang="en">
<head>
@ -14,7 +8,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schematic ReTrace</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.8.26/dist/zip.min.js"></script>
<script src="db-core.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap');

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
// ── App entry point ───────────────────────────────────────────
import { autoImportDeviceLib } from './kicad.js';
import { S, NET_COLORS, initCanvas } from './state.js';

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
// ═══════════════════════════════════════════════════════════════
// COMPONENT LIBRARY
//

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
// ═══════════════════════════════════════════════════════════════
// schema/db.js — Database layer for Schematic ReTrace
//

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
import { S, canvas, ctx, w2s, getNetColor, GRID } from './state.js';
import { createComp } from './components.js';

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
// ── Undo history ─────────────────────────────────────────────
// Kept separate to avoid circular imports between interaction.js and ui.js.
import { S } from './state.js';

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
// ── Interaction ───────────────────────────────────────────────
import { db } from './db.js';
import { S, canvas, s2w, snap } from './state.js';

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
// ── Exports ───────────────────────────────────────────────────
import { S } from './state.js';
import { createComp } from './components.js';

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
import { db, uuid } from './db.js';
const SCALE = 20 / 2.54;
@ -154,27 +148,66 @@ async function upsertParsedSymbols(parsedSymbols, libName) {
// ── Public Importers ──────────────────────────────────────────────
export async function importSelectedFromZip(zip, filenames) {
let stats = { inserted: 0, updated: 0 };
const filesByLib = {};
for (const filename of filenames) {
const match = filename.match(/([^\/]+)\.kicad_symdir\//);
const libName = match ? match[1] : 'Imported';
if (!filesByLib[libName]) filesByLib[libName] = [];
filesByLib[libName].push(filename);
}
export async function importSelectedFromZip({ reader, entries }, filenames, onProgress) {
const notify = (msg) => { if (onProgress) onProgress(msg); };
const { TextWriter } = zip;
const entryMap = new Map(entries.map(e => [e.filename, e]));
const total = filenames.length;
for (const [libName, files] of Object.entries(filesByLib)) {
notify(`Decompressing 0/${total} files…`);
// Phase 1: decompression
let done = 0;
zip.configure({ /*useWebWorkers: false, */ useCompressionStream: true });
// console.time('[kicad] decompress');
const results = await Promise.all(
filenames.map(async filename => {
const entry = entryMap.get(filename);
if (!entry) return null;
const text = await entry.getData(new TextWriter());
notify(`Decompressing ${++done}/${total} files…`);
return { filename, text };
})
);
// console.timeEnd('[kicad] decompress');
// Phase 2: parsing
notify(`Parsing symbols…`);
// console.time('[kicad] parse');
const textsByLib = {};
for (const result of results) {
if (!result) continue;
const match = result.filename.match(/([^\/]+)\.kicad_symdir\//);
const libName = match ? match[1] : 'Imported';
if (!textsByLib[libName]) textsByLib[libName] = [];
textsByLib[libName].push(result.text);
}
const parsedByLib = {};
let totalSyms = 0;
for (const [libName, fileTexts] of Object.entries(textsByLib)) {
const parsedSymbols = {};
for (const filename of files) {
const text = await zip.files[filename].async("text");
extractTopLevelSymbols(parseSexpr(text), parsedSymbols);
}
for (const text of fileTexts) extractTopLevelSymbols(parseSexpr(text), parsedSymbols);
resolveInheritance(parsedSymbols);
parsedByLib[libName] = parsedSymbols;
totalSyms += Object.keys(parsedSymbols).length;
}
// console.timeEnd('[kicad] parse');
// Phase 3: DB writes
notify(`Writing ${totalSyms} symbols to database…`);
// console.time('[kicad] db-write');
let stats = { inserted: 0, updated: 0 };
const libs = Object.entries(parsedByLib);
for (let i = 0; i < libs.length; i++) {
const [libName, parsedSymbols] = libs[i];
notify(`Writing ${libName} (${i + 1}/${libs.length} libraries)…`);
const res = await upsertParsedSymbols(parsedSymbols, libName);
stats.inserted += res.inserted;
stats.updated += res.updated;
}
// console.timeEnd('[kicad] db-write');
await reader.close();
return stats;
}
@ -195,11 +228,27 @@ export async function autoImportDeviceLib() {
const res = await fetch(ZIP_URL);
if (!res.ok) throw new Error('Failed to fetch zip');
const buffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(buffer);
const { ZipReader, Uint8ArrayReader, TextWriter } = zip;
const buffer = new Uint8Array(await res.arrayBuffer());
const reader = new ZipReader(new Uint8ArrayReader(buffer), {
// useWebWorkers: false,
useCompressionStream: true
});
const entries = await reader.getEntries();
const deviceFiles = Object.keys(zip.files).filter(name => name.includes('/Device.kicad_symdir/') && name.endsWith('.kicad_sym'));
await importSelectedFromZip(zip, deviceFiles);
const parsedSymbols = {};
for (const entry of entries) {
if (!entry.directory
&& entry.filename.includes('/Device.kicad_symdir/')
&& entry.filename.endsWith('.kicad_sym')) {
const text = await entry.getData(new TextWriter());
extractTopLevelSymbols(parseSexpr(text), parsedSymbols);
}
}
await reader.close();
resolveInheritance(parsedSymbols);
await upsertParsedSymbols(parsedSymbols, 'Device');
console.log('[kicad] Default library import complete!');
} catch (err) {
console.error('[kicad] Auto-import failed:', err);

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
// ═══════════════════════════════════════════════════════════════
// LAYOUT — WireBender placement + routing
//

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
// ── Parsers ───────────────────────────────────────────────────
import { classifyComp } from './components.js';

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
// ── State ─────────────────────────────────────────────────────
// Single mutable app state object. All modules share this reference.
export const S = {

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
// ── UI helpers ────────────────────────────────────────────────
import { db, uuid } from './db.js';
import { S, getNetColor } from './state.js';
@ -492,7 +486,13 @@ function initLibraryManager() {
let libsData = {};
const log = (msg) => { logEl.innerHTML += `<div>${msg}</div>`; logEl.scrollTop = logEl.scrollHeight; };
const resetZipUi = () => { defaultUi.style.display = 'flex'; zipUi.style.display = 'none'; currentZip = null; libsData = {}; };
const resetZipUi = () => {
if (currentZip?.reader) currentZip.reader.close().catch(() => {});
currentZip = null;
libsData = {};
defaultUi.style.display = 'flex';
zipUi.style.display = 'none';
};
if (btnOpen) btnOpen.onclick = () => { modal.classList.add('active'); resetZipUi(); };
@ -504,16 +504,25 @@ function initLibraryManager() {
async function processZipBuffer(buffer) {
log('Parsing ZIP directory structure...');
currentZip = await JSZip.loadAsync(buffer);
const files = Object.keys(currentZip.files).filter(n => n.endsWith('.kicad_sym'));
const { ZipReader, Uint8ArrayReader } = zip;
const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(buffer)), {
// useWebWorkers: false,
useCompressionStream: true
});
const entries = await reader.getEntries();
// Keep reader open — needed later when user clicks Import
currentZip = { reader, entries };
libsData = {};
files.forEach(f => {
const match = f.match(/([^\/]+)\.kicad_symdir\//);
const libName = match ? match[1] : 'Imported';
if (!libsData[libName]) libsData[libName] = new Array();
libsData[libName].push({ path: f, name: f.split('/').pop().replace('.kicad_sym','') });
});
entries
.filter(e => !e.directory && e.filename.endsWith('.kicad_sym'))
.forEach(e => {
const match = e.filename.match(/([^\/]+)\.kicad_symdir\//);
const libName = match ? match[1] : 'Imported';
if (!libsData[libName]) libsData[libName] = [];
libsData[libName].push({ path: e.filename, name: e.filename.split('/').pop().replace('.kicad_sym', '') });
});
treeEl.innerHTML = '';
Object.keys(libsData).sort().forEach(libName => {
@ -597,13 +606,28 @@ function initLibraryManager() {
if (selectedPaths.length === 0) return toast('No files selected', 'warn');
// Capture the zip reference BEFORE resetting the UI state!
// Detach without closing — importSelectedFromZip owns the reader now and will close it
const zipRef = currentZip;
resetZipUi();
currentZip = null;
libsData = {};
defaultUi.style.display = 'flex';
zipUi.style.display = 'none';
log(`Extracting and parsing ${selectedPaths.length} files...`);
try {
const stats = await importSelectedFromZip(zipRef, selectedPaths);
const stats = await importSelectedFromZip(zipRef, selectedPaths, (msg) => {
// Update last line if it looks like a progress message, otherwise append
const last = logEl.lastElementChild;
if (last && last.dataset.progress) {
last.textContent = msg;
} else {
const div = document.createElement('div');
div.dataset.progress = '1';
div.textContent = msg;
logEl.appendChild(div);
}
logEl.scrollTop = logEl.scrollHeight;
});
log(`Success: <span style="color:#4ade80">${stats.inserted} inserted</span>, <span style="color:#f0c040">${stats.updated} updated</span>.`);
toast('Import Complete', 'ok');
} catch (err) { log(`<span style="color:var(--accent3)">Error: ${err.message}</span>`); }

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
/**
* spyglass.js
* Handles zooming, panning, and coordinate adjustment for PCB previews.

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
/* stitch-editor.js */
class StitchEditor {

View file

@ -1,9 +1,3 @@
<!--
~ Copyright (c) 2025-2026 Taras Greben
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
~ See LICENSE file for details.
-->
<!DOCTYPE html>
<html lang="en">
<head>
@ -15,7 +9,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="icon" href="favicon.ico">
<title>PCB ReTrace Studio</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.8.26/dist/zip.min.js"></script>
<!-- LIBRARIES -->
<script async src="https://docs.opencv.org/4.x/opencv.js" type="text/javascript"></script>

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
/* studio.js - Main Application Logic */
const ICONS = { RESISTOR: `<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="6" width="16" height="12" rx="3" fill="#bae6fd" stroke="#0ea5e9" stroke-width="1"/><rect x="7" y="6" width="2" height="12" fill="#ef4444"/><rect x="11" y="6" width="2" height="12" fill="#000000"/><rect x="15" y="6" width="2" height="12" fill="#ef4444"/><line x1="1" y1="12" x2="4" y2="12" stroke="#94a3b8" stroke-width="2"/><line x1="20" y1="12" x2="23" y2="12" stroke="#94a3b8" stroke-width="2"/></svg>`, INDUCTOR: `<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="5" width="16" height="14" rx="4" fill="#bbf7d0" stroke="#22c55e" stroke-width="1"/><rect x="7" y="5" width="2" height="14" fill="#cbd5e1"/><rect x="11" y="5" width="2" height="14" fill="#ef4444"/><rect x="15" y="5" width="2" height="14" fill="#ef4444"/><line x1="1" y1="12" x2="4" y2="12" stroke="#94a3b8" stroke-width="2"/><line x1="20" y1="12" x2="23" y2="12" stroke="#94a3b8" stroke-width="2"/></svg>`, COIL: `<svg viewBox="0 0 24 24" fill="none" stroke="#ea580c" stroke-width="2" stroke-linecap="round"><line x1="1" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="23" y2="12"/><path d="M5 12 C5 4 9 4 9 12"/><path d="M9 12 C9 19 10 19 10 12" stroke-opacity="0.5"/><path d="M10 12 C10 4 14 4 14 12"/><path d="M14 12 C14 19 15 19 15 12" stroke-opacity="0.5"/><path d="M15 12 C15 4 19 4 19 12"/></svg>` };
@ -603,48 +597,55 @@ async function importSchemaDependencies(schemaDeps, idMap) {
}
// Device Export
async function exportDeviceZIP() {
if (!window.JSZip) return alert("JSZip required.");
if (!currentDeviceId) return;
const pass = await requestPassphrase('Protect Device Export with Passphrase?', { allowEmpty: true, confirmNew: true });
if (pass === null) return; // cancelled
const { BlobWriter, BlobReader, TextReader, ZipWriter } = zip;
const dev = deviceList.find(d => d.id === currentDeviceId);
const boms = await db.getProjectsByDevice(currentDeviceId);
const zip = new JSZip();
// Fetch ALL nets once (since we don't have an index, we filter in JS)
const allNets = await db.getNets();
const allSchemas = await db.getSchemas();
const allSchemaComps = await db.getSchemaComponents();
// 1. Generate README
const zipBlobWriter = new BlobWriter('application/zip');
const zipWriter = new ZipWriter(zipBlobWriter); // encryption controlled per entry
const encOpts = pass ? { password: pass, encryptionStrength: 3 } : {};
const encNote = pass ? `
ENCRYPTED FILE NOTE:
README.txt is unencrypted and always readable.
All other files require the passphrase to extract.
Recommended extraction tools (outside PCB ReTrace):
Windows : 7-Zip (7-zip.org) free
macOS : The Unarchiver or Keka free
Linux : GNOME Archive Manager (built-in) or p7zip ("7z x filename.zip")
macOS built-in Archive Utility and Linux "unzip" command do not
support AES-256 ZIP encryption use the tools listed above.
` : '';
const readmeContent = `PCB ReTrace Data Export
Type: Full Device Backup (${dev.name})
Generated by: pcb.etaras.com
Date: ${new Date().toISOString()}
${pass ? 'Protection: AES-256 encrypted — requires passphrase to open.\n' : ''}
HOW TO USE:
1. Go to https://pcb.etaras.com/studio.html
2. Click "Import Device" or drag and drop this ZIP file into the tool.
${encNote}
`;
zip.file("README.txt", readmeContent);
await zipWriter.add('README.txt', new TextReader(readmeContent));
// 2. Generate Manifest
const manifest = {
device: dev,
version: DB_VER,
source: "pcb.etaras.com",
boards: []
};
const imgFolder = zip.folder("images");
const manifest = { device: dev, version: db.ver, source: 'pcb.etaras.com', boards: [] };
for (const bom of boms) {
const comps = await db.getComponents(bom.id);
const imgs = await db.getImages(bom.id);
// Filter nets for this specific board
const boardNets = allNets.filter(n => n.projectId === bom.id);
const boardSchemas = allSchemas.filter(s => s.boardId === bom.id);
const boardSchemaIds = new Set(boardSchemas.map(s => s.id));
const imgs = await db.getImages(bom.id);
const boardNets = allNets.filter(n => n.projectId === bom.id);
const boardSchemas = allSchemas.filter(s => s.boardId === bom.id);
const boardSchemaIds = new Set(boardSchemas.map(s => s.id));
const boardSchemaComps = allSchemaComps.filter(sc => boardSchemaIds.has(sc.schemaId));
const overlapsMap = new Map();
@ -653,46 +654,36 @@ HOW TO USE:
ovs.forEach(o => overlapsMap.set(o.id, o));
}
const cleanComps = comps.map(c => { const { boardId, ...rest } = c; return rest; });
const imgMeta = imgs.map(img => ({ id: img.id, name: img.name, type: 'image/jpeg' }));
manifest.boards.push({
meta: bom,
components: cleanComps,
images: imgMeta,
components: comps.map(c => { const { boardId, ...rest } = c; return rest; }),
images: imgs.map(img => ({ id: img.id, name: img.name, type: 'image/jpeg' })),
overlaps: Array.from(overlapsMap.values()),
nets: boardNets,
schemas: boardSchemas,
schemaComponents: boardSchemaComps
nets: boardNets, schemas: boardSchemas, schemaComponents: boardSchemaComps
});
// Image Loop
for (const img of imgs) {
let blobToSave = img.blob;
if (img.blob.type !== 'image/jpeg') {
const bmp = await createImageBitmap(img.blob);
const canvas = document.createElement('canvas');
canvas.width = bmp.width;
canvas.height = bmp.height;
canvas.getContext('2d').drawImage(bmp, 0, 0);
bmp.close();
canvas.width = bmp.width; canvas.height = bmp.height;
canvas.getContext('2d').drawImage(bmp, 0, 0); bmp.close();
blobToSave = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.85));
}
imgFolder.file(`${img.id}.jpg`, blobToSave);
await zipWriter.add(`images/${img.id}.jpg`, new BlobReader(blobToSave), encOpts);
}
}
manifest.deviceSchemas = allSchemas.filter(s => s.deviceId === dev.id && !s.boardId);
const devSchemaIds = new Set(manifest.deviceSchemas.map(s => s.id));
manifest.deviceSchemaComponents = allSchemaComps.filter(sc => devSchemaIds.has(sc.schemaId));
const exportedSchemaComps = manifest.deviceSchemaComponents.concat(
manifest.boards.flatMap(b => b.schemaComponents || [])
);
const exportedSchemaComps = manifest.deviceSchemaComponents.concat(manifest.boards.flatMap(b => b.schemaComponents || []));
manifest.schemaDependencies = await gatherSchemaDependencies(exportedSchemaComps);
zip.file("device.json", JSON.stringify(manifest, null, 2));
zip.generateAsync({ type: "blob" }).then(c => dl(c, `${dev.name}_Backup.zip`, 'application/zip'));
await zipWriter.add('device.json', new TextReader(JSON.stringify(manifest, null, 2)), encOpts);
await zipWriter.close();
dl(await zipBlobWriter.getData(), `${dev.name}_Backup.zip`, 'application/zip');
}
function isValidBoardData(data) {
@ -863,65 +854,88 @@ function setupDragDrop() {
}
async function processZIP(file) {
if (!window.JSZip) return alert("JSZip library not loaded.");
const { BlobReader, BlobWriter, TextWriter, ZipReader } = zip;
try {
const zip = await JSZip.loadAsync(file);
let recognized = false;
// 1. Open without password to inspect entries
let reader = new ZipReader(new BlobReader(file));
let entries = await reader.getEntries();
await reader.close();
// CASE A: Device Backup
if (zip.file("device.json")) {
const content = await zip.file("device.json").async("string");
// 2. Detect encryption and prompt for passphrase once
const isEncrypted = entries.some(e => e.encrypted);
let pass = null;
if (isEncrypted) {
pass = await requestPassphrase('This file is password protected.', {});
if (!pass) return; // user cancelled — cannot proceed without passphrase
}
// 3. Re-open, reading all content (with password if needed)
reader = new ZipReader(new BlobReader(file), pass ? { password: pass } : {});
entries = await reader.getEntries();
// 4. Extract text files and images into a flat Map in one pass
const texts = {}; // filename → string
const imageMap = new Map(); // basename (e.g. "abc123.jpg") → Blob
for (const entry of entries) {
if (entry.directory) continue;
const name = entry.filename;
try {
if (name === 'device.json' || name === 'bom.json') {
texts[name] = await entry.getData(new TextWriter());
} else if (name.startsWith('images/')) {
const blob = await entry.getData(new BlobWriter());
imageMap.set(name.split('/').pop(), blob);
}
} catch (e) {
throw new Error(`Failed to read "${name}". ${isEncrypted ? 'Check your passphrase.' : e.message}`);
}
}
await reader.close();
// 5. Route to correct handler
if (texts['device.json']) {
let manifest;
try { manifest = JSON.parse(content); } catch (e) { throw new Error("device.json is corrupt"); }
// STRICT CHECK
if (isValidDeviceManifest(manifest)) {
recognized = true;
const vMsg = manifest.version ? `(v${manifest.version})` : '';
const sMsg = manifest.source ? `\nSource: ${manifest.source}` : '';
if (confirm(`Restore Device: "${manifest.device.name}" ${vMsg}?${sMsg}\n\nThis will merge boards and overwrite existing components.`)) {
await restoreDevice(manifest, zip);
}
} else {
throw new Error("ZIP contains device.json, but it is missing required fields (id, name, boards).");
}
try { manifest = JSON.parse(texts['device.json']); }
catch (e) { throw new Error('device.json is corrupt.'); }
if (!isValidDeviceManifest(manifest))
throw new Error('ZIP contains device.json, but it is missing required fields (id, name, boards).');
const vMsg = manifest.version ? ` (v${manifest.version})` : '';
const sMsg = manifest.source ? `\nSource: ${manifest.source}` : '';
if (confirm(`Restore Device: "${manifest.device.name}"${vMsg}?${sMsg}\n\nThis will merge boards and overwrite existing components.`))
await restoreDevice(manifest, imageMap);
return;
}
// CASE B: Single Board Backup
else if (zip.file("bom.json")) {
const content = await zip.file("bom.json").async("string");
if (texts['bom.json']) {
let data;
try { data = JSON.parse(content); } catch (e) { throw new Error("bom.json is corrupt"); }
// STRICT CHECK
if (isValidBoardData(data)) {
recognized = true;
if (!currentDeviceId) {
alert("Please select or create a Device first.");
return;
}
if (confirm(`Import Board: "${data.meta.name}" into current Device?`)) {
data.meta.deviceId = currentDeviceId;
await processImportData(data, zip);
}
} else {
throw new Error("ZIP contains bom.json, but it is missing required fields (id, meta, components).");
try { data = JSON.parse(texts['bom.json']); }
catch (e) { throw new Error('bom.json is corrupt.'); }
if (!isValidBoardData(data))
throw new Error('ZIP contains bom.json, but it is missing required fields (id, meta, components).');
if (!currentDeviceId) { alert('Please select or create a Device first.'); return; }
if (confirm(`Import Board: "${data.meta.name}" into current Device?`)) {
data.meta.deviceId = currentDeviceId;
await processImportData(data, imageMap);
}
return;
}
if (!recognized) {
alert("Unrecognized ZIP format.\n\nExpected 'device.json' or 'bom.json' with valid structure.");
}
alert("Unrecognized ZIP format.\n\nExpected 'device.json' or 'bom.json' with valid structure.");
} catch (e) {
console.error(e);
alert("Import Failed: " + e.message);
const msg = e.message || '';
if (msg.toLowerCase().includes('passphrase') || msg.toLowerCase().includes('decrypt')) {
// Wrong passphrase — retry without restarting the import
const retry = await requestPassphrase('Incorrect passphrase — try again.', {});
if (retry) return processZIP(file); // restart with same file
return;
}
alert('Import Failed: ' + msg);
}
}
async function restoreDevice(manifest, zip) {
async function restoreDevice(manifest, imageMap) {
const devId = manifest.device.id;
const existingDev = deviceList.find(d => d.id === devId);
if (!existingDev) {
@ -944,7 +958,6 @@ async function restoreDevice(manifest, zip) {
}
}
const imgFolder = zip.folder("images");
let updatedBoards = 0;
let mergedBoards = 0;
@ -1015,25 +1028,17 @@ async function restoreDevice(manifest, zip) {
// --- UPSERT PHASE ---
// Images (unchanged logic...)
if (imgFolder && boardData.images) {
// Images
if (imageMap && boardData.images) {
for (const im of boardData.images) {
if (!isNewer) {
const existingImg = await db.getImage(im.id);
if (existingImg) continue;
}
const cleanStoredId = im.id.split('/').pop();
let filename = cleanStoredId + ".jpg";
let file = imgFolder.file(filename);
if (!file) file = zip.file("images/" + filename);
if (!file) {
const cleanExt = (im.type.split('/')[1] || 'png');
filename = cleanStoredId + "." + cleanExt;
file = imgFolder.file(filename);
if (!file) file = zip.file("images/" + filename);
}
if (file) {
const blob = await file.async("blob");
const cleanId = im.id.split('/').pop();
const ext = (im.type || 'image/jpeg').split('/')[1] || 'jpg';
const blob = imageMap.get(cleanId + '.jpg') || imageMap.get(cleanId + '.' + ext);
if (blob) {
const cleanName = im.name.replace(/\.[^/.]+$/, "");
await db.addImage({ id: im.id, boardId: boardId, blob, name: cleanName });
}
@ -1132,7 +1137,7 @@ async function restoreDevice(manifest, zip) {
await loadDeviceBoms();
}
async function processImportData(data, zipObj) {
async function processImportData(data, imageMap) {
const idMap = new Map();
if (data.schemaDependencies) {
await importSchemaDependencies(data.schemaDependencies, idMap);
@ -1203,31 +1208,20 @@ async function processImportData(data, zipObj) {
// --- UPSERT PHASE ---
// Images (unchanged logic...)
if (zipObj && data.images) {
const imgFolder = zipObj.folder("images");
if (imgFolder) {
const imgFiles = [];
imgFolder.forEach((path, file) => imgFiles.push(file));
for (const f of imgFiles) {
const fileName = f.name.split('/').pop();
const idFromName = fileName.split('.')[0];
const metaImg = data.images.find(x => x.id === idFromName || x.id.endsWith(idFromName));
const finalId = metaImg ? metaImg.id : idFromName;
const finalName = metaImg ? metaImg.name.replace(/\.[^/.]+$/, "") : fileName;
const mime = metaImg ? metaImg.type : 'image/jpeg';
if (!isNewer) {
const existing = await db.getImage(finalId);
if (existing) continue;
}
const rawBlob = await f.async("blob");
const blob = new Blob([rawBlob], { type: mime });
await db.addImage({ id: finalId, boardId: boardId, blob, name: finalName });
// Images
if (imageMap && data.images) {
for (const im of data.images) {
if (!isNewer) {
const existing = await db.getImage(im.id);
if (existing) continue;
}
const cleanId = im.id.split('/').pop();
const ext = (im.type || 'image/jpeg').split('/')[1] || 'jpg';
const rawBlob = imageMap.get(cleanId + '.jpg') || imageMap.get(cleanId + '.' + ext);
if (rawBlob) {
const blob = new Blob([rawBlob], { type: im.type || 'image/jpeg' });
const cleanName = im.name.replace(/\.[^/.]+$/, "");
await db.addImage({ id: im.id, boardId: boardId, blob, name: cleanName });
}
}
}
@ -1309,12 +1303,12 @@ async function processLegacyImport(b) {
// Board Export
async function exportZIP() {
if (!window.JSZip) return;
const zip = new JSZip();
const meta = bomList.find(x => x.id === currentBomId);
const images = await db.getImages(currentBomId);
const pass = await requestPassphrase('Protect Board Export with Passphrase?', { allowEmpty: true, confirmNew: true });
if (pass === null) return; // cancelled
// Fetch and filter Nets
const { BlobWriter, BlobReader, TextReader, ZipWriter } = zip;
const meta = bomList.find(x => x.id === currentBomId);
const images = await db.getImages(currentBomId);
const allNets = await db.getNets();
const boardNets = allNets.filter(n => n.projectId === currentBomId);
@ -1323,10 +1317,6 @@ async function exportZIP() {
const ovs = await db.getOverlapsForImage(img.id);
ovs.forEach(o => overlapsMap.set(o.id, o));
}
const overlaps = Array.from(overlapsMap.values());
const imgMeta = images.map(i => ({ id: i.id, name: i.name, type: 'image/jpeg' }));
const cleanComps = bomData.map(c => { const { boardId, ...rest } = c; return rest; });
const allSchemas = await db.getSchemas();
const boardSchemas = allSchemas.filter(s => s.boardId === currentBomId);
@ -1335,53 +1325,60 @@ async function exportZIP() {
const boardSchemaComps = allSchemaComps.filter(sc => boardSchemaIds.has(sc.schemaId));
const schemaDeps = await gatherSchemaDependencies(boardSchemaComps);
// 1. Generate README
const encNote = pass ? `
ENCRYPTED FILE NOTE:
README.txt is unencrypted and always readable.
All other files require the passphrase to extract.
Recommended extraction tools (outside PCB ReTrace):
Windows : 7-Zip (7-zip.org) free
macOS : The Unarchiver or Keka free
Linux : GNOME Archive Manager (built-in) or p7zip ("7z x filename.zip")
macOS built-in Archive Utility and Linux "unzip" command do not
support AES-256 ZIP encryption use the tools listed above.
` : '';
const readmeContent = `PCB ReTrace Data Export
Type: Single Board (${meta.name})
Generated by: pcb.etaras.com
Date: ${new Date().toISOString()}
${pass ? 'Protection: AES-256 encrypted — requires passphrase to open.\n' : ''}
HOW TO USE:
1. Go to https://pcb.etaras.com/studio.html
2. Select or Create a Device.
3. Click "Import Board" or drag and drop this ZIP file into the component list.
${encNote}
`;
zip.file("README.txt", readmeContent);
// 2. Generate JSON
const data = {
meta,
components: cleanComps,
images: imgMeta,
overlaps: overlaps,
nets: boardNets, // Add Nets
schemas: boardSchemas,
schemaComponents: boardSchemaComps,
schemaDependencies: schemaDeps,
version: DB_VER,
source: "pcb.etaras.com"
meta, version: db.ver, source: 'pcb.etaras.com',
components: bomData.map(c => { const { boardId, ...rest } = c; return rest; }),
images: images.map(i => ({ id: i.id, name: i.name, type: 'image/jpeg' })),
overlaps: Array.from(overlapsMap.values()),
nets: boardNets, schemas: boardSchemas, schemaComponents: boardSchemaComps,
schemaDependencies: schemaDeps
};
zip.file("bom.json", JSON.stringify(data, null, 2));
const zipBlobWriter = new BlobWriter('application/zip');
const zipWriter = new ZipWriter(zipBlobWriter); // encryption controlled per entry
const encOpts = pass ? { password: pass, encryptionStrength: 3 } : {};
const imgFolder = zip.folder("images");
await zipWriter.add('README.txt', new TextReader(readmeContent));
await zipWriter.add('bom.json', new TextReader(JSON.stringify(data, null, 2)), encOpts);
// Image Loop
for (const img of images) {
let blobToSave = img.blob;
if (img.blob.type !== 'image/jpeg') {
const bmp = await createImageBitmap(img.blob);
const canvas = document.createElement('canvas');
canvas.width = bmp.width;
canvas.height = bmp.height;
canvas.getContext('2d').drawImage(bmp, 0, 0);
bmp.close();
canvas.width = bmp.width; canvas.height = bmp.height;
canvas.getContext('2d').drawImage(bmp, 0, 0); bmp.close();
blobToSave = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.85));
}
imgFolder.file(img.id + ".jpg", blobToSave);
await zipWriter.add(`images/${img.id}.jpg`, new BlobReader(blobToSave), encOpts);
}
zip.generateAsync({ type: "blob" }).then(c => dl(c, meta.name + "_Board.zip", "application/zip"));
await zipWriter.close();
dl(await zipBlobWriter.getData(), `${meta.name}_Board.zip`, 'application/zip');
}
function exportCSV() {
@ -2736,6 +2733,190 @@ const NavManager = {
}
};
function passphraseStrength(p) {
if (!p) return { label: '', color: '' };
const score = (p.length >= 20 ? 3 : p.length >= 12 ? 2 : p.length >= 8 ? 1 : 0)
+ (/[A-Z]/.test(p) ? 1 : 0)
+ (/[0-9]/.test(p) ? 1 : 0)
+ (/[^A-Za-z0-9]/.test(p) || / /.test(p) ? 1 : 0);
if (score >= 5) return { label: '🟢 Strong', color: '#059669' };
if (score >= 3) return { label: '🟡 Moderate', color: '#d97706' };
return { label: '🔴 Weak', color: '#ef4444' };
}
/**
* @param {string} title
* @param {object} opts
* @param {boolean} opts.allowEmpty show "No Passphrase" button (export)
* @param {boolean} opts.confirmNew show confirm field (export new passphrase)
* @returns Promise<string|null>
* non-empty string passphrase
* '' user chose no encryption
* null cancelled
*/
function requestPassphrase(title, opts = {}) {
return new Promise((resolve) => {
const modal = document.getElementById('generic-input-modal');
const helpToggle = document.getElementById('gim-help-toggle');
const helpContent = document.getElementById('gim-help-content');
const inp = document.getElementById('gim-input');
const extraBtn = document.getElementById('gim-extra-btn');
const modalContext = 'generic-input-modal';
document.getElementById('gim-title').innerText = title;
document.getElementById('gim-label').innerText = 'Passphrase';
inp.type = 'password';
inp.value = '';
inp.placeholder = 'Enter passphrase…';
inp.autocomplete = opts.confirmNew ? 'new-password' : 'current-password';
helpToggle.style.display = 'none';
helpContent.style.display = 'block';
// ── Show/hide toggle ──────────────────────────────────────
const inputWrap = document.createElement('div');
inputWrap.style.cssText = 'position:relative; display:flex; align-items:center;';
inp.parentNode.insertBefore(inputWrap, inp);
inputWrap.appendChild(inp);
const eyeBtn = document.createElement('button');
eyeBtn.type = 'button';
eyeBtn.textContent = '👁';
eyeBtn.title = 'Show/hide passphrase';
eyeBtn.style.cssText = 'position:absolute; right:8px; background:none; border:none; cursor:pointer; color:var(--text2); font-size:14px; padding:0; line-height:1;';
eyeBtn.onclick = () => {
const show = inp.type === 'password';
inp.type = show ? 'text' : 'password';
if (confirmInp) confirmInp.type = inp.type;
eyeBtn.style.opacity = show ? '1' : '0.4';
inp.focus();
};
inputWrap.appendChild(eyeBtn);
// ── Confirm field (export only) ───────────────────────────
let confirmInp = null;
let confirmWrap = null;
if (opts.confirmNew) {
confirmWrap = document.createElement('div');
confirmWrap.style.cssText = 'display:flex; flex-direction:column; gap:4px; margin-top:8px;';
const confirmLabel = document.createElement('label');
confirmLabel.textContent = 'Confirm Passphrase';
confirmLabel.style.cssText = 'font-size:0.8rem; color:var(--text2);';
confirmInp = document.createElement('input');
confirmInp.type = 'password';
confirmInp.autocomplete = 'new-password';
confirmInp.placeholder = 'Repeat passphrase…';
confirmInp.style.cssText = inp.style.cssText || 'width:100%; padding:0.5rem; background:var(--input-bg, #1e1e2e); border:1px solid var(--border, #444); border-radius:4px; color:inherit; font-size:inherit;';
confirmWrap.appendChild(confirmLabel);
confirmWrap.appendChild(confirmInp);
inputWrap.parentNode.insertBefore(confirmWrap, helpContent);
}
// ── Strength + mismatch feedback ──────────────────────────
const updateFeedback = () => {
const v = inp.value;
if (confirmInp) {
// Confirm mode: show mismatch or strength
if (confirmInp.value && confirmInp.value !== v) {
helpContent.innerHTML = '<span style="color:#ef4444; font-size:0.8rem; font-weight:600;">⚠ Passphrases do not match</span>';
return;
}
}
if (!v) {
helpContent.innerHTML = opts.allowEmpty
? '<span style="color:#94a3b8; font-size:0.8rem;">Leave empty or click "No Passphrase" to save without encryption.</span>'
: '<span style="color:#94a3b8; font-size:0.8rem;">Enter the passphrase used when this file was saved.</span>';
return;
}
if (opts.confirmNew) {
const s = passphraseStrength(v);
helpContent.innerHTML = `<span style="color:${s.color}; font-size:0.8rem; font-weight:600;">${s.label}</span>`;
} else {
helpContent.innerHTML = ''; // no strength hint on import
}
};
let resultToResolve = null;
const cleanup = () => {
inp.type = 'text';
inp.placeholder = '';
inp.autocomplete = 'off';
inp.oninput = null;
inp.onkeydown = null;
// Restore DOM
inputWrap.parentNode.insertBefore(inp, inputWrap);
inputWrap.remove();
if (confirmWrap) confirmWrap.remove();
helpContent.style.display = 'none';
helpContent.innerHTML = '';
window.removeEventListener('popstate', onPopState);
modal.style.display = 'none';
resolve(resultToResolve);
};
const onPopState = () => cleanup();
const commit = (v) => {
resultToResolve = v;
if (history.state && history.state.context === modalContext) history.back();
else cleanup();
};
const tryCommit = () => {
const v = inp.value.trim();
if (confirmInp) {
if (!v) { commit(''); return; } // empty = no encryption
if (confirmInp.value.trim() !== v) {
// Flash mismatch — don't close
helpContent.innerHTML = '<span style="color:#ef4444; font-size:0.8rem; font-weight:600;">⚠ Passphrases do not match — please re-enter</span>';
confirmInp.focus();
return;
}
}
commit(v || '');
};
const newOk = document.getElementById('gim-ok-btn').cloneNode(true);
const newCancel = document.getElementById('gim-cancel-btn').cloneNode(true);
const newExtra = extraBtn.cloneNode(true);
document.getElementById('gim-ok-btn').replaceWith(newOk);
document.getElementById('gim-cancel-btn').replaceWith(newCancel);
extraBtn.replaceWith(newExtra);
newOk.onclick = (e) => { e.stopPropagation(); tryCommit(); };
newCancel.onclick = (e) => { e.stopPropagation(); commit(null); };
modal.querySelector('.close-btn').onclick = (e) => { e.stopPropagation(); commit(null); };
if (opts.allowEmpty) {
newExtra.style.display = 'block';
newExtra.innerText = 'No Passphrase';
newExtra.className = 'secondary';
newExtra.style.padding = '0.6rem 1rem';
newExtra.style.marginRight = 'auto';
newExtra.onclick = (e) => { e.stopPropagation(); commit(''); };
} else {
newExtra.style.display = 'none';
}
inp.oninput = updateFeedback;
if (confirmInp) confirmInp.oninput = updateFeedback;
inp.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); tryCommit(); } };
if (confirmInp) confirmInp.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); tryCommit(); } };
updateFeedback();
if (!history.state || history.state.context !== modalContext)
history.pushState({ context: modalContext }, '', '');
window.addEventListener('popstate', onPopState);
modal.style.display = 'flex';
inp.focus();
});
}
/**
* Generic Input Dialog Helper
* @param {string} title - Modal Title

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
/**
* tool-bridge.js
* Common logic for connecting Component Tools (iframe) to the main Application (studio.js).

View file

@ -1,9 +1,3 @@
/*
* Copyright (c) 2025-2026 Taras Greben
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace
* See LICENSE file for details.
*/
/* tool-style.css - Shared styles for Component Editors */
@import url('common.css');