CMMS fork: embedding hooks (window.PCBRETRACE_CONFIG)
New docs/cmms-bridge.js — inert unless window.PCBRETRACE_CONFIG is present
(only public/vendor/pcb-retrace/edit.php in the CMMS sets it). When set:
- injects "Save" / "Save & close" into the top bar; hides the standalone
import/export/share buttons.
- on boot, if config.loadUrl is set, pulls the project .pcbretrace.zip
from the CMMS and restores it silently (reuses processUrlImport).
- Save builds the whole-device ZIP in memory and POSTs it multipart to
the CMMS attachment endpoint (action/csrf/job_id/id/original_name/file,
same contract as circuit-edit.php); first save of a new board switches
to in-place updates.
studio.js:
- exportDeviceZIP() split into buildDeviceZIP(pass, forCmms) -> {blob,
filename} + a thin exportDeviceZIP() wrapper. forCmms adjusts the
embedded README + manifest.source.
- importFile/processZIP/processUrlImport take an opts arg; opts.silent
skips the restore/import confirms and re-throws instead of alert().
- init() awaits window.CMMS.init() at the end when present.
studio.html: loads cmms-bridge.js (defer, after studio.js).
Standalone pcb.etaras.com / local use is unchanged — every new path is
gated on the config object or opts.silent, both absent by default.
Dark mode: config.dark is recorded as data-theme="dark" but studio.html
is light-only today; CMMS-theme-follow is a later task.
This commit is contained in:
parent
5e4c7245b6
commit
8bf5fd3a9a
3 changed files with 170 additions and 17 deletions
133
docs/cmms-bridge.js
Normal file
133
docs/cmms-bridge.js
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.CMMS = { init, save };
|
||||||
|
})();
|
||||||
|
|
@ -27,6 +27,9 @@
|
||||||
<script src="nets.js"></script>
|
<script src="nets.js"></script>
|
||||||
<script src="inspect-traces.js"></script>
|
<script src="inspect-traces.js"></script>
|
||||||
<script src="inspector.js"></script>
|
<script src="inspector.js"></script>
|
||||||
|
<!-- CMMS embedding bridge — inert unless window.PCBRETRACE_CONFIG is set
|
||||||
|
(only public/vendor/pcb-retrace/edit.php sets it). -->
|
||||||
|
<script src="cmms-bridge.js" defer></script>
|
||||||
|
|
||||||
<link rel="stylesheet" href="common.css">
|
<link rel="stylesheet" href="common.css">
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,9 @@ async function init() {
|
||||||
localStorage.removeItem('pcb_startup_tab');
|
localStorage.removeItem('pcb_startup_tab');
|
||||||
setTimeout(() => switchView(startupTab), 100);
|
setTimeout(() => switchView(startupTab), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CMMS embed — inert unless cmms-bridge.js saw window.PCBRETRACE_CONFIG.
|
||||||
|
if (window.CMMS) await window.CMMS.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Find all transitive connections for a given image
|
// Helper: Find all transitive connections for a given image
|
||||||
|
|
@ -602,11 +605,10 @@ async function importSchemaDependencies(schemaDeps, idMap) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Device Export
|
// Device Export
|
||||||
async function exportDeviceZIP() {
|
// Build a full-device backup ZIP in memory and return { blob, filename }.
|
||||||
if (!currentDeviceId) return;
|
// pass: AES-256 passphrase, or null/'' for none. forCmms: adjust the embedded
|
||||||
const pass = await requestPassphrase('Protect Device Export with Passphrase?', { allowEmpty: true, confirmNew: true });
|
// README + manifest.source for re-import through the CMMS editor.
|
||||||
if (pass === null) return; // cancelled
|
async function buildDeviceZIP(pass, forCmms = false) {
|
||||||
|
|
||||||
const { BlobWriter, BlobReader, TextReader, ZipWriter } = zip;
|
const { BlobWriter, BlobReader, TextReader, ZipWriter } = zip;
|
||||||
const dev = deviceList.find(d => d.id === currentDeviceId);
|
const dev = deviceList.find(d => d.id === currentDeviceId);
|
||||||
const boms = await db.getProjectsByDevice(currentDeviceId);
|
const boms = await db.getProjectsByDevice(currentDeviceId);
|
||||||
|
|
@ -632,19 +634,21 @@ ENCRYPTED FILE NOTE:
|
||||||
support AES-256 ZIP encryption — use the tools listed above.
|
support AES-256 ZIP encryption — use the tools listed above.
|
||||||
` : '';
|
` : '';
|
||||||
|
|
||||||
|
const howTo = forCmms
|
||||||
|
? 'Open the board\'s attachment in the CMMS PCB reverse-engineering editor,\nor import this ZIP into pcb.etaras.com/studio.html.'
|
||||||
|
: '1. Go to https://pcb.etaras.com/studio.html\n2. Click "Import Device" or drag and drop this ZIP file into the tool.';
|
||||||
const readmeContent = `PCB ReTrace Data Export
|
const readmeContent = `PCB ReTrace Data Export
|
||||||
Type: Full Device Backup (${dev.name})
|
Type: Full Device Backup (${dev.name})
|
||||||
Generated by: pcb.etaras.com
|
Generated by: ${forCmms ? 'the CMMS PCB reverse-engineering editor' : 'pcb.etaras.com'}
|
||||||
Date: ${new Date().toISOString()}
|
Date: ${new Date().toISOString()}
|
||||||
${pass ? 'Protection: AES-256 encrypted — requires passphrase to open.\n' : ''}
|
${pass ? 'Protection: AES-256 encrypted — requires passphrase to open.\n' : ''}
|
||||||
HOW TO USE:
|
HOW TO USE:
|
||||||
1. Go to https://pcb.etaras.com/studio.html
|
${howTo}
|
||||||
2. Click "Import Device" or drag and drop this ZIP file into the tool.
|
|
||||||
${encNote}
|
${encNote}
|
||||||
`;
|
`;
|
||||||
await zipWriter.add('README.txt', new TextReader(readmeContent));
|
await zipWriter.add('README.txt', new TextReader(readmeContent));
|
||||||
|
|
||||||
const manifest = { device: dev, version: db.ver, source: 'pcb.etaras.com', boards: [] };
|
const manifest = { device: dev, version: db.ver, source: forCmms ? 'cmms' : 'pcb.etaras.com', boards: [] };
|
||||||
|
|
||||||
for (const bom of boms) {
|
for (const bom of boms) {
|
||||||
const comps = await db.getComponents(bom.id);
|
const comps = await db.getComponents(bom.id);
|
||||||
|
|
@ -689,7 +693,15 @@ ${encNote}
|
||||||
|
|
||||||
await zipWriter.add('device.json', new TextReader(JSON.stringify(manifest, null, 2)), encOpts);
|
await zipWriter.add('device.json', new TextReader(JSON.stringify(manifest, null, 2)), encOpts);
|
||||||
await zipWriter.close();
|
await zipWriter.close();
|
||||||
dl(await zipBlobWriter.getData(), `${dev.name}_Backup.zip`, 'application/zip');
|
return { blob: await zipBlobWriter.getData(), filename: `${dev.name}_Backup.zip` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportDeviceZIP() {
|
||||||
|
if (!currentDeviceId) return;
|
||||||
|
const pass = await requestPassphrase('Protect Device Export with Passphrase?', { allowEmpty: true, confirmNew: true });
|
||||||
|
if (pass === null) return; // cancelled
|
||||||
|
const { blob, filename } = await buildDeviceZIP(pass);
|
||||||
|
dl(blob, filename, 'application/zip');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidBoardData(data) {
|
function isValidBoardData(data) {
|
||||||
|
|
@ -738,13 +750,13 @@ function isValidLegacyData(data) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importFile(f) {
|
async function importFile(f, opts = {}) {
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
const name = f.name.toLowerCase();
|
const name = f.name.toLowerCase();
|
||||||
|
|
||||||
// 1. ZIP Import (Device Backup or Board Export)
|
// 1. ZIP Import (Device Backup or Board Export)
|
||||||
if (name.endsWith('.zip')) {
|
if (name.endsWith('.zip')) {
|
||||||
await processZIP(f);
|
await processZIP(f, opts);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -859,7 +871,9 @@ function setupDragDrop() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processZIP(file) {
|
async function processZIP(file, opts = {}) {
|
||||||
|
// opts.silent: skip the "restore/import?" confirms (CMMS auto-load of a
|
||||||
|
// trusted attachment). Encryption prompts still apply if the ZIP is locked.
|
||||||
const { BlobReader, BlobWriter, TextWriter, ZipReader } = zip;
|
const { BlobReader, BlobWriter, TextWriter, ZipReader } = zip;
|
||||||
try {
|
try {
|
||||||
// 1. Open without password to inspect entries
|
// 1. Open without password to inspect entries
|
||||||
|
|
@ -908,7 +922,7 @@ async function processZIP(file) {
|
||||||
throw new Error('ZIP contains device.json, but it is missing required fields (id, name, boards).');
|
throw new Error('ZIP contains device.json, but it is missing required fields (id, name, boards).');
|
||||||
const vMsg = manifest.version ? ` (v${manifest.version})` : '';
|
const vMsg = manifest.version ? ` (v${manifest.version})` : '';
|
||||||
const sMsg = manifest.source ? `\nSource: ${manifest.source}` : '';
|
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.`))
|
if (opts.silent || confirm(`Restore Device: "${manifest.device.name}"${vMsg}?${sMsg}\n\nThis will merge boards and overwrite existing components.`))
|
||||||
await restoreDevice(manifest, imageMap);
|
await restoreDevice(manifest, imageMap);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -920,7 +934,7 @@ async function processZIP(file) {
|
||||||
if (!isValidBoardData(data))
|
if (!isValidBoardData(data))
|
||||||
throw new Error('ZIP contains bom.json, but it is missing required fields (id, meta, components).');
|
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 (!currentDeviceId) { alert('Please select or create a Device first.'); return; }
|
||||||
if (confirm(`Import Board: "${data.meta.name}" into current Device?`)) {
|
if (opts.silent || confirm(`Import Board: "${data.meta.name}" into current Device?`)) {
|
||||||
data.meta.deviceId = currentDeviceId;
|
data.meta.deviceId = currentDeviceId;
|
||||||
await processImportData(data, imageMap);
|
await processImportData(data, imageMap);
|
||||||
}
|
}
|
||||||
|
|
@ -3128,7 +3142,7 @@ async function importDeviceFromURL() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processUrlImport(url) {
|
async function processUrlImport(url, opts = {}) {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
showBusy("Downloading Device...");
|
showBusy("Downloading Device...");
|
||||||
|
|
@ -3160,12 +3174,15 @@ async function processUrlImport(url) {
|
||||||
hideBusy();
|
hideBusy();
|
||||||
|
|
||||||
// 4. Handover to existing Import logic
|
// 4. Handover to existing Import logic
|
||||||
await importFile(file);
|
await importFile(file, opts);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
hideBusy();
|
hideBusy();
|
||||||
console.error("URL Import Error:", e);
|
console.error("URL Import Error:", e);
|
||||||
|
|
||||||
|
// CMMS auto-load surfaces its own banner — let the caller handle it.
|
||||||
|
if (opts.silent) throw e;
|
||||||
|
|
||||||
// User-friendly error regarding CORS
|
// User-friendly error regarding CORS
|
||||||
let msg = `Import Failed.\n\nError: ${e.message}`;
|
let msg = `Import Failed.\n\nError: ${e.message}`;
|
||||||
if (e.name === 'TypeError' && e.message === 'Failed to fetch') {
|
if (e.name === 'TypeError' && e.message === 'Failed to fetch') {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue