/* studio.js - Main Application Logic */ const DB_NAME = 'PcbReTrace'; const DB_VER = 1; const ICONS = { RESISTOR: ``, INDUCTOR: ``, COIL: `` }; const TOOL_REGISTRY = { 'R': [{url:'resistor.html',icon:ICONS.RESISTOR,title:'Resistor'}], 'L': [{url:'inductor.html',icon:ICONS.INDUCTOR,title:'Inductor'},{url:'coil.html',icon:ICONS.COIL,title:'Coil'}] }; const MAX_DIRECT_TOOLS = 3; // Global State let db=null, deviceList=[], currentDeviceId=null, bomList=[], currentBomId=null, bomData=[], bomImages=[], currentImgId=null, sortMode='none', editingIndex=-1, mapState={scale:1,x:0,y:0,isDragging:false,startX:0,startY:0}; let skipNextFit = false; let returnToMap = false; let spyglass = null; let isMainViewActive = true; // Tracks if Spyglass is showing the actual source image // Phase 6 Extensions let cvManager = null; let stitchEditor = null; let currentOverlaps = []; let netManager = null; let inspector = null; // Utility: Global UUID generator (used by StitchEditor too) window.uuid = function(){ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c=>{var r=Math.random()*16|0,v=c=='x'?r:(r&0x3|0x8);return v.toString(16);}); } class PcbDatabase { constructor() { this.name=DB_NAME; this.ver=DB_VER; } async init() { return new Promise((r,j)=>{ const q=indexedDB.open(this.name,this.ver); q.onupgradeneeded=e=>{ const d=e.target.result; if(d.objectStoreNames.contains('projects')) d.deleteObjectStore('projects'); if(!d.objectStoreNames.contains('devices')) d.createObjectStore('devices',{keyPath:'id'}); if(!d.objectStoreNames.contains('boards')) { const bs=d.createObjectStore('boards',{keyPath:'id'}); bs.createIndex('deviceId','deviceId',{unique:false}); } if(!d.objectStoreNames.contains('components')) { const cs=d.createObjectStore('components',{keyPath:'id'}); cs.createIndex('boardId','boardId',{unique:false}); } if(!d.objectStoreNames.contains('images')) { const is=d.createObjectStore('images',{keyPath:'id'}); is.createIndex('boardId','boardId',{unique:false}); } // v6: POI Support if(!d.objectStoreNames.contains('overlappedImages')) { const os=d.createObjectStore('overlappedImages',{keyPath:'id'}); os.createIndex('fromImageId','fromImageId'); } // v7: Nets Store if(!d.objectStoreNames.contains('nets')) { const ns=d.createObjectStore('nets',{keyPath:'id'}); // No index needed yet as we usually load all nets for a board via manual filter or ID list } }; q.onsuccess=e=>{this.db=e.target.result;r()}; q.onerror=e=>{ console.error("DB Open Error:", e); j(e); }; }); } // Helpers async _tx(s,m,cb){ return new Promise((r,j)=>{const t=this.db.transaction(s,m); const q=cb(t.objectStore(s)); q.onsuccess=()=>r(q.result); q.onerror=()=>j(q.error);}); } async _ix(s,i,v){ return new Promise((r,j)=>{const q=this.db.transaction(s,'readonly').objectStore(s).index(i).getAll(v); q.onsuccess=()=>r(q.result); q.onerror=()=>j(q.error);}); } // Core CRUD async getDevices() { return this._tx('devices','readonly',s=>s.getAll()); } async addDevice(d) { return this._tx('devices','readwrite',s=>s.put(d)); } async getProjectsByDevice(devId) { return this._ix('boards', 'deviceId', devId); } async getProject(id) { return this._tx('boards','readonly',s=>s.get(id)); } async addProject(p) { return this._tx('boards','readwrite',s=>s.put(p)); } async deleteProject(id) { const cs=await this.getComponents(id); const is=await this.getImages(id); const tx=this.db.transaction(['boards','components','images'],'readwrite'); cs.forEach(c=>tx.objectStore('components').delete(c.id)); is.forEach(i=>tx.objectStore('images').delete(i.id)); tx.objectStore('boards').delete(id); return new Promise(r=>tx.oncomplete=r); } async getComponents(pid) { return this._ix('components','boardId',pid); } async addComponent(c) { return this._tx('components','readwrite',s=>s.put(c)); } async deleteComponent(id) { return this._tx('components','readwrite',s=>s.delete(id)); } async clearComponents(pid) { const cs=await this.getComponents(pid); const tx=this.db.transaction('components','readwrite'); cs.forEach(c=>tx.objectStore('components').delete(c.id)); return new Promise(r=>tx.oncomplete=r); } async clearProjectData(pid) { const cs=await this.getComponents(pid); const is=await this.getImages(pid); const tx=this.db.transaction(['components','images'],'readwrite'); cs.forEach(c=>tx.objectStore('components').delete(c.id)); is.forEach(i=>tx.objectStore('images').delete(i.id)); return new Promise(r=>tx.oncomplete=r); } async getImages(pid) { return this._ix('images','boardId',pid); } async addImage(i) { return this._tx('images','readwrite',s=>s.put(i)); } async deleteImage(id) { return this._tx('images','readwrite',s=>s.delete(id)); } async getImage(id) { return this._tx('images', 'readonly', s => s.get(id)); } async getNets() { return this._tx('nets','readonly',s=>s.getAll()); } async addNet(n) { return this._tx('nets','readwrite',s=>s.put(n)); } async deleteNet(id) { return this._tx('nets','readwrite',s=>s.delete(id)); } // POI Extensions async addOverlap(ov) { return this._tx('overlappedImages','readwrite',s=>s.put(ov)); } async getOverlapsForPair(id1, id2) { const all = await this._tx('overlappedImages','readonly',s=>s.getAll()); return all.find(x => (x.fromImageId===id1 && x.toImageId===id2) || (x.fromImageId===id2 && x.toImageId===id1)); } async getOverlapsForImage(id) { const all = await this._tx('overlappedImages','readonly',s=>s.getAll()); return all.filter(x => x.fromImageId===id || x.toImageId===id); } async deleteOverlapsForPair(id1, id2) { const tx = this.db.transaction('overlappedImages', 'readwrite'); const store = tx.objectStore('overlappedImages'); const req = store.getAll(); return new Promise(resolve => { req.onsuccess = e => { const all = e.target.result; const toDel = all.filter(x => (x.fromImageId===id1 && x.toImageId===id2) || (x.fromImageId===id2 && x.toImageId===id1)); let c=0; if(toDel.length===0) resolve(); toDel.forEach(item => { store.delete(item.id).onsuccess=()=>{ c++; if(c===toDel.length) resolve(); }}); } }); } } // MAIN INIT async function init() { db = new PcbDatabase(); await db.init(); if (!history.state) { history.replaceState({ context: 'list' }, "", ""); } cvManager = new CVManager(); cvManager.init(); // Lazy load (don't await) // Init net managers netManager = new NetManager(db); inspector = new Inspector(db, cvManager); // [CRITICAL] Expose to window so inline HTML onclicks (generated by Inspector) work window.netManager = netManager; window.inspector = inspector; stitchEditor = new StitchEditor(db, cvManager); deviceList = await db.getDevices(); if(deviceList.length === 0) { const defDev = { id: uuid(), name: 'Default Device' }; await db.addDevice(defDev); deviceList = [defDev]; const bid = uuid(); await db.addProject({ id: bid, deviceId: defDev.id, name: 'Main Board', sortMode:'none' }); } currentDeviceId = localStorage.getItem('pcb_dev_id') || deviceList[0].id; if (!deviceList.find(d => d.id === currentDeviceId)) currentDeviceId = deviceList[0].id; updateDeviceDropdown(); await loadDeviceBoms(); document.getElementById('add-form').addEventListener('keydown', function(e) { if(e.key === 'Enter') addPart(); }); if (typeof PcbSpyglass !== 'undefined') { spyglass = new PcbSpyglass('preview-canvas', 'zoom-level', (newX, newY) => { // When user drags the spyglass, update the hidden inputs (only for the Main View) if (isMainViewActive) { document.getElementById('inp-x').value = newX; document.getElementById('inp-y').value = newY; } // Optional: If we want real-time DB saving while dragging in the list view, // we could enable it, but usually updating inputs + clicking "Add/Update" is safer. }); } setupDragDrop(); NavManager.init(); } // Helper: Find all transitive connections for a given image // Returns Array of { sourceId, matrix (Source->Target) } async function getConnectedImages(targetImgId) { if (!targetImgId || !cvManager) return []; // Use Helper const paths = await ImageGraph.solvePaths(targetImgId, cvManager, db); // Map to expected format return paths.map(p => ({ sourceId: p.id, matrix: p.H })); } function updateDeviceDropdown() { const s = document.getElementById('device-select'); s.innerHTML=''; deviceList.forEach(d => { const o = document.createElement('option'); o.value=d.id; o.innerText=d.name; if(d.id===currentDeviceId) o.selected=true; s.appendChild(o); }); } async function switchDevice() { resetStickyEditor(); currentDeviceId = document.getElementById('device-select').value; localStorage.setItem('pcb_dev_id', currentDeviceId); await loadDeviceBoms(); } async function loadDeviceBoms() { bomList = await db.getProjectsByDevice(currentDeviceId); const s = document.getElementById('bom-select'); s.innerHTML=''; if (bomList.length === 0) { s.innerHTML = ''; currentBomId = null; } else { const groups = {}; bomList.forEach(b => { const sec = b.section || "General"; if(!groups[sec]) groups[sec] = []; groups[sec].push(b); }); for (const [secName, boms] of Object.entries(groups)) { const grp = document.createElement('optgroup'); grp.label = secName; boms.forEach(b => { const o = document.createElement('option'); o.value=b.id; o.innerText=b.name; grp.appendChild(o); }); s.appendChild(grp); } const lastBom = localStorage.getItem('pcb_bom_id'); currentBomId = (lastBom && bomList.find(b=>b.id===lastBom)) ? lastBom : bomList[0].id; s.value = currentBomId; } await loadProjectData(); } async function switchBom() { resetStickyEditor(); currentBomId = document.getElementById('bom-select').value; localStorage.setItem('pcb_bom_id', currentBomId); currentImgId = null; await loadProjectData(); } async function loadProjectData() { // Helper to refresh other tabs const refreshViews = async () => { if (window.netManager) window.netManager.render(); if (window.inspector) { // Clear previous selection as IDs are no longer valid window.inspector.visibleIds.clear(); window.inspector.activeNet = null; // Reset active net await window.inspector.init(); // Rebuild sidebar & grid } }; if (!currentBomId) { bomData = []; bomImages = []; renderList(); // FIX: Clear other views if no board selected const imgSel = document.getElementById('image-select'); if(imgSel) imgSel.innerHTML = ''; clearMap(); await refreshViews(); return; } const meta = bomList.find(p=>p.id===currentBomId); if (meta) { sortMode = (meta && meta.sortMode) ? meta.sortMode : 'none'; document.getElementById('sort-select').value = sortMode; } bomData = await db.getComponents(currentBomId); bomImages = await db.getImages(currentBomId); // Convert raw blobs to Image objects for cache if needed (lazy loaded usually) for (const img of bomImages) { if (/\.(jpg|jpeg|png|webp)$/i.test(img.name)) { img.name = img.name.replace(/\.[^/.]+$/, ""); db.addImage(img); } } const imgSel = document.getElementById('image-select'); imgSel.innerHTML = ''; if(bomImages.length > 0) { bomImages.forEach(img => { const opt = document.createElement('option'); opt.value = img.id; opt.innerText = img.name; imgSel.appendChild(opt); }); if(!currentImgId || !bomImages.find(i=>i.id===currentImgId)) currentImgId = bomImages[0].id; imgSel.value = currentImgId; // Only load the image if we are actually on the map tab if(document.getElementById('view-map').classList.contains('active')) showImage(currentImgId); } else { currentImgId = null; imgSel.innerHTML = ''; clearMap(); } renderList(); // Refresh Nets and Inspector with new data await refreshViews(); } async function createNewDevice() { const n = await requestInput("New Device", "Device Name", ""); if (n) { const id = uuid(); await db.addDevice({ id, name: n }); currentDeviceId = id; const bid = uuid(); await db.addProject({ id: bid, deviceId: id, name: 'Main Board', sortMode: 'none' }); deviceList = await db.getDevices(); updateDeviceDropdown(); await loadDeviceBoms(); } } async function createNewBom() { if (!currentDeviceId) return alert("Select a device first."); const n = await requestInput("New Board", "Board Name", ""); if(n) { const sec = await requestInput("Section", "Group (Optional)", "") || ""; const id=uuid(); await db.addProject({id, deviceId: currentDeviceId, name:n, section: sec, lastModified:Date.now(), sortMode:'none'}); currentBomId=id; await loadDeviceBoms(); } } // --- SETTINGS --- function openBoardSettings() { if (!currentBomId) return; const p = bomList.find(x => x.id === currentBomId); if (!p) return; document.getElementById('edit-board-name').value = p.name; document.getElementById('edit-board-section').value = p.section || ""; const ds = document.getElementById('edit-board-device'); ds.innerHTML = ''; deviceList.forEach(d => { const o = document.createElement('option'); o.value = d.id; o.innerText = d.name; if (d.id === p.deviceId) o.selected = true; ds.appendChild(o); }); document.getElementById('board-settings-modal').style.display = 'flex'; } async function saveBoardSettings() { const p = bomList.find(x => x.id === currentBomId); if (!p) return; const newName = document.getElementById('edit-board-name').value; const newSec = document.getElementById('edit-board-section').value; const newDevId = document.getElementById('edit-board-device').value; if (newName) { p.name = newName; p.section = newSec; p.deviceId = newDevId; await db.addProject(p); document.getElementById('board-settings-modal').style.display = 'none'; if (newDevId !== currentDeviceId) { currentDeviceId = newDevId; updateDeviceDropdown(); } await loadDeviceBoms(); } } function openDeviceSettings() { if (!currentDeviceId) return; const d = deviceList.find(x => x.id === currentDeviceId); if (!d) return; document.getElementById('edit-device-name').value = d.name; document.getElementById('device-settings-modal').style.display = 'flex'; } async function saveDeviceSettings() { const d = deviceList.find(x => x.id === currentDeviceId); if (!d) return; const newName = document.getElementById('edit-device-name').value; if (newName && newName !== d.name) { d.name = newName; await db.addDevice(d); deviceList = await db.getDevices(); updateDeviceDropdown(); } document.getElementById('device-settings-modal').style.display = 'none'; } async function deleteCurrentDevice() { if (!currentDeviceId) return; // 1. Modal Check if (!await confirmAction("Are you sure? This will PERMANENTLY DELETE the device and ALL its boards, components, and images.\n\nThis action cannot be undone.", "Delete Device")) { return; } try { const boards = await db.getProjectsByDevice(currentDeviceId); for (const board of boards) { const images = await db.getImages(board.id); for (const img of images) { const overlaps = await db.getOverlapsForImage(img.id); const tx = db.db.transaction('overlappedImages', 'readwrite'); const store = tx.objectStore('overlappedImages'); overlaps.forEach(ov => store.delete(ov.id)); await new Promise(r => tx.oncomplete = r); } await db.deleteProject(board.id); } await db._tx('devices', 'readwrite', s => s.delete(currentDeviceId)); document.getElementById('device-settings-modal').style.display = 'none'; deviceList = await db.getDevices(); if (deviceList.length === 0) { const defDev = { id: uuid(), name: 'Default Device' }; await db.addDevice(defDev); deviceList = [defDev]; const bid = uuid(); await db.addProject({ id: bid, deviceId: defDev.id, name: 'Main Board', sortMode:'none' }); } currentDeviceId = deviceList[0].id; localStorage.setItem('pcb_dev_id', currentDeviceId); updateDeviceDropdown(); await loadDeviceBoms(); resetStickyEditor(); } catch (e) { console.error("Delete failed:", e); alert("Error deleting device: " + e.message); } } // --- COMPONENTS --- async function addPart() { const id = document.getElementById('inp-id').value; const label = document.getElementById('inp-label').value.toUpperCase().trim(); const value = document.getElementById('inp-value').value; const desc = document.getElementById('inp-desc').value; const x = document.getElementById('inp-x').value; const y = document.getElementById('inp-y').value; const imgId = document.getElementById('inp-img-id').value; if(!label) return alert("Ref required"); // Check collision const collision = bomData.find(c => c.label === label && c.id !== id); if (collision) { if(!await confirmAction("Reference exists. Overwrite?", "Overwrite")) return; await db.deleteComponent(collision.id); } // Save const c = { id: id || uuid(), boardId: currentBomId, label, value, desc }; if(x && y && imgId) { c.x = parseFloat(x); c.y = parseFloat(y); c.imgId = imgId; } await db.addComponent(c); document.getElementById('inp-id').value = c.id; await loadProjectData(); if(returnToMap) { switchView('map'); returnToMap = false; } else { // Optional: blink the button or give feedback const btn = document.querySelector('.btn-add'); const origText = btn.innerText; btn.innerText = "Saved!"; setTimeout(() => btn.innerText = origText, 1000); } } async function deleteCurrentPart() { const id = document.getElementById('inp-id').value; // Safety: Don't do anything if no component is selected (e.g. creating new) if (!id) return; if (await confirmAction("Delete this component?", "Delete")) { await db.deleteComponent(id); await loadProjectData(); resetStickyEditor(); // Clear the form/images after deletion } } // --- IMPORT / EXPORT --- // Device Export async function exportDeviceZIP() { if(!window.JSZip) return alert("JSZip required."); if(!currentDeviceId) return; 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(); // 1. Generate README const readmeContent = `PCB ReTrace Data Export Type: Full Device Backup (${dev.name}) Generated by: pcb.etaras.com Date: ${new Date().toISOString()} 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. `; zip.file("README.txt", readmeContent); // 2. Generate Manifest const manifest = { device: dev, version: DB_VER, source: "pcb.etaras.com", boards: [] }; const imgFolder = zip.folder("images"); 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 overlapsMap = new Map(); for(const img of imgs) { const ovs = await db.getOverlapsForImage(img.id); 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, overlaps: Array.from(overlapsMap.values()), nets: boardNets // Add Nets to manifest }); // 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(); blobToSave = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.85)); } imgFolder.file(`${img.id}.jpg`, blobToSave); } } zip.file("device.json", JSON.stringify(manifest, null, 2)); zip.generateAsync({type:"blob"}).then(c => dl(c, `${dev.name}_Backup.zip`, 'application/zip')); } function isValidBoardData(data) { // 1. Root Object check if (!data || typeof data !== 'object') return false; // 2. Meta Object check if (!data.meta || typeof data.meta !== 'object') return false; // ID must be a string and not empty if (typeof data.meta.id !== 'string' || !data.meta.id.trim()) return false; // Name must be a string (can be empty, but must exist) if (typeof data.meta.name !== 'string') return false; // 3. Components Array check if (!Array.isArray(data.components)) return false; // 4. Images Array check (optional but must be array if present) if (data.images && !Array.isArray(data.images)) return false; return true; } function isValidDeviceManifest(data) { if (!data || typeof data !== 'object') return false; // 1. Device Object check if (!data.device || typeof data.device !== 'object') return false; if (typeof data.device.id !== 'string' || !data.device.id.trim()) return false; if (typeof data.device.name !== 'string') return false; // 2. Boards Array check if (!Array.isArray(data.boards)) return false; // 3. Version check (optional warning could be added here, but structural check is pass/fail) return true; } function isValidLegacyData(data) { // Case A: Top-level Array of objects if (Array.isArray(data)) return true; // Case B: Object with 'components' or 'data' array if (data && typeof data === 'object') { return Array.isArray(data.components) || Array.isArray(data.data); } return false; } async function importFile(f) { if(!f) return; const name = f.name.toLowerCase(); // 1. ZIP Import (Device Backup or Board Export) if(name.endsWith('.zip')) { await processZIP(f); return; } // 2. JSON Import (Metadata only) if(name.endsWith('.json')) { const r = new FileReader(); r.onload = async e => { try { const json = JSON.parse(e.target.result); if (isValidBoardData(json)) { if (!currentDeviceId) return alert("Select a Device first."); if(confirm(`Import Board "${json.meta.name}" from JSON?`)) { json.meta.deviceId = currentDeviceId; await processImportData(json, null); } return; } if (isValidDeviceManifest(json)) { alert("Device Manifests should be imported via ZIP to include images.\nImporting metadata only."); if(confirm("Proceed with metadata-only import?")) { await restoreDevice(json, null); } return; } if (isValidLegacyData(json)) { if(confirm("Detected Legacy BOM format. Import into current board?")) { await processLegacyImport(json); } return; } throw new Error("JSON structure does not match known schemas."); } catch(e) { console.error(e); alert("Invalid JSON: " + e.message); } }; r.readAsText(f); return; } // 3. Image Import (Drag & Drop -> Open Editor) if(/\.(jpg|jpeg|png|webp)$/i.test(name)) { // SAFETY: Ensure a board is currently active if(!currentBomId) { alert("Cannot import image: No board selected.\nPlease select or create a board first."); return; } // Read file as DataURL to pass to the Editor const r = new FileReader(); r.onload = e => { if (typeof ImageImporter !== 'undefined') { // Pre-fill the name if(ImageImporter.nameInput) { ImageImporter.nameInput.value = f.name.replace(/\.[^/.]+$/, ""); } // Open the Modal ImageImporter.loadImage(e.target.result); } else { // Fallback if UI not loaded (unlikely) if(confirm(`Import image "${f.name}"?`)) { saveProcessedImageToDB(f, f.name); } } }; r.readAsDataURL(f); return; } } // Button Handler (keeps input element logic) function handleImport(i) { const f = i.files[0]; if(f) importFile(f); i.value = ''; // Reset input so same file can be selected again } function setupDragDrop() { const zone = document.body; let dragCounter = 0; // Fixes flickering when dragging over child elements // 1. Drag Enter zone.addEventListener('dragenter', e => { e.preventDefault(); dragCounter++; zone.classList.add('drag-active'); }); // 2. Drag Leave zone.addEventListener('dragleave', e => { e.preventDefault(); dragCounter--; if(dragCounter === 0) zone.classList.remove('drag-active'); }); // 3. Drag Over (Required to allow dropping) zone.addEventListener('dragover', e => e.preventDefault()); // 4. Drop zone.addEventListener('drop', async e => { e.preventDefault(); dragCounter = 0; zone.classList.remove('drag-active'); if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { // Process only the first file await importFile(e.dataTransfer.files[0]); } }); } async function processZIP(file) { if(!window.JSZip) return alert("JSZip library not loaded."); try { const zip = await JSZip.loadAsync(file); let recognized = false; // CASE A: Device Backup if (zip.file("device.json")) { const content = await zip.file("device.json").async("string"); 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)."); } } // CASE B: Single Board Backup else if (zip.file("bom.json")) { const content = await zip.file("bom.json").async("string"); let data; try { data = JSON.parse(content); } catch(e) { throw new Error("bom.json is corrupt"); } // STRICT CHECK if (isValidBoardData(data)) { recognized = true; if (!currentDeviceId) { alert("Please select or create a Device first."); return; } 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)."); } } 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); } } async function restoreDevice(manifest, zip) { const devId = manifest.device.id; const existingDev = deviceList.find(d => d.id === devId); if (!existingDev) { await db.addDevice(manifest.device); } // --- PRUNING PHASE 1: BOARDS --- const localBoards = await db.getProjectsByDevice(devId); const manifestBoardIds = new Set(manifest.boards.map(b => b.meta.id)); for (const lb of localBoards) { if (!manifestBoardIds.has(lb.id)) { await db.deleteProject(lb.id); // Note: deleteProject doesn't currently delete Nets automatically in the DB class // We should ideally clean them up, but for now we focus on the restore logic. console.log(`Pruned Board: ${lb.name}`); } } const imgFolder = zip.folder("images"); let updatedBoards = 0; let mergedBoards = 0; for (const boardData of manifest.boards) { const boardId = boardData.meta.id; const localBoard = await db.getProject(boardId); // Timestamp Logic const incomingTime = boardData.meta.lastModified || 0; const localTime = localBoard ? (localBoard.lastModified || 0) : -1; const isNewer = incomingTime > localTime; if (isNewer) { // Update Meta boardData.meta.deviceId = devId; await db.addProject(boardData.meta); if(localBoard) updatedBoards++; // --- PRUNING PHASE 2: CONTENT --- // 1. Prune Components const localComps = await db.getComponents(boardId); const importCompIds = new Set(boardData.components.map(c => c.id)); for(const lc of localComps) { if(!importCompIds.has(lc.id)) await db.deleteComponent(lc.id); } // 2. Prune Images const localImages = await db.getImages(boardId); const importImgIds = new Set(boardData.images.map(i => i.id)); for(const li of localImages) { if(!importImgIds.has(li.id)) await db.deleteImage(li.id); } // 3. Prune Overlaps if(localImages.length > 0) { const boardOverlapIds = new Set(); for(const li of localImages) { const ovs = await db.getOverlapsForImage(li.id); ovs.forEach(o => boardOverlapIds.add(o.id)); } const importOvIds = new Set((boardData.overlaps || []).map(o => o.id)); const tx = db.db.transaction('overlappedImages', 'readwrite'); const store = tx.objectStore('overlappedImages'); boardOverlapIds.forEach(ovid => { if(!importOvIds.has(ovid)) store.delete(ovid); }); } // 4. Prune Nets [NEW] const allNets = await db.getNets(); const localNets = allNets.filter(n => n.projectId === boardId); const importNetIds = new Set((boardData.nets || []).map(n => n.id)); for(const ln of localNets) { if(!importNetIds.has(ln.id)) await db.deleteNet(ln.id); } } else { mergedBoards++; } // --- UPSERT PHASE --- // Images (unchanged logic...) if (imgFolder && 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 cleanName = im.name.replace(/\.[^/.]+$/, ""); await db.addImage({ id: im.id, boardId: boardId, blob, name: cleanName }); } } } // Components for (const c of boardData.components) { c.boardId = boardId; if (isNewer) { await db.addComponent(c); } else { const existingC = await db._tx('components', 'readonly', s => s.get(c.id)); if (!existingC) await db.addComponent(c); } } // Overlaps if (boardData.overlaps) { for (const ov of boardData.overlaps) { if (isNewer) { await db.addOverlap(ov); } else { const existingOv = await db._tx('overlappedImages', 'readonly', s => s.get(ov.id)); if (!existingOv) await db.addOverlap(ov); } } } // Nets [NEW] if (boardData.nets) { for (const net of boardData.nets) { // Ensure correct association net.projectId = boardId; if (isNewer) { await db.addNet(net); } else { const existingNet = await db._tx('nets', 'readonly', s => s.get(net.id)); if (!existingNet) await db.addNet(net); } } } } deviceList = await db.getDevices(); currentDeviceId = devId; updateDeviceDropdown(); await loadDeviceBoms(); } async function processImportData(data, zipObj) { const boardId = data.meta.id; const localBoard = await db.getProject(boardId); const incomingTime = data.meta.lastModified || 0; const localTime = localBoard ? (localBoard.lastModified || 0) : -1; const isNewer = incomingTime > localTime; if (isNewer) { // Update Meta data.meta.deviceId = localBoard ? localBoard.deviceId : currentDeviceId; await db.addProject(data.meta); // --- PRUNING PHASE --- // 1. Components const localComps = await db.getComponents(boardId); const importCompIds = new Set(data.components.map(c => c.id)); for(const lc of localComps) { if(!importCompIds.has(lc.id)) await db.deleteComponent(lc.id); } // 2. Images const localImages = await db.getImages(boardId); const importImgIds = new Set((data.images || []).map(i => i.id)); for(const li of localImages) { if(!importImgIds.has(li.id)) await db.deleteImage(li.id); } // 3. Overlaps if(localImages.length > 0) { const boardOverlapIds = new Set(); for(const li of localImages) { const ovs = await db.getOverlapsForImage(li.id); ovs.forEach(o => boardOverlapIds.add(o.id)); } const importOvIds = new Set((data.overlaps || []).map(o => o.id)); const tx = db.db.transaction('overlappedImages', 'readwrite'); const store = tx.objectStore('overlappedImages'); boardOverlapIds.forEach(ovid => { if(!importOvIds.has(ovid)) store.delete(ovid); }); } // 4. Nets [NEW] const allNets = await db.getNets(); const localNets = allNets.filter(n => n.projectId === boardId); const importNetIds = new Set((data.nets || []).map(n => n.id)); for(const ln of localNets) { if(!importNetIds.has(ln.id)) await db.deleteNet(ln.id); } } else { console.log("Import is older/same. Merging missing items only."); } // --- 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 }); } } } // Components for(const c of data.components) { c.boardId = boardId; if (isNewer) { await db.addComponent(c); } else { const existing = await db._tx('components', 'readonly', s => s.get(c.id)); if (!existing) await db.addComponent(c); } } // Overlaps if (data.overlaps) { for (const ov of data.overlaps) { if (isNewer) { await db.addOverlap(ov); } else { const existing = await db._tx('overlappedImages', 'readonly', s => s.get(ov.id)); if (!existing) await db.addOverlap(ov); } } } // Nets [NEW] if (data.nets) { for (const net of data.nets) { net.projectId = boardId; if (isNewer) { await db.addNet(net); } else { const existing = await db._tx('nets', 'readonly', s => s.get(net.id)); if (!existing) await db.addNet(net); } } } await loadDeviceBoms(); } async function processLegacyImport(b) { if (!currentBomId) return alert("Create/Select a board first."); for(const c of (b.components || b.data)) { c.id = uuid(); c.boardId = currentBomId; await db.addComponent(c); } await loadProjectData(); } // 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); // Fetch and filter Nets const allNets = await db.getNets(); const boardNets = allNets.filter(n => n.projectId === currentBomId); const overlapsMap = new Map(); for(const img of images) { 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; }); // 1. Generate README const readmeContent = `PCB ReTrace Data Export Type: Single Board (${meta.name}) Generated by: pcb.etaras.com Date: ${new Date().toISOString()} 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. `; zip.file("README.txt", readmeContent); // 2. Generate JSON const data = { meta, components: cleanComps, images: imgMeta, overlaps: overlaps, nets: boardNets, // Add Nets version: DB_VER, source: "pcb.etaras.com" }; zip.file("bom.json", JSON.stringify(data, null, 2)); const imgFolder = zip.folder("images"); // 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(); blobToSave = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.85)); } imgFolder.file(img.id + ".jpg", blobToSave); } zip.generateAsync({type:"blob"}).then(c => dl(c, meta.name + "_Board.zip", "application/zip")); } function exportCSV() { const meta = bomList.find(p => p.id === currentBomId); const view = getSortedView(); let csv = "Reference,Value,Description\n"; view.forEach(r => csv += `${r.label},${r.value},"${(r.desc||'').replace(/"/g,'""')}"\n`); const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${meta.name}_BOM.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); } function dl(c,n,t){ const b=(c instanceof Blob)?c:new Blob([c],{type:t}); const u=window.URL.createObjectURL(b); const a=document.createElement('a'); a.href=u; a.download=n; document.body.appendChild(a); a.click(); document.body.removeChild(a); } // --- UI HELPERS --- // Global Confirmation Helper (Promise-based) function confirmAction(message, btnText = "Confirm") { return new Promise((resolve) => { const modal = document.getElementById('confirmation-modal'); const msgEl = document.getElementById('confirm-msg'); const okBtn = document.getElementById('confirm-btn-ok'); const cancelBtn = document.getElementById('confirm-btn-cancel'); msgEl.innerText = message; okBtn.innerText = btnText; // Cleanup old handlers by reassigning okBtn.onclick = () => { modal.style.display = 'none'; resolve(true); }; cancelBtn.onclick = () => { modal.style.display = 'none'; resolve(false); }; modal.style.display = 'flex'; cancelBtn.focus(); // Default focus on Cancel }); } function switchView(v) { // UI Toggles document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.view-section').forEach(s => s.classList.remove('active')); // Button Active State const btn = document.getElementById(`tab-${v}`); if (btn) btn.classList.add('active'); // View Active State const view = document.getElementById(`view-${v}`); if (view) view.classList.add('active'); // View Specific Initialization if (v === 'map') { if(currentImgId) { if(!document.getElementById('map-content').querySelector('.pcb-image')) showImage(currentImgId); else { setTimeout(() => { if(mapState.scale === 1 && mapState.x === 0 && mapState.y === 0) fitMap(); renderPins(); }, 50); } } } else if (v === 'nets') { if(window.netManager) window.netManager.render(); } else if (v === 'inspect') { if(window.inspector) window.inspector.init(); } // 'list' needs no specific init } function resetStickyEditor() { document.getElementById('inp-id').value = ''; document.getElementById('inp-label').value = ''; document.getElementById('inp-value').value = ''; document.getElementById('inp-desc').value = ''; document.getElementById('inp-x').value = ''; document.getElementById('inp-y').value = ''; document.getElementById('inp-img-id').value = ''; document.getElementById('new-part-tool-container').innerHTML = ''; document.getElementById('inline-thumbs').innerHTML = ''; if(spyglass) spyglass.clear(); } function parseLabel(l) { const m=l.toUpperCase().match(/^([A-Z]+)(\d+)(.*)$/); return m?{valid:true,prefix:m[1],num:parseInt(m[2])}:{valid:false}; } async function changeSortMode() { sortMode=document.getElementById('sort-select').value; const m=bomList.find(x=>x.id===currentBomId); if(m){ m.sortMode=sortMode; await db.addProject(m); renderList(); } } function getSortedView() { let view = bomData.map((item, index) => ({ ...item, dataIndex: index })); if (sortMode === 'none') return view; view.sort((a, b) => { const pa=parseLabel(a.label), pb=parseLabel(b.label); if (!pa.valid || !pb.valid) return a.label.localeCompare(b.label); if (sortMode === 'std') return pa.prefix!==pb.prefix ? pa.prefix.localeCompare(pb.prefix) : pa.num-pb.num; return pa.num!==pb.num ? pa.num-pb.num : pa.prefix.localeCompare(pb.prefix); }); return view; } function renderList() { const tb = document.getElementById('bom-body'); tb.innerHTML=''; let view = getSortedView(); if(view.length===0) tb.innerHTML=`