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 ## 📦 Third-Party Libraries
* **OpenCV.js:** Computer Vision (Apache 2.0 License) * **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) * **WireBender:** Schematic generation/routing library (AGPLv3 or Commercial)
* **Adaptagrams:** Used by WireBender via `libavoid`, `libcola`, and `libvpsc` (LGPL 2.1 or later) * **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` * **KiCad symbol libraries:** Redistributed symbol data bundled as `assets/kicad-symbols-master.zip`

View file

@ -31,27 +31,34 @@ limitations under the License.
--- ---
## JSZip ## zip.js
**License:** MIT License **License:** BSD 3-Clause License
**Copyright:** (c) 2009-2016 Stuart Knightley, David Duponchel, Franz Buchinger, António Afonso **Copyright:** (c) 2023, Gildas Lormeau
Permission is hereby granted, free of charge, to any person obtaining a copy Redistribution and use in source and binary forms, with or without
of this software and associated documentation files (the "Software"), to deal modification, are permitted provided that the following conditions are met:
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:
The above copyright notice and this permission notice shall be included in 1. Redistributions of source code must retain the above copyright notice, this
all copies or substantial portions of the Software. list of conditions and the following disclaimer.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 2. Redistributions in binary form must reproduce the above copyright notice,
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, this list of conditions and the following disclaimer in the documentation
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE and/or other materials provided with the distribution.
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 3. Neither the name of the copyright holder nor the names of its
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN contributors may be used to endorse or promote products derived from
THE SOFTWARE. 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) */ /* canvas-ui.js - Shared Canvas Logic (Mobile/Touch Ready + Resize Hook) */
class PanZoomCanvas { 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <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 { :root {
--border: #cbd5e1; --border: #cbd5e1;
--text-muted: #94a3b8; --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 */ /* cv-core.js - Computer Vision Logic for PCB ReTrace Suite */
class CVManager { class CVManager {
constructor() { 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 */ /* db-core.js - Shared database initialization and schema definitions */
// Global UUID generator shared across all components // 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <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) */ /* inspector.js - Visual Trace Tracking (v6) */
class Inspector { 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) */ /* nets.js - Netlist Management (v3) */
class NetManager { 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -14,7 +8,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schematic ReTrace</title> <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> <script src="db-core.js"></script>
<style> <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'); @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 ─────────────────────────────────────────── // ── App entry point ───────────────────────────────────────────
import { autoImportDeviceLib } from './kicad.js'; import { autoImportDeviceLib } from './kicad.js';
import { S, NET_COLORS, initCanvas } from './state.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 // 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 // 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 { S, canvas, ctx, w2s, getNetColor, GRID } from './state.js';
import { createComp } from './components.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 ───────────────────────────────────────────── // ── Undo history ─────────────────────────────────────────────
// Kept separate to avoid circular imports between interaction.js and ui.js. // Kept separate to avoid circular imports between interaction.js and ui.js.
import { S } from './state.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 ─────────────────────────────────────────────── // ── Interaction ───────────────────────────────────────────────
import { db } from './db.js'; import { db } from './db.js';
import { S, canvas, s2w, snap } from './state.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 ─────────────────────────────────────────────────── // ── Exports ───────────────────────────────────────────────────
import { S } from './state.js'; import { S } from './state.js';
import { createComp } from './components.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'; import { db, uuid } from './db.js';
const SCALE = 20 / 2.54; const SCALE = 20 / 2.54;
@ -154,27 +148,66 @@ async function upsertParsedSymbols(parsedSymbols, libName) {
// ── Public Importers ────────────────────────────────────────────── // ── Public Importers ──────────────────────────────────────────────
export async function importSelectedFromZip(zip, filenames) { export async function importSelectedFromZip({ reader, entries }, filenames, onProgress) {
let stats = { inserted: 0, updated: 0 }; const notify = (msg) => { if (onProgress) onProgress(msg); };
const filesByLib = {}; const { TextWriter } = zip;
for (const filename of filenames) { const entryMap = new Map(entries.map(e => [e.filename, e]));
const match = filename.match(/([^\/]+)\.kicad_symdir\//); const total = filenames.length;
const libName = match ? match[1] : 'Imported';
if (!filesByLib[libName]) filesByLib[libName] = [];
filesByLib[libName].push(filename);
}
for (const [libName, files] of Object.entries(filesByLib)) { notify(`Decompressing 0/${total} files…`);
const parsedSymbols = {};
for (const filename of files) { // Phase 1: decompression
const text = await zip.files[filename].async("text"); let done = 0;
extractTopLevelSymbols(parseSexpr(text), parsedSymbols); 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 text of fileTexts) extractTopLevelSymbols(parseSexpr(text), parsedSymbols);
resolveInheritance(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); const res = await upsertParsedSymbols(parsedSymbols, libName);
stats.inserted += res.inserted; stats.inserted += res.inserted;
stats.updated += res.updated; stats.updated += res.updated;
} }
// console.timeEnd('[kicad] db-write');
await reader.close();
return stats; return stats;
} }
@ -195,11 +228,27 @@ export async function autoImportDeviceLib() {
const res = await fetch(ZIP_URL); const res = await fetch(ZIP_URL);
if (!res.ok) throw new Error('Failed to fetch zip'); if (!res.ok) throw new Error('Failed to fetch zip');
const buffer = await res.arrayBuffer(); const { ZipReader, Uint8ArrayReader, TextWriter } = zip;
const zip = await JSZip.loadAsync(buffer); 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')); const parsedSymbols = {};
await importSelectedFromZip(zip, deviceFiles); 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!'); console.log('[kicad] Default library import complete!');
} catch (err) { } catch (err) {
console.error('[kicad] Auto-import failed:', 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 // 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 ─────────────────────────────────────────────────── // ── Parsers ───────────────────────────────────────────────────
import { classifyComp } from './components.js'; 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 ───────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────
// Single mutable app state object. All modules share this reference. // Single mutable app state object. All modules share this reference.
export const S = { 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 ──────────────────────────────────────────────── // ── UI helpers ────────────────────────────────────────────────
import { db, uuid } from './db.js'; import { db, uuid } from './db.js';
import { S, getNetColor } from './state.js'; import { S, getNetColor } from './state.js';
@ -492,7 +486,13 @@ function initLibraryManager() {
let libsData = {}; let libsData = {};
const log = (msg) => { logEl.innerHTML += `<div>${msg}</div>`; logEl.scrollTop = logEl.scrollHeight; }; 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(); }; if (btnOpen) btnOpen.onclick = () => { modal.classList.add('active'); resetZipUi(); };
@ -504,15 +504,24 @@ function initLibraryManager() {
async function processZipBuffer(buffer) { async function processZipBuffer(buffer) {
log('Parsing ZIP directory structure...'); log('Parsing ZIP directory structure...');
currentZip = await JSZip.loadAsync(buffer); const { ZipReader, Uint8ArrayReader } = zip;
const files = Object.keys(currentZip.files).filter(n => n.endsWith('.kicad_sym')); 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 = {}; libsData = {};
files.forEach(f => { entries
const match = f.match(/([^\/]+)\.kicad_symdir\//); .filter(e => !e.directory && e.filename.endsWith('.kicad_sym'))
.forEach(e => {
const match = e.filename.match(/([^\/]+)\.kicad_symdir\//);
const libName = match ? match[1] : 'Imported'; const libName = match ? match[1] : 'Imported';
if (!libsData[libName]) libsData[libName] = new Array(); if (!libsData[libName]) libsData[libName] = [];
libsData[libName].push({ path: f, name: f.split('/').pop().replace('.kicad_sym','') }); libsData[libName].push({ path: e.filename, name: e.filename.split('/').pop().replace('.kicad_sym', '') });
}); });
treeEl.innerHTML = ''; treeEl.innerHTML = '';
@ -597,13 +606,28 @@ function initLibraryManager() {
if (selectedPaths.length === 0) return toast('No files selected', 'warn'); 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; const zipRef = currentZip;
resetZipUi(); currentZip = null;
libsData = {};
defaultUi.style.display = 'flex';
zipUi.style.display = 'none';
log(`Extracting and parsing ${selectedPaths.length} files...`); log(`Extracting and parsing ${selectedPaths.length} files...`);
try { 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>.`); log(`Success: <span style="color:#4ade80">${stats.inserted} inserted</span>, <span style="color:#f0c040">${stats.updated} updated</span>.`);
toast('Import Complete', 'ok'); toast('Import Complete', 'ok');
} catch (err) { log(`<span style="color:var(--accent3)">Error: ${err.message}</span>`); } } 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 * spyglass.js
* Handles zooming, panning, and coordinate adjustment for PCB previews. * 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 */ /* stitch-editor.js */
class StitchEditor { 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -15,7 +9,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="icon" href="favicon.ico"> <link rel="icon" href="favicon.ico">
<title>PCB ReTrace Studio</title> <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 --> <!-- LIBRARIES -->
<script async src="https://docs.opencv.org/4.x/opencv.js" type="text/javascript"></script> <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 */ /* 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>` }; 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,45 +597,52 @@ async function importSchemaDependencies(schemaDeps, idMap) {
} }
// Device Export // Device Export
async function exportDeviceZIP() { async function exportDeviceZIP() {
if (!window.JSZip) return alert("JSZip required.");
if (!currentDeviceId) return; 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 dev = deviceList.find(d => d.id === currentDeviceId);
const boms = await db.getProjectsByDevice(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 allNets = await db.getNets();
const allSchemas = await db.getSchemas(); const allSchemas = await db.getSchemas();
const allSchemaComps = await db.getSchemaComponents(); 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 const readmeContent = `PCB ReTrace Data Export
Type: Full Device Backup (${dev.name}) Type: Full Device Backup (${dev.name})
Generated by: pcb.etaras.com Generated by: pcb.etaras.com
Date: ${new Date().toISOString()} Date: ${new Date().toISOString()}
${pass ? 'Protection: AES-256 encrypted — requires passphrase to open.\n' : ''}
HOW TO USE: HOW TO USE:
1. Go to https://pcb.etaras.com/studio.html 1. Go to https://pcb.etaras.com/studio.html
2. Click "Import Device" or drag and drop this ZIP file into the tool. 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 manifest = {
device: dev,
version: DB_VER,
source: "pcb.etaras.com",
boards: []
};
const imgFolder = zip.folder("images");
for (const bom of boms) { for (const bom of boms) {
const comps = await db.getComponents(bom.id); const comps = await db.getComponents(bom.id);
const imgs = await db.getImages(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 boardNets = allNets.filter(n => n.projectId === bom.id);
const boardSchemas = allSchemas.filter(s => s.boardId === bom.id); const boardSchemas = allSchemas.filter(s => s.boardId === bom.id);
const boardSchemaIds = new Set(boardSchemas.map(s => s.id)); const boardSchemaIds = new Set(boardSchemas.map(s => s.id));
@ -653,46 +654,36 @@ HOW TO USE:
ovs.forEach(o => overlapsMap.set(o.id, o)); 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({ manifest.boards.push({
meta: bom, meta: bom,
components: cleanComps, components: comps.map(c => { const { boardId, ...rest } = c; return rest; }),
images: imgMeta, images: imgs.map(img => ({ id: img.id, name: img.name, type: 'image/jpeg' })),
overlaps: Array.from(overlapsMap.values()), overlaps: Array.from(overlapsMap.values()),
nets: boardNets, nets: boardNets, schemas: boardSchemas, schemaComponents: boardSchemaComps
schemas: boardSchemas,
schemaComponents: boardSchemaComps
}); });
// Image Loop
for (const img of imgs) { for (const img of imgs) {
let blobToSave = img.blob; let blobToSave = img.blob;
if (img.blob.type !== 'image/jpeg') { if (img.blob.type !== 'image/jpeg') {
const bmp = await createImageBitmap(img.blob); const bmp = await createImageBitmap(img.blob);
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = bmp.width; canvas.width = bmp.width; canvas.height = bmp.height;
canvas.height = bmp.height; canvas.getContext('2d').drawImage(bmp, 0, 0); bmp.close();
canvas.getContext('2d').drawImage(bmp, 0, 0);
bmp.close();
blobToSave = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.85)); 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); manifest.deviceSchemas = allSchemas.filter(s => s.deviceId === dev.id && !s.boardId);
const devSchemaIds = new Set(manifest.deviceSchemas.map(s => s.id)); const devSchemaIds = new Set(manifest.deviceSchemas.map(s => s.id));
manifest.deviceSchemaComponents = allSchemaComps.filter(sc => devSchemaIds.has(sc.schemaId)); 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); manifest.schemaDependencies = await gatherSchemaDependencies(exportedSchemaComps);
zip.file("device.json", JSON.stringify(manifest, null, 2)); await zipWriter.add('device.json', new TextReader(JSON.stringify(manifest, null, 2)), encOpts);
zip.generateAsync({ type: "blob" }).then(c => dl(c, `${dev.name}_Backup.zip`, 'application/zip')); await zipWriter.close();
dl(await zipBlobWriter.getData(), `${dev.name}_Backup.zip`, 'application/zip');
} }
function isValidBoardData(data) { function isValidBoardData(data) {
@ -863,65 +854,88 @@ function setupDragDrop() {
} }
async function processZIP(file) { async function processZIP(file) {
if (!window.JSZip) return alert("JSZip library not loaded."); const { BlobReader, BlobWriter, TextWriter, ZipReader } = zip;
try { try {
const zip = await JSZip.loadAsync(file); // 1. Open without password to inspect entries
let recognized = false; let reader = new ZipReader(new BlobReader(file));
let entries = await reader.getEntries();
await reader.close();
// CASE A: Device Backup // 2. Detect encryption and prompt for passphrase once
if (zip.file("device.json")) { const isEncrypted = entries.some(e => e.encrypted);
const content = await zip.file("device.json").async("string"); 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; let manifest;
try { manifest = JSON.parse(content); } catch (e) { throw new Error("device.json is corrupt"); } try { manifest = JSON.parse(texts['device.json']); }
catch (e) { throw new Error('device.json is corrupt.'); }
// STRICT CHECK if (!isValidDeviceManifest(manifest))
if (isValidDeviceManifest(manifest)) { throw new Error('ZIP contains device.json, but it is missing required fields (id, name, boards).');
recognized = true;
const vMsg = manifest.version ? ` (v${manifest.version})` : ''; const vMsg = manifest.version ? ` (v${manifest.version})` : '';
const sMsg = manifest.source ? `\nSource: ${manifest.source}` : ''; 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.`))
if (confirm(`Restore Device: "${manifest.device.name}" ${vMsg}?${sMsg}\n\nThis will merge boards and overwrite existing components.`)) { await restoreDevice(manifest, imageMap);
await restoreDevice(manifest, zip);
}
} else {
throw new Error("ZIP contains device.json, but it is missing required fields (id, name, boards).");
}
}
// CASE B: Single Board Backup
else if (zip.file("bom.json")) {
const content = await zip.file("bom.json").async("string");
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; return;
} }
if (texts['bom.json']) {
let data;
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?`)) { if (confirm(`Import Board: "${data.meta.name}" into current Device?`)) {
data.meta.deviceId = currentDeviceId; data.meta.deviceId = currentDeviceId;
await processImportData(data, zip); await processImportData(data, imageMap);
}
} else {
throw new Error("ZIP contains bom.json, but it is missing required fields (id, meta, components).");
} }
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) { } catch (e) {
console.error(e); const msg = e.message || '';
alert("Import Failed: " + 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 devId = manifest.device.id;
const existingDev = deviceList.find(d => d.id === devId); const existingDev = deviceList.find(d => d.id === devId);
if (!existingDev) { if (!existingDev) {
@ -944,7 +958,6 @@ async function restoreDevice(manifest, zip) {
} }
} }
const imgFolder = zip.folder("images");
let updatedBoards = 0; let updatedBoards = 0;
let mergedBoards = 0; let mergedBoards = 0;
@ -1015,25 +1028,17 @@ async function restoreDevice(manifest, zip) {
// --- UPSERT PHASE --- // --- UPSERT PHASE ---
// Images (unchanged logic...) // Images
if (imgFolder && boardData.images) { if (imageMap && boardData.images) {
for (const im of boardData.images) { for (const im of boardData.images) {
if (!isNewer) { if (!isNewer) {
const existingImg = await db.getImage(im.id); const existingImg = await db.getImage(im.id);
if (existingImg) continue; if (existingImg) continue;
} }
const cleanStoredId = im.id.split('/').pop(); const cleanId = im.id.split('/').pop();
let filename = cleanStoredId + ".jpg"; const ext = (im.type || 'image/jpeg').split('/')[1] || 'jpg';
let file = imgFolder.file(filename); const blob = imageMap.get(cleanId + '.jpg') || imageMap.get(cleanId + '.' + ext);
if (!file) file = zip.file("images/" + filename); if (blob) {
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 cleanName = im.name.replace(/\.[^/.]+$/, ""); const cleanName = im.name.replace(/\.[^/.]+$/, "");
await db.addImage({ id: im.id, boardId: boardId, blob, name: cleanName }); await db.addImage({ id: im.id, boardId: boardId, blob, name: cleanName });
} }
@ -1132,7 +1137,7 @@ async function restoreDevice(manifest, zip) {
await loadDeviceBoms(); await loadDeviceBoms();
} }
async function processImportData(data, zipObj) { async function processImportData(data, imageMap) {
const idMap = new Map(); const idMap = new Map();
if (data.schemaDependencies) { if (data.schemaDependencies) {
await importSchemaDependencies(data.schemaDependencies, idMap); await importSchemaDependencies(data.schemaDependencies, idMap);
@ -1203,31 +1208,20 @@ async function processImportData(data, zipObj) {
// --- UPSERT PHASE --- // --- UPSERT PHASE ---
// Images (unchanged logic...) // Images
if (zipObj && data.images) { if (imageMap && data.images) {
const imgFolder = zipObj.folder("images"); for (const im of data.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) { if (!isNewer) {
const existing = await db.getImage(finalId); const existing = await db.getImage(im.id);
if (existing) continue; if (existing) continue;
} }
const cleanId = im.id.split('/').pop();
const rawBlob = await f.async("blob"); const ext = (im.type || 'image/jpeg').split('/')[1] || 'jpg';
const blob = new Blob([rawBlob], { type: mime }); const rawBlob = imageMap.get(cleanId + '.jpg') || imageMap.get(cleanId + '.' + ext);
if (rawBlob) {
await db.addImage({ id: finalId, boardId: boardId, blob, name: finalName }); 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 // Board Export
async function exportZIP() { async function exportZIP() {
if (!window.JSZip) return; const pass = await requestPassphrase('Protect Board Export with Passphrase?', { allowEmpty: true, confirmNew: true });
const zip = new JSZip(); if (pass === null) return; // cancelled
const { BlobWriter, BlobReader, TextReader, ZipWriter } = zip;
const meta = bomList.find(x => x.id === currentBomId); const meta = bomList.find(x => x.id === currentBomId);
const images = await db.getImages(currentBomId); const images = await db.getImages(currentBomId);
// Fetch and filter Nets
const allNets = await db.getNets(); const allNets = await db.getNets();
const boardNets = allNets.filter(n => n.projectId === currentBomId); const boardNets = allNets.filter(n => n.projectId === currentBomId);
@ -1323,10 +1317,6 @@ async function exportZIP() {
const ovs = await db.getOverlapsForImage(img.id); const ovs = await db.getOverlapsForImage(img.id);
ovs.forEach(o => overlapsMap.set(o.id, o)); 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 allSchemas = await db.getSchemas();
const boardSchemas = allSchemas.filter(s => s.boardId === currentBomId); 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 boardSchemaComps = allSchemaComps.filter(sc => boardSchemaIds.has(sc.schemaId));
const schemaDeps = await gatherSchemaDependencies(boardSchemaComps); 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 const readmeContent = `PCB ReTrace Data Export
Type: Single Board (${meta.name}) Type: Single Board (${meta.name})
Generated by: pcb.etaras.com Generated by: pcb.etaras.com
Date: ${new Date().toISOString()} Date: ${new Date().toISOString()}
${pass ? 'Protection: AES-256 encrypted — requires passphrase to open.\n' : ''}
HOW TO USE: HOW TO USE:
1. Go to https://pcb.etaras.com/studio.html 1. Go to https://pcb.etaras.com/studio.html
2. Select or Create a Device. 2. Select or Create a Device.
3. Click "Import Board" or drag and drop this ZIP file into the component list. 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 = { const data = {
meta, meta, version: db.ver, source: 'pcb.etaras.com',
components: cleanComps, components: bomData.map(c => { const { boardId, ...rest } = c; return rest; }),
images: imgMeta, images: images.map(i => ({ id: i.id, name: i.name, type: 'image/jpeg' })),
overlaps: overlaps, overlaps: Array.from(overlapsMap.values()),
nets: boardNets, // Add Nets nets: boardNets, schemas: boardSchemas, schemaComponents: boardSchemaComps,
schemas: boardSchemas, schemaDependencies: schemaDeps
schemaComponents: boardSchemaComps,
schemaDependencies: schemaDeps,
version: DB_VER,
source: "pcb.etaras.com"
}; };
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) { for (const img of images) {
let blobToSave = img.blob; let blobToSave = img.blob;
if (img.blob.type !== 'image/jpeg') { if (img.blob.type !== 'image/jpeg') {
const bmp = await createImageBitmap(img.blob); const bmp = await createImageBitmap(img.blob);
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = bmp.width; canvas.width = bmp.width; canvas.height = bmp.height;
canvas.height = bmp.height; canvas.getContext('2d').drawImage(bmp, 0, 0); bmp.close();
canvas.getContext('2d').drawImage(bmp, 0, 0);
bmp.close();
blobToSave = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.85)); 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() { 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 * Generic Input Dialog Helper
* @param {string} title - Modal Title * @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 * tool-bridge.js
* Common logic for connecting Component Tools (iframe) to the main Application (studio.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 */ /* tool-style.css - Shared styles for Component Editors */
@import url('common.css'); @import url('common.css');