feat: move revision tracking to ExportRecord, add perceptual hash comparison, cut list modal, and auto-start API

- Move Revision from Drawing to ExportRecord so each export captures its own revision snapshot
- Add Hamming distance comparison for perceptual hashes (tolerance of 10 bits) to avoid false revision bumps
- Replace CoenM.ImageHash with inline DifferenceHash impl (compatible with ImageSharp 3.x)
- Increase PDF render DPI from 72 to 150 for better hash fidelity
- Add download-dxfs-by-drawing endpoint for cross-export DXF zip downloads
- Prefix DXF filenames with equipment number when no drawing number is present
- Pass original filename to storage service for standalone part exports
- Auto-start FabWorks.Api from ExportDXF client if not already running
- Add cut list modal with copy-to-clipboard in the web UI
- Update PDF hash on existing export records after upload
- Bump static asset cache versions to v3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 15:48:28 -05:00
parent f6cd91f1b5
commit b9e84de7c0
20 changed files with 803 additions and 50 deletions
+95
View File
@@ -58,3 +58,98 @@ function toggleBomRow(id) {
row.style.display = visible ? 'none' : '';
if (icon) icon.classList.toggle('open', !visible);
}
/* ─── Cut List Modal ─── */
function showCutListModal(bomItems) {
const cutItems = bomItems.filter(b => b.cutTemplate);
if (cutItems.length === 0) {
showToast('No cut templates found');
return;
}
const rows = cutItems.map(b => {
const ct = b.cutTemplate;
const name = ct.cutTemplateName || ct.dxfFilePath?.split(/[/\\]/).pop()?.replace(/\.dxf$/i, '') || b.partName || '';
const qty = b.qty ?? '';
return { name, qty };
});
const tableRows = rows.map((r, i) => `
<tr style="animation: fadeSlideIn 0.15s ease ${0.02 * i}s forwards; opacity: 0">
<td style="font-family:var(--font-mono);font-weight:600">${esc(r.name)}</td>
<td style="font-family:var(--font-mono);text-align:center">${r.qty}</td>
</tr>`).join('');
// Remove existing modal if any
const existing = document.getElementById('cut-list-modal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.id = 'cut-list-modal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-panel">
<div class="modal-header">
<span>${icons.laser} Cut List</span>
<span class="badge badge-count">${cutItems.length} templates</span>
<span style="margin-left:auto;display:flex;gap:6px">
<button class="btn btn-cyan btn-sm" onclick="copyCutList()" id="copy-cut-list-btn">${icons.clipboard} Copy</button>
<button class="btn btn-sm" onclick="closeCutListModal()">${icons.close}</button>
</span>
</div>
<div class="modal-body">
<table>
<thead><tr>
<th>Name</th>
<th style="width:60px;text-align:center">Qty</th>
</tr></thead>
<tbody>${tableRows}</tbody>
</table>
</div>
</div>`;
document.body.appendChild(modal);
// Store data for copy
modal._cutData = rows;
// Close on backdrop click
modal.addEventListener('click', e => { if (e.target === modal) closeCutListModal(); });
// Close on Escape
modal._keyHandler = e => { if (e.key === 'Escape') closeCutListModal(); };
document.addEventListener('keydown', modal._keyHandler);
}
function closeCutListModal() {
const modal = document.getElementById('cut-list-modal');
if (!modal) return;
document.removeEventListener('keydown', modal._keyHandler);
modal.remove();
}
function copyCutList() {
const modal = document.getElementById('cut-list-modal');
if (!modal || !modal._cutData) return;
const text = modal._cutData.map(r => `${r.name}\t${r.qty}`).join('\n');
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copy-cut-list-btn');
if (btn) {
btn.innerHTML = `${icons.check} Copied!`;
btn.classList.remove('btn-cyan');
btn.classList.add('btn-green');
setTimeout(() => {
btn.innerHTML = `${icons.clipboard} Copy`;
btn.classList.remove('btn-green');
btn.classList.add('btn-cyan');
}, 2000);
}
});
}
function showToast(msg) {
const t = document.createElement('div');
t.className = 'toast';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2500);
}
+3
View File
@@ -10,6 +10,9 @@ const icons = {
laser: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--cyan)" stroke-width="1.2"><circle cx="8" cy="8" r="2"/><path d="M8 2v3M8 11v3M2 8h3M11 8h3" opacity="0.5"/></svg>`,
bend: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--amber)" stroke-width="1.2"><path d="M3 13V7a4 4 0 0 1 4-4h6"/><polyline points="10 6 13 3 10 0" transform="translate(0,2)"/></svg>`,
trash: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`,
clipboard: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="2" width="6" height="4" rx="1"/><path d="M9 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2h-2"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="15" y2="16"/></svg>`,
check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>`,
close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
};
function fileIcon(name) {
+8 -1
View File
@@ -213,6 +213,9 @@ const pages = {
allBom = [...bomByItem.values()];
}
// Store for cut list modal
window._currentBom = allBom;
const bomRows = allBom.map((b, i) => {
const hasDetails = b.cutTemplate || b.formProgram;
const toggleId = `dbom-${b.id}`;
@@ -265,8 +268,12 @@ const pages = {
${bomHeader}
<span class="badge badge-count">${allBom.length} items</span>
<span style="margin-left:auto;display:flex;gap:6px">
${dxfCount > 0 ? `<button class="btn btn-sm" onclick="showCutListModal(window._currentBom)">${icons.clipboard} Cut List</button>` : ''}
${pdfHash ? `<a class="btn btn-amber btn-sm" href="/api/filebrowser/download?hash=${encodeURIComponent(pdfHash)}&ext=pdf&name=${pdfName}">${icons.download} PDF</a>` : ''}
${dxfCount > 0 ? `<a class="btn btn-cyan btn-sm" href="/api/exports/${activeExport.id}/download-dxfs">${icons.download} All DXFs</a>` : ''}
${dxfCount > 0 ? (singleExport
? `<a class="btn btn-cyan btn-sm" href="/api/exports/${activeExport.id}/download-dxfs">${icons.download} All DXFs</a>`
: `<a class="btn btn-cyan btn-sm" href="/api/exports/download-dxfs?drawingNumber=${encodeURIComponent(drawingNumber)}">${icons.download} All DXFs</a>`
) : ''}
</span>
</div>
${allBom.length ? `