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