read_board_image reported "no board photos available" - buildProjectImages was returning [] (both photos failed to encode) with no visibility. - split encodeImage into downscale() + blobToB64(); on any failure (createImageBitmap / canvas.toBlob returning null / EXIF-heavy JPEGs) fall back to sending the raw blob base64 if it's under ~3MB. - coerce a non-Blob img.blob through new Blob([...]). - per-image [cmms-pcb-bridge] tracing: "downscaled ok" / "downscale failed: <msg> - trying raw" / "raw encode also failed" etc.
355 lines
14 KiB
JavaScript
355 lines
14 KiB
JavaScript
/*
|
|
* 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();
|
|
}
|
|
|
|
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 allNets = currentBomId ? await db.getNets() : [];
|
|
const nets = allNets.filter(n => n.projectId === currentBomId);
|
|
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: bomImages.length,
|
|
images: bomImages.map(i => ({ id: i.id, name: i.name || '' })),
|
|
components: bomData.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() {
|
|
if (!currentBomId || !bomImages.length) return [];
|
|
const out = [];
|
|
for (const img of bomImages.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', bomImages.length, 'photos encoded');
|
|
return out;
|
|
}
|
|
|
|
async function applyProjectPatch(patchJson) {
|
|
const patch = JSON.parse(patchJson);
|
|
if (!currentBomId) { status('Select or create a board first', true); return; }
|
|
|
|
const byLabel = l => bomData.find(c => (c.label || '').toUpperCase() === l.toUpperCase());
|
|
const fallbackImg = currentImgId || (bomImages[0] && bomImages[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 };
|
|
})();
|