/* * Copyright (c) 2025-2026 Taras Greben (upstream) — CMMS fork addition. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial-pcb-retrace * * cmms-bridge.js — glue between pcb-retrace and the CMMS attachment system. * * Completely inert unless window.PCBRETRACE_CONFIG is present, which only * public/vendor/pcb-retrace/edit.php sets. Standalone pcb.etaras.com / local * use is unaffected. * * PCBRETRACE_CONFIG = { * loadUrl: GET url for the project .pcbretrace.zip (omit for a new board) * saveUrl: POST target ("/attachment.php") * saveAction: "pcb_retrace_save" | "pcb_retrace_save_new" * attachmentId: existing attachment id (omit for a new board) * jobId: owner scope token "entityType:entityId", echoed back on save * csrfToken: CMMS CSRF token * originalName: filename to save as * returnTo: url to navigate to after "Save & close" * dark: boolean (studio.html is light-only today — recorded, not styled) * } * * Relies on globals from studio.js (same page, classic scripts share scope): * db, currentDeviceId, buildDeviceZIP(), processUrlImport(). * window.CMMS.init() is awaited at the end of studio.js's init(). */ (function () { 'use strict'; const CFG = window.PCBRETRACE_CONFIG; if (!CFG) return; let saveInFlight = false; let statusEl = null; function status(msg, isError) { if (!statusEl) return; statusEl.textContent = msg || ''; statusEl.style.color = isError ? '#b91c1c' : '#475569'; if (msg && !isError) { clearTimeout(status._t); status._t = setTimeout(() => { if (statusEl.textContent === msg) statusEl.textContent = ''; }, 4000); } } function injectControls() { const topBar = document.querySelector('.top-bar'); if (!topBar) return; const mk = (label, title, primary) => { const b = document.createElement('button'); b.textContent = label; b.title = title; b.className = primary ? 'primary' : 'secondary'; b.style.cssText = 'white-space:nowrap;' + (primary ? 'background:#2563eb;color:#fff;border-color:#2563eb;' : ''); return b; }; const saveBtn = mk('💾 Save', 'Save this board back to the CMMS', true); const saveExitBtn = mk('Save & close', 'Save and return to the CMMS', false); statusEl = document.createElement('span'); statusEl.style.cssText = 'font-size:0.8rem; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:180px;'; saveBtn.addEventListener('click', () => save(false)); saveExitBtn.addEventListener('click', () => save(true)); const box = document.createElement('div'); box.style.cssText = 'margin-left:auto; display:flex; gap:6px; align-items:center; flex:0 0 auto;'; box.append(statusEl, saveBtn, saveExitBtn); topBar.appendChild(box); // Hide the standalone import/export/share buttons — the CMMS owns // persistence here. (The whole-device drag-drop still works.) ['Import from URL', 'Import Device', 'Export Device'].forEach(t => { const el = document.querySelector('#device-actions button[title="' + t + '"]'); if (el) el.style.display = 'none'; }); } async function save(thenExit) { if (saveInFlight) return; if (!currentDeviceId) { status('Nothing to save yet', true); return; } saveInFlight = true; status('Saving…'); try { const name = CFG.originalName || 'board.pcbretrace.zip'; const { blob } = await buildDeviceZIP(null, true); const fd = new FormData(); fd.append('action', CFG.saveAction); fd.append('csrf_token', CFG.csrfToken); fd.append('job_id', CFG.jobId); fd.append('id', CFG.attachmentId ? String(CFG.attachmentId) : ''); fd.append('original_name', name); fd.append('file', blob, name); const r = await fetch(CFG.saveUrl, { method: 'POST', body: fd, credentials: 'same-origin' }); const data = await r.json().catch(() => { throw new Error('HTTP ' + r.status); }); if (!data || !data.ok) throw new Error((data && data.error) || ('HTTP ' + r.status)); // First save of a new board — switch to updating it in place. if (!CFG.attachmentId && data.id) { CFG.attachmentId = String(data.id); CFG.saveAction = 'pcb_retrace_save'; } status('Saved ✓'); if (thenExit && CFG.returnTo) window.location.href = CFG.returnTo; } catch (e) { status('Save failed: ' + (e.message || e), true); } finally { saveInFlight = false; } } // ---- AI Assistant editor-buffer bridge ------------------------------- // The chat embed (an iframe) postMessages here; this top-level page is the // "editor" side of the cmms-ai-editor-* protocol (like cad-editor, whose // WASM app is also the top-level page). read: serialise the open board; // apply: merge a JSON patch of proposed components + nets. Nothing is saved. const trace = (...a) => { try { console.log('[cmms-pcb-bridge]', ...a); } catch (e) {} }; // The chat's get-buffer request can land before studio.js's init() has // finished creating the DB and selecting a board (or while an existing // project ZIP is still downloading). Wait briefly rather than replying // "not ready" - the widget times out at 2.5s, so stay under that. const ready = () => !!(db && currentBomId); async function waitReady(ms) { const t0 = Date.now(); while (!ready() && Date.now() - t0 < ms) { await new Promise(r => setTimeout(r, 100)); } return ready(); } // Read the project straight from IndexedDB rather than the studio.js globals // (bomData/bomImages) - those get transiently reset while a project loads or // auto-stitches, which was leaving the AI bridge with an empty image list // even though buildProjectView's own snapshot showed photos. async function readBoard() { const [components, imageRecs, allNets] = await Promise.all([ db.getComponents(currentBomId), db.getImages(currentBomId), db.getNets(), ]); return { components, imageRecs, nets: allNets.filter(n => n.projectId === currentBomId) }; } async function buildProjectView() { // The widget waits 6s for a pcb reply (ai-assistant-widget.js); stay // under that so a "still initialising" answer still gets through. if (!(await waitReady(4500))) { trace('buildProjectView: db/board not ready after 4.5s (db:', !!db, 'board:', !!currentBomId, ')'); // db exists but no board -> a real (empty) project the model can // still work from; only a missing db means "still loading". if (!db) return null; } const dev = deviceList.find(d => d.id === currentDeviceId); const board = bomList.find(b => b.id === currentBomId); const { components, imageRecs, nets } = currentBomId ? await readBoard() : { components: [], imageRecs: [], nets: [] }; const view = { device: dev ? dev.name : null, board: board ? board.name : null, note: currentBomId ? undefined : 'No board is open yet - the user needs to create or select one before components/nets can be added.', image_count: imageRecs.length, images: imageRecs.map(i => ({ id: i.id, name: i.name || '' })), components: components.map(c => ({ label: c.label, value: c.value || '', desc: c.desc || '', x: Math.round(c.x), y: Math.round(c.y), })), nets: nets.map(n => ({ name: n.name, pins: (n.nodes || []).map(nd => nd.label) })), }; trace('buildProjectView:', view.components.length, 'components,', view.nets.length, 'nets,', view.image_count, 'photos'); return JSON.stringify(view); } // The board photos live in pcb-retrace's IndexedDB (imported by the user or // unpacked from the .pcbretrace.zip), NOT as CMMS attachments, so the // assistant can't reach them with read_attachment_image. Hand them along // with the buffer so a `read_board_image` tool can surface them. const MAX_IMG_B64 = 4 * 1024 * 1024; // ~3MB raw; matches message.php's cap function blobToB64(blob) { return new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(String(r.result).split(',', 2)[1] || ''); r.onerror = () => rej(new Error('FileReader failed')); r.readAsDataURL(blob); }); } // Downscale a photo to ~maxEdge on the long side as JPEG. Returns a Blob. async function downscale(blob, maxEdge) { const bmp = await createImageBitmap(blob); try { const s = Math.min(1, maxEdge / Math.max(bmp.width, bmp.height)); const w = Math.max(1, Math.round(bmp.width * s)); const h = Math.max(1, Math.round(bmp.height * s)); const c = document.createElement('canvas'); c.width = w; c.height = h; c.getContext('2d').drawImage(bmp, 0, 0, w, h); const out = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.8)); if (!out) throw new Error('canvas.toBlob returned null'); return out; } finally { try { bmp.close(); } catch (e) {} } } async function buildProjectImages() { trace('buildProjectImages: start (board:', currentBomId || '(none)', ')'); if (!currentBomId) return []; const imgs = await db.getImages(currentBomId); trace('buildProjectImages:', imgs.length, 'image record(s) in the DB'); const out = []; for (const img of imgs.slice(0, 6)) { let blob = img.blob; if (!(blob instanceof Blob)) { try { blob = new Blob([blob], { type: (img.type || 'image/jpeg') }); } catch (e) { trace('image', img.id, 'has no usable blob'); continue; } } let b64 = null; try { b64 = await blobToB64(await downscale(blob, 1400)); trace('image', img.id, 'downscaled ok'); } catch (e) { trace('image', img.id, 'downscale failed:', e && e.message, '- trying raw'); // Fall back to the raw blob (some builds / EXIF-heavy JPEGs break // createImageBitmap or toBlob). Only if it's not huge. try { if (blob.size <= 3 * 1024 * 1024) b64 = await blobToB64(blob); else trace('image', img.id, 'raw blob too large (', blob.size, ')'); } catch (e2) { trace('image', img.id, 'raw encode also failed:', e2 && e2.message); } } if (!b64) continue; if (b64.length > MAX_IMG_B64) { trace('image', img.id, 'over size cap after encode'); continue; } out.push({ id: img.id, name: img.name || (img.id + '.jpg'), mime: (blob.type && blob.type.indexOf('image/') === 0) ? blob.type : 'image/jpeg', data_base64: b64, }); } trace('buildProjectImages:', out.length, 'of', imgs.length, 'photos encoded, total b64', out.reduce((n, i) => n + i.data_base64.length, 0)); return out; } async function applyProjectPatch(patchJson) { const patch = JSON.parse(patchJson); if (!currentBomId) { status('Select or create a board first', true); return; } const comps = await db.getComponents(currentBomId); const imgRecs = await db.getImages(currentBomId); const byLabel = l => comps.find(c => (c.label || '').toUpperCase() === l.toUpperCase()); const fallbackImg = currentImgId || (imgRecs[0] && imgRecs[0].id) || null; let spread = 40; for (const c of (patch.components || [])) { const existing = byLabel(c.label); const rec = existing || { id: uuid(), boardId: currentBomId, label: c.label, x: 0, y: 0 }; if (c.value !== undefined) rec.value = c.value; if (c.desc !== undefined) rec.desc = c.desc; if (c.x !== undefined) rec.x = Math.round(c.x); if (c.y !== undefined) rec.y = Math.round(c.y); // New part with no coordinate → stagger it so it's draggable, not stacked. if (!existing && c.x === undefined) { rec.x = spread; rec.y = spread; spread += 40; } rec.source = 'ai_suggested'; await db.addComponent(rec); } const allNets = await db.getNets(); const projectNets = allNets.filter(n => n.projectId === currentBomId); for (const n of (patch.nets || [])) { let net = projectNets.find(x => x.name === n.name); if (!net) net = { id: uuid(), name: n.name, projectId: currentBomId, nodes: [] }; const have = new Set((net.nodes || []).map(nd => nd.label)); for (const pin of (n.pins || [])) { if (have.has(pin)) continue; const comp = byLabel(pin.split('.')[0]); net.nodes.push({ id: uuid(), imgId: fallbackImg, x: comp ? Math.round(comp.x) : 0, y: comp ? Math.round(comp.y) : 0, label: pin, }); } await db.addNet(net); } await loadProjectData(); if (window.netManager) await window.netManager.render(); if (window.inspector && window.inspector.updateNetNodeCache) window.inspector.updateNetNodeCache(); const nc = (patch.components || []).length, nn = (patch.nets || []).length; status(`AI proposal applied (${nc} component${nc === 1 ? '' : 's'}, ${nn} net${nn === 1 ? '' : 's'}) — check each against the photo, then Save`); } // Reply to the chat iframe. Prefer evt.source; fall back to a DOM query // (cad-editor hit a case where event.source was an unusable cross-realm // Window - belt and braces here too). function replyToChat(evtSource, msg) { let posted = false; try { if (evtSource && evtSource.postMessage) { evtSource.postMessage(msg, window.location.origin); posted = true; } } catch (e) {} if (!posted) { const f = document.querySelector('#ai-assistant-editor-embed iframe'); try { if (f && f.contentWindow) { f.contentWindow.postMessage(msg, window.location.origin); posted = true; } } catch (e) {} } trace('reply', msg.type, posted ? 'sent' : 'DROPPED'); } window.addEventListener('message', async (evt) => { if (evt.origin !== window.location.origin || !evt.data || typeof evt.data !== 'object') return; const d = evt.data; if (d.type === 'cmms-ai-editor-get-buffer') { trace('get-buffer received'); let buffer = null; let images = []; try { buffer = await buildProjectView(); } catch (e) { trace('buildProjectView threw:', e && e.message); } try { images = await buildProjectImages(); } catch (e) { trace('buildProjectImages threw:', e && e.message); } replyToChat(evt.source, { type: 'cmms-ai-editor-buffer', buffer: buffer, images: images }); } else if (d.type === 'cmms-ai-editor-apply' && d.editor === 'pcb' && typeof d.new_source === 'string') { trace('apply received'); try { await applyProjectPatch(d.new_source); } catch (e) { trace('applyProjectPatch threw:', e && e.message); status('Could not apply the AI proposal: ' + (e.message || e), true); } } }); // A "+ New board RE" launch with no board on the selected device (studio.js // only auto-creates one when the whole DB is empty) - make one so the user // lands on a working board, not an empty "No Boards" shell. async function ensureBoard() { if (currentBomId) return; let devId = currentDeviceId; if (!devId || !deviceList.find(d => d.id === devId)) { devId = uuid(); await db.addDevice({ id: devId, name: 'Board' }); currentDeviceId = devId; try { localStorage.setItem('pcb_device_id', devId); } catch (e) {} } await db.addProject({ id: uuid(), deviceId: devId, name: 'Board 1', sortMode: 'none' }); deviceList = await db.getDevices(); updateDeviceDropdown(); await loadDeviceBoms(); trace('ensureBoard: created a starter board'); } async function init() { if (CFG.dark) document.documentElement.setAttribute('data-theme', 'dark'); injectControls(); if (CFG.loadUrl) { status('Loading board…'); try { // processUrlImport → importFile → processZIP → restoreDevice, // which refreshes the device/board UI itself. silent: skip the // "restore?" confirm for a trusted CMMS attachment. await processUrlImport(CFG.loadUrl, { silent: true }); status(''); } catch (e) { status('Could not load the saved board: ' + (e.message || e), true); } } else { try { await ensureBoard(); } catch (e) { trace('ensureBoard threw:', e && e.message); } } } window.CMMS = { init, save }; })();