Files
ExportDXF/FabWorks.Api/Services/FileStorageService.cs
AJ Isaacs b9e84de7c0 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>
2026-02-25 15:48:28 -05:00

168 lines
6.7 KiB
C#

using FabWorks.Api.Configuration;
using FabWorks.Core.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace FabWorks.Api.Services
{
public class FileUploadResult
{
public string ContentHash { get; set; }
public string FileName { get; set; }
public bool WasUnchanged { get; set; }
public bool IsNewFile { get; set; }
}
public interface IFileStorageService
{
string OutputFolder { get; }
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);
}
public class FileStorageService : IFileStorageService
{
private readonly FileStorageOptions _options;
private readonly FabWorksDbContext _db;
public string OutputFolder => _options.OutputFolder;
public FileStorageService(IOptions<FileStorageOptions> options, FabWorksDbContext db)
{
_options = options.Value;
_db = db;
var blobRoot = Path.Combine(_options.OutputFolder, "blobs");
if (!Directory.Exists(blobRoot))
Directory.CreateDirectory(blobRoot);
}
public async Task<FileUploadResult> StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash, string originalFileName = null)
{
var fileName = BuildDxfFileName(drawingNo, equipment, itemNo, originalFileName);
// Look up previous hash by drawing number + item number
var drawingNumber = BuildDrawingNumber(equipment, drawingNo);
var previousHash = await _db.CutTemplates
.Where(c => c.BomItem.ExportRecord.DrawingNumber == drawingNumber
&& c.BomItem.ItemNo == itemNo
&& c.ContentHash != null)
.OrderByDescending(c => c.Id)
.Select(c => c.ContentHash)
.FirstOrDefaultAsync();
var wasUnchanged = previousHash != null && previousHash == contentHash;
var isNewFile = await StoreBlobAsync(stream, contentHash, "dxf");
return new FileUploadResult
{
ContentHash = contentHash,
FileName = fileName,
WasUnchanged = wasUnchanged,
IsNewFile = isNewFile
};
}
public async Task<FileUploadResult> StorePdfAsync(Stream stream, string equipment, string drawingNo, string contentHash, int? exportRecordId = null)
{
var drawingNumber = BuildDrawingNumber(equipment, drawingNo);
var fileName = $"{drawingNumber}.pdf";
// Look up previous PDF hash
var previousHash = await _db.ExportRecords
.Where(r => r.DrawingNumber == drawingNumber
&& r.PdfContentHash != null
&& (exportRecordId == null || r.Id != exportRecordId))
.OrderByDescending(r => r.Id)
.Select(r => r.PdfContentHash)
.FirstOrDefaultAsync();
var wasUnchanged = previousHash != null && previousHash == contentHash;
var isNewFile = await StoreBlobAsync(stream, contentHash, "pdf");
// Update the export record with the PDF content hash
if (exportRecordId.HasValue)
{
var record = await _db.ExportRecords.FindAsync(exportRecordId.Value);
if (record != null)
{
record.PdfContentHash = contentHash;
await _db.SaveChangesAsync();
}
}
return new FileUploadResult
{
ContentHash = contentHash,
FileName = fileName,
WasUnchanged = wasUnchanged,
IsNewFile = isNewFile
};
}
public Stream OpenBlob(string contentHash, string extension)
{
var path = GetBlobPath(contentHash, extension);
if (!File.Exists(path))
return null;
return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
}
public bool BlobExists(string contentHash, string extension)
{
return File.Exists(GetBlobPath(contentHash, extension));
}
private async Task<bool> StoreBlobAsync(Stream stream, string contentHash, string extension)
{
var blobPath = GetBlobPath(contentHash, extension);
if (File.Exists(blobPath))
return false; // blob already exists (dedup)
var dir = Path.GetDirectoryName(blobPath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
using var fileStream = new FileStream(blobPath, FileMode.Create, FileAccess.Write);
await stream.CopyToAsync(fileStream);
return true; // new blob written
}
private string GetBlobPath(string contentHash, string extension)
{
var prefix1 = contentHash[..2];
var prefix2 = contentHash[2..4];
return Path.Combine(_options.OutputFolder, "blobs", prefix1, prefix2, $"{contentHash}.{extension}");
}
private static string BuildDrawingNumber(string equipment, string drawingNo)
{
if (!string.IsNullOrEmpty(equipment) && !string.IsNullOrEmpty(drawingNo))
return $"{equipment} {drawingNo}";
if (!string.IsNullOrEmpty(equipment))
return equipment;
return drawingNo ?? "";
}
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))
return $"{drawingNumber} PT{paddedItem}.dxf";
return $"PT{paddedItem}.dxf";
}
}
}