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:
@@ -1,4 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Numerics;
|
||||
using FabWorks.Api.DTOs;
|
||||
using FabWorks.Api.Services;
|
||||
using FabWorks.Core.Data;
|
||||
@@ -215,8 +217,9 @@ namespace FabWorks.Api.Controllers
|
||||
|
||||
if (!string.IsNullOrEmpty(record.DrawingNumber) && !string.IsNullOrEmpty(request.PdfContentHash))
|
||||
{
|
||||
var drawing = await ResolveDrawingAsync(record.DrawingNumber, record.Title, request.PdfContentHash);
|
||||
record.DrawingId = drawing.Id;
|
||||
var (drawing, revision) = await ResolveDrawingAsync(record.DrawingNumber, record.Title, request.PdfContentHash);
|
||||
record.Drawing = drawing;
|
||||
record.DrawingRevision = revision;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
@@ -224,36 +227,42 @@ namespace FabWorks.Api.Controllers
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task<Drawing> ResolveDrawingAsync(string drawingNumber, string title, string pdfContentHash)
|
||||
private async Task<(Drawing drawing, int revision)> ResolveDrawingAsync(string drawingNumber, string title, string pdfContentHash)
|
||||
{
|
||||
var drawing = await _db.Drawings
|
||||
.FirstOrDefaultAsync(d => d.DrawingNumber == drawingNumber);
|
||||
|
||||
// Get the highest revision recorded for this drawing across all exports
|
||||
var lastRevision = await _db.ExportRecords
|
||||
.Where(r => r.DrawingNumber == drawingNumber && r.DrawingRevision != null)
|
||||
.OrderByDescending(r => r.DrawingRevision)
|
||||
.Select(r => r.DrawingRevision)
|
||||
.FirstOrDefaultAsync() ?? 0;
|
||||
|
||||
if (drawing == null)
|
||||
{
|
||||
drawing = new Drawing
|
||||
{
|
||||
DrawingNumber = drawingNumber,
|
||||
Title = title,
|
||||
PdfContentHash = pdfContentHash,
|
||||
Revision = 1
|
||||
PdfContentHash = pdfContentHash
|
||||
};
|
||||
_db.Drawings.Add(drawing);
|
||||
}
|
||||
else if (drawing.PdfContentHash != pdfContentHash)
|
||||
{
|
||||
drawing.PdfContentHash = pdfContentHash;
|
||||
drawing.Revision++;
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
drawing.Title = title;
|
||||
}
|
||||
// If hash matches, keep same revision (just update title if needed)
|
||||
else if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
drawing.Title = title;
|
||||
return (drawing, 1);
|
||||
}
|
||||
|
||||
return drawing;
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
drawing.Title = title;
|
||||
|
||||
if (ArePerceptualHashesSimilar(drawing.PdfContentHash, pdfContentHash))
|
||||
{
|
||||
// Hash unchanged — keep same revision
|
||||
return (drawing, lastRevision == 0 ? 1 : lastRevision);
|
||||
}
|
||||
|
||||
// Hash changed — bump revision and update stored hash
|
||||
drawing.PdfContentHash = pdfContentHash;
|
||||
return (drawing, lastRevision + 1);
|
||||
}
|
||||
|
||||
[HttpGet("previous-cut-template")]
|
||||
@@ -316,6 +325,37 @@ namespace FabWorks.Api.Controllers
|
||||
|
||||
if (dxfItems.Count == 0) return NotFound("No DXF files for this export.");
|
||||
|
||||
var zipName = $"{record.DrawingNumber ?? $"Export-{id}"} DXFs.zip";
|
||||
return BuildDxfZip(dxfItems, zipName);
|
||||
}
|
||||
|
||||
[HttpGet("download-dxfs")]
|
||||
public async Task<IActionResult> DownloadDxfsByDrawing([FromQuery] string drawingNumber)
|
||||
{
|
||||
if (string.IsNullOrEmpty(drawingNumber))
|
||||
return BadRequest("drawingNumber is required.");
|
||||
|
||||
var dxfItems = await _db.BomItems
|
||||
.Include(b => b.CutTemplate)
|
||||
.Where(b => b.ExportRecord.DrawingNumber == drawingNumber
|
||||
&& b.CutTemplate != null
|
||||
&& b.CutTemplate.ContentHash != null)
|
||||
.ToListAsync();
|
||||
|
||||
if (dxfItems.Count == 0) return NotFound("No DXF files for this drawing.");
|
||||
|
||||
// Deduplicate by content hash (keep latest)
|
||||
dxfItems = dxfItems
|
||||
.GroupBy(b => b.CutTemplate.ContentHash)
|
||||
.Select(g => g.Last())
|
||||
.ToList();
|
||||
|
||||
var zipName = $"{drawingNumber} DXFs.zip";
|
||||
return BuildDxfZip(dxfItems, zipName);
|
||||
}
|
||||
|
||||
private FileResult BuildDxfZip(List<BomItem> dxfItems, string zipName)
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
@@ -341,16 +381,41 @@ namespace FabWorks.Api.Controllers
|
||||
|
||||
var entry = zip.CreateEntry(fileName, CompressionLevel.Fastest);
|
||||
using var entryStream = entry.Open();
|
||||
await blobStream.CopyToAsync(entryStream);
|
||||
blobStream.CopyTo(entryStream);
|
||||
blobStream.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
ms.Position = 0;
|
||||
var zipName = $"{record.DrawingNumber ?? $"Export-{id}"} DXFs.zip";
|
||||
return File(ms, "application/zip", zipName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two perceptual hashes using Hamming distance.
|
||||
/// Perceptual hashes (16 hex chars / 64 bits) are compared with a tolerance
|
||||
/// of up to 10 differing bits (~84% similarity). SHA256 fallback hashes
|
||||
/// (64 hex chars) use exact comparison.
|
||||
/// </summary>
|
||||
private static bool ArePerceptualHashesSimilar(string hash1, string hash2)
|
||||
{
|
||||
if (hash1 == hash2) return true;
|
||||
if (string.IsNullOrEmpty(hash1) || string.IsNullOrEmpty(hash2)) return false;
|
||||
|
||||
// Perceptual hashes are 16 hex chars (64-bit DifferenceHash)
|
||||
// SHA256 fallback hashes are 64 hex chars — require exact match
|
||||
if (hash1.Length != 16 || hash2.Length != 16)
|
||||
return false;
|
||||
|
||||
if (ulong.TryParse(hash1, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var h1) &&
|
||||
ulong.TryParse(hash2, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var h2))
|
||||
{
|
||||
var hammingDistance = BitOperations.PopCount(h1 ^ h2);
|
||||
return hammingDistance <= 10;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ExportDetailDto MapToDto(ExportRecord r) => new()
|
||||
{
|
||||
Id = r.Id,
|
||||
|
||||
@@ -88,7 +88,7 @@ namespace FabWorks.Api.Controllers
|
||||
r.DrawingNumber,
|
||||
r.PdfContentHash,
|
||||
r.ExportedAt,
|
||||
DrawingRevision = r.Drawing != null ? (int?)r.Drawing.Revision : null
|
||||
r.DrawingRevision
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace FabWorks.Api.Controllers
|
||||
return BadRequest("No file uploaded.");
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _fileStorage.StoreDxfAsync(stream, equipment, drawingNo, itemNo, contentHash);
|
||||
var result = await _fileStorage.StoreDxfAsync(stream, equipment, drawingNo, itemNo, contentHash, file.FileName);
|
||||
|
||||
return Ok(new FileUploadResponse
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace FabWorks.Api.Services
|
||||
public interface IFileStorageService
|
||||
{
|
||||
string OutputFolder { get; }
|
||||
Task<FileUploadResult> StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash);
|
||||
Task<FileUploadResult> StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash, string originalFileName = null);
|
||||
Task<FileUploadResult> StorePdfAsync(Stream stream, string equipment, string drawingNo, string contentHash, int? exportRecordId = null);
|
||||
Stream OpenBlob(string contentHash, string extension);
|
||||
bool BlobExists(string contentHash, string extension);
|
||||
@@ -39,9 +39,9 @@ namespace FabWorks.Api.Services
|
||||
Directory.CreateDirectory(blobRoot);
|
||||
}
|
||||
|
||||
public async Task<FileUploadResult> StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash)
|
||||
public async Task<FileUploadResult> StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash, string originalFileName = null)
|
||||
{
|
||||
var fileName = BuildDxfFileName(drawingNo, equipment, itemNo);
|
||||
var fileName = BuildDxfFileName(drawingNo, equipment, itemNo, originalFileName);
|
||||
|
||||
// Look up previous hash by drawing number + item number
|
||||
var drawingNumber = BuildDrawingNumber(equipment, drawingNo);
|
||||
@@ -147,8 +147,16 @@ namespace FabWorks.Api.Services
|
||||
return drawingNo ?? "";
|
||||
}
|
||||
|
||||
private static string BuildDxfFileName(string drawingNo, string equipment, string itemNo)
|
||||
private static string BuildDxfFileName(string drawingNo, string equipment, string itemNo, string originalFileName = null)
|
||||
{
|
||||
// No drawing number: use the original filename from the client
|
||||
if (string.IsNullOrEmpty(drawingNo) && !string.IsNullOrEmpty(originalFileName))
|
||||
{
|
||||
return originalFileName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||
? originalFileName
|
||||
: originalFileName + ".dxf";
|
||||
}
|
||||
|
||||
var drawingNumber = BuildDrawingNumber(equipment, drawingNo);
|
||||
var paddedItem = (itemNo ?? "").PadLeft(2, '0');
|
||||
if (!string.IsNullOrEmpty(drawingNumber) && !string.IsNullOrEmpty(itemNo))
|
||||
|
||||
@@ -735,6 +735,85 @@ tbody tr:last-child td { border-bottom: none; }
|
||||
.equip-group.collapsed .equip-body { display: none; }
|
||||
.equip-group.collapsed .equip-header { border-radius: 6px; }
|
||||
|
||||
/* ─── Modal ─── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.modal-panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
width: 90%;
|
||||
max-width: 640px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: fadeSlideIn 0.2s ease forwards;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-header svg { width: 16px; height: 16px; }
|
||||
|
||||
.modal-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-green {
|
||||
background: var(--green-dim);
|
||||
color: var(--green);
|
||||
border-color: rgba(6, 118, 71, 0.25);
|
||||
}
|
||||
|
||||
.btn-green:hover {
|
||||
background: rgba(6, 118, 71, 0.15);
|
||||
border-color: rgba(6, 118, 71, 0.4);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* ─── Toast ─── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--text);
|
||||
color: #fff;
|
||||
padding: 8px 20px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
z-index: 300;
|
||||
animation: fadeSlideIn 0.2s ease, fadeOut 0.3s ease 2s forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeOut { to { opacity: 0; } }
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
|
||||
@@ -46,11 +46,11 @@
|
||||
<div class="page-content" id="page-content"></div>
|
||||
</div>
|
||||
|
||||
<script src="js/icons.js?v=2"></script>
|
||||
<script src="js/helpers.js?v=2"></script>
|
||||
<script src="js/components.js?v=2"></script>
|
||||
<script src="js/pages.js?v=2"></script>
|
||||
<script src="js/router.js?v=2"></script>
|
||||
<script src="js/icons.js?v=3"></script>
|
||||
<script src="js/helpers.js?v=3"></script>
|
||||
<script src="js/components.js?v=3"></script>
|
||||
<script src="js/pages.js?v=3"></script>
|
||||
<script src="js/router.js?v=3"></script>
|
||||
<script>router.init();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ? `
|
||||
|
||||
Reference in New Issue
Block a user