AES-256 encryption option for project files, progress on importing KiCad symbols
This commit is contained in:
parent
41939fb47c
commit
0a0f9ff9fd
32 changed files with 493 additions and 394 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
const parsedSymbols = {};
|
||||
for (const filename of files) {
|
||||
const text = await zip.files[filename].async("text");
|
||||
extractTopLevelSymbols(parseSexpr(text), parsedSymbols);
|
||||
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 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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,15 +504,24 @@ 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\//);
|
||||
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] = new Array();
|
||||
libsData[libName].push({ path: f, name: f.split('/').pop().replace('.kicad_sym','') });
|
||||
if (!libsData[libName]) libsData[libName] = [];
|
||||
libsData[libName].push({ path: e.filename, name: e.filename.split('/').pop().replace('.kicad_sym', '') });
|
||||
});
|
||||
|
||||
treeEl.innerHTML = '';
|
||||
|
|
@ -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>`); }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
493
docs/studio.js
493
docs/studio.js
|
|
@ -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,45 +597,52 @@ 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));
|
||||
|
|
@ -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})` : '';
|
||||
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, 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.");
|
||||
if (confirm(`Restore Device: "${manifest.device.name}"${vMsg}?${sMsg}\n\nThis will merge boards and overwrite existing components.`))
|
||||
await restoreDevice(manifest, imageMap);
|
||||
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?`)) {
|
||||
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).");
|
||||
await processImportData(data, imageMap);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recognized) {
|
||||
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';
|
||||
|
||||
// Images
|
||||
if (imageMap && data.images) {
|
||||
for (const im of data.images) {
|
||||
if (!isNewer) {
|
||||
const existing = await db.getImage(finalId);
|
||||
const existing = await db.getImage(im.id);
|
||||
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 });
|
||||
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 pass = await requestPassphrase('Protect Board Export with Passphrase?', { allowEmpty: true, confirmNew: true });
|
||||
if (pass === null) return; // cancelled
|
||||
|
||||
const { BlobWriter, BlobReader, TextReader, ZipWriter } = zip;
|
||||
const meta = bomList.find(x => x.id === currentBomId);
|
||||
const images = await db.getImages(currentBomId);
|
||||
|
||||
// Fetch and filter Nets
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue