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:
Stuart 2026-09-03 14:16:42 +10:00
commit 8bf5fd3a9a
3 changed files with 170 additions and 17 deletions

View file

@ -190,6 +190,9 @@ async function init() {
localStorage.removeItem('pcb_startup_tab');
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
@ -602,11 +605,10 @@ async function importSchemaDependencies(schemaDeps, idMap) {
}
}
// Device Export
async function exportDeviceZIP() {
if (!currentDeviceId) return;
const pass = await requestPassphrase('Protect Device Export with Passphrase?', { allowEmpty: true, confirmNew: true });
if (pass === null) return; // cancelled
// Build a full-device backup ZIP in memory and return { blob, filename }.
// pass: AES-256 passphrase, or null/'' for none. forCmms: adjust the embedded
// README + manifest.source for re-import through the CMMS editor.
async function buildDeviceZIP(pass, forCmms = false) {
const { BlobWriter, BlobReader, TextReader, ZipWriter } = zip;
const dev = deviceList.find(d => d.id === currentDeviceId);
const boms = await db.getProjectsByDevice(currentDeviceId);
@ -632,19 +634,21 @@ ENCRYPTED FILE NOTE:
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
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()}
${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.
${howTo}
${encNote}
`;
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) {
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.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) {
@ -738,13 +750,13 @@ function isValidLegacyData(data) {
return false;
}
async function importFile(f) {
async function importFile(f, opts = {}) {
if (!f) return;
const name = f.name.toLowerCase();
// 1. ZIP Import (Device Backup or Board Export)
if (name.endsWith('.zip')) {
await processZIP(f);
await processZIP(f, opts);
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;
try {
// 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).');
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.`))
if (opts.silent || confirm(`Restore Device: "${manifest.device.name}"${vMsg}?${sMsg}\n\nThis will merge boards and overwrite existing components.`))
await restoreDevice(manifest, imageMap);
return;
}
@ -920,7 +934,7 @@ async function processZIP(file) {
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?`)) {
if (opts.silent || confirm(`Import Board: "${data.meta.name}" into current Device?`)) {
data.meta.deviceId = currentDeviceId;
await processImportData(data, imageMap);
}
@ -3128,7 +3142,7 @@ async function importDeviceFromURL() {
}
}
async function processUrlImport(url) {
async function processUrlImport(url, opts = {}) {
if (!url) return;
showBusy("Downloading Device...");
@ -3160,12 +3174,15 @@ async function processUrlImport(url) {
hideBusy();
// 4. Handover to existing Import logic
await importFile(file);
await importFile(file, opts);
} catch (e) {
hideBusy();
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
let msg = `Import Failed.\n\nError: ${e.message}`;
if (e.name === 'TypeError' && e.message === 'Failed to fetch') {