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

@ -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