Compare commits

..

13 Commits

Author SHA1 Message Date
bd3e7c2a36 chore: remove .claude/settings.local.json from tracking
Local Claude Code settings are machine-specific and should not be
version controlled. Added .claude/ to .gitignore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:58:15 -05:00
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
f6cd91f1b5 docs: add design plans for API and auto-fill features
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:54:21 -05:00
3554bb6110 feat: show revision column in file browser
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:54:13 -05:00
4707e96359 feat: resolve drawing revisions on PDF upload
When a PDF hash is set on an export record, resolve or create the
associated Drawing and bump its revision if the content hash changed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:54:07 -05:00
c5bd7fb4c8 feat: add Drawing entity with revision tracking
Introduce a Drawing table that tracks unique drawing numbers with their
current PDF content hash and revision counter. ExportRecord gains a
DrawingId FK. Includes a data migration to seed Drawings from existing
export records.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:54:01 -05:00
13c61a82a4 fix: log etch line failures instead of silently swallowing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:53:51 -05:00
444a077cbc fix: resize form controls to match window layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:53:46 -05:00
c4920f933d chore: remove netDxf project from solution
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:53:39 -05:00
b472729fda feat: use perceptual hash for PDF change detection
Render PDF page 1 to an image and compute a DifferenceHash instead of
SHA256 on raw file bytes. This ignores metadata/timestamp changes that
SolidWorks varies between exports, preventing false revision bumps on
Drawing entities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:52:06 -05:00
5d2948d563 feat: replace text-based DXF hash with geometric content hash
SolidWorks re-exports produce files with identical geometry but different
entity ordering, handle assignments, style names, and floating-point
epsilon values. This caused hash mismatches and unnecessary API updates.

Uses ACadSharp to parse DXF entities and build canonical, sorted
signatures (LINE, ARC, CIRCLE, MTEXT) with coordinates rounded to 4
decimal places. Falls back to raw file hash if parsing fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:40:51 -05:00
71c65e0bf5 feat: auto-populate title from part description when opening documents
Adds a Description property to DrawingInfo that extracts the
descriptive text after the equipment/drawing number, excluding the
file extension. MainForm now sets the title box from this description
when no title was already set by the API history lookup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 05:53:08 -05:00
53aa23f762 refactor: consolidate export detail into drawing detail page
Remove the duplicate export detail page and route exports list
directly to drawing detail. When navigating from exports, the
specific export's BOM items are shown via eid param; from drawings,
items are deduplicated to the latest revision. Add Rev column,
PDF download, and All DXFs download to drawing detail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:26:43 -05:00
33 changed files with 3173 additions and 229 deletions

View File

@@ -1,10 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)"
],
"deny": [],
"ask": []
}
}

3
.gitignore vendored
View File

@@ -245,3 +245,6 @@ ModelManifest.xml
# Test documents
TestDocs/
# Claude Code local settings
.claude/

View File

@@ -1,14 +1,12 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29123.88
# Visual Studio Version 18
VisualStudioVersion = 18.3.11512.155 d18.3
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExportDXF", "ExportDXF\ExportDXF.csproj", "{05F21D73-FD31-4E77-8D9B-41C86D4D8305}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EtchBendLines", "EtchBendLines\EtchBendLines\EtchBendLines.csproj", "{229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "netDxf", "EtchBendLines\netDxf\netDxf\netDxf.csproj", "{785380E0-CEB9-4C34-82E5-60D0E33E848E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FabWorks.Core", "FabWorks.Core\FabWorks.Core.csproj", "{24547EE4-2EAA-4A6C-AD94-1117C038D8CD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FabWorks.Tests", "FabWorks.Tests\FabWorks.Tests.csproj", "{6DD89774-D86B-47E9-B982-2794BD95616A}"
@@ -49,18 +47,6 @@ Global
{229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Release|x64.Build.0 = Release|Any CPU
{229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Release|x86.ActiveCfg = Release|Any CPU
{229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Release|x86.Build.0 = Release|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Debug|x64.ActiveCfg = Debug|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Debug|x64.Build.0 = Debug|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Debug|x86.ActiveCfg = Debug|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Debug|x86.Build.0 = Debug|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Release|Any CPU.Build.0 = Release|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Release|x64.ActiveCfg = Release|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Release|x64.Build.0 = Release|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Release|x86.ActiveCfg = Release|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Release|x86.Build.0 = Release|Any CPU
{24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Debug|x64.ActiveCfg = Debug|Any CPU

View File

@@ -1,3 +1,4 @@
using System.IO;
using System.Text.RegularExpressions;
namespace ExportDXF
@@ -13,6 +14,35 @@ namespace ExportDXF
public string Source { get; set; }
/// <summary>
/// The descriptive text after the equipment/drawing number (e.g. "Prox switch bracket for drive").
/// </summary>
public string Description
{
get
{
if (string.IsNullOrEmpty(Source) || string.IsNullOrEmpty(EquipmentNo))
return null;
// Strip equipment number (and optional drawing number) from the source to get the description
var prefix = string.IsNullOrEmpty(DrawingNo)
? EquipmentNo
: EquipmentNo + " " + DrawingNo;
var desc = Source;
if (desc.StartsWith(prefix, System.StringComparison.OrdinalIgnoreCase))
desc = desc.Substring(prefix.Length);
// Remove file extension (e.g. ".SLDPRT")
var ext = Path.GetExtension(desc);
if (!string.IsNullOrEmpty(ext))
desc = desc.Substring(0, desc.Length - ext.Length);
desc = desc.Trim();
return string.IsNullOrEmpty(desc) ? null : desc;
}
}
public override string ToString()
{
if (string.IsNullOrEmpty(DrawingNo))

View File

@@ -14,6 +14,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="PDFtoImage" Version="4.1.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.0" />
</ItemGroup>

View File

@@ -114,16 +114,16 @@ namespace ExportDXF.Forms
logEventsDataGrid.GridColor = System.Drawing.Color.WhiteSmoke;
logEventsDataGrid.Location = new System.Drawing.Point(6, 6);
logEventsDataGrid.Name = "logEventsDataGrid";
logEventsDataGrid.Size = new System.Drawing.Size(890, 440);
logEventsDataGrid.Size = new System.Drawing.Size(890, 444);
logEventsDataGrid.TabIndex = 0;
//
// bomTab
//
bomTab.Controls.Add(bomDataGrid);
bomTab.Location = new System.Drawing.Point(4, 28);
bomTab.Location = new System.Drawing.Point(4, 30);
bomTab.Name = "bomTab";
bomTab.Padding = new System.Windows.Forms.Padding(3);
bomTab.Size = new System.Drawing.Size(902, 409);
bomTab.Size = new System.Drawing.Size(902, 458);
bomTab.TabIndex = 1;
bomTab.Text = "Bill Of Materials";
bomTab.UseVisualStyleBackColor = true;
@@ -135,16 +135,16 @@ namespace ExportDXF.Forms
bomDataGrid.GridColor = System.Drawing.Color.WhiteSmoke;
bomDataGrid.Location = new System.Drawing.Point(6, 6);
bomDataGrid.Name = "bomDataGrid";
bomDataGrid.Size = new System.Drawing.Size(1281, 644);
bomDataGrid.Size = new System.Drawing.Size(890, 444);
bomDataGrid.TabIndex = 1;
//
// cutTemplatesTab
//
cutTemplatesTab.Controls.Add(cutTemplatesDataGrid);
cutTemplatesTab.Location = new System.Drawing.Point(4, 28);
cutTemplatesTab.Location = new System.Drawing.Point(4, 30);
cutTemplatesTab.Name = "cutTemplatesTab";
cutTemplatesTab.Padding = new System.Windows.Forms.Padding(3);
cutTemplatesTab.Size = new System.Drawing.Size(902, 409);
cutTemplatesTab.Size = new System.Drawing.Size(902, 458);
cutTemplatesTab.TabIndex = 2;
cutTemplatesTab.Text = "Cut Templates";
cutTemplatesTab.UseVisualStyleBackColor = true;
@@ -156,7 +156,7 @@ namespace ExportDXF.Forms
cutTemplatesDataGrid.GridColor = System.Drawing.Color.WhiteSmoke;
cutTemplatesDataGrid.Location = new System.Drawing.Point(6, 6);
cutTemplatesDataGrid.Name = "cutTemplatesDataGrid";
cutTemplatesDataGrid.Size = new System.Drawing.Size(1281, 644);
cutTemplatesDataGrid.Size = new System.Drawing.Size(890, 447);
cutTemplatesDataGrid.TabIndex = 2;
//
// equipmentBox

View File

@@ -487,6 +487,9 @@ namespace ExportDXF.Forms
equipmentBox.Text = drawingInfo.EquipmentNo;
}
if (string.IsNullOrEmpty(titleBox.Text) && !string.IsNullOrEmpty(drawingInfo.Description))
titleBox.Text = drawingInfo.Description;
// Load drawings for the selected equipment, then set drawing number
await UpdateDrawingDropdownAsync();

View File

@@ -3,7 +3,10 @@ using ExportDXF.Forms;
using ExportDXF.Services;
using System;
using System.Configuration;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Windows.Forms;
namespace ExportDXF
@@ -44,6 +47,8 @@ namespace ExportDXF
var partExporter = new PartExporter();
var drawingExporter = new DrawingExporter();
EnsureApiRunning();
var httpClient = new HttpClient
{
BaseAddress = new Uri(_apiBaseUrl),
@@ -60,5 +65,52 @@ namespace ExportDXF
return new MainForm(solidWorksService, exportService, apiClient);
}
private void EnsureApiRunning()
{
// Check if API is already responding
using (var probe = new HttpClient { Timeout = TimeSpan.FromSeconds(2) })
{
try
{
var response = probe.GetAsync(_apiBaseUrl + "/api/exports?take=1").Result;
if (response.IsSuccessStatusCode)
return; // already running
}
catch { }
}
// Find the API executable relative to this assembly
var exeDir = AppContext.BaseDirectory;
var apiExe = Path.GetFullPath(Path.Combine(exeDir, @"..\..\..\FabWorks.Api\bin\Debug\net8.0\FabWorks.Api.exe"));
if (!File.Exists(apiExe))
return; // can't find it, skip
var startInfo = new ProcessStartInfo
{
FileName = apiExe,
WorkingDirectory = Path.GetDirectoryName(apiExe),
UseShellExecute = false,
CreateNoWindow = true
};
Process.Start(startInfo);
// Wait up to 10 seconds for API to become ready
using (var probe = new HttpClient { Timeout = TimeSpan.FromSeconds(2) })
{
for (int i = 0; i < 20; i++)
{
Thread.Sleep(500);
try
{
var response = probe.GetAsync(_apiBaseUrl + "/api/exports?take=1").Result;
if (response.IsSuccessStatusCode)
return;
}
catch { }
}
}
}
}
}

View File

@@ -207,7 +207,6 @@ namespace ExportDXF.Services
private async Task ExportDrawingAsync(ExportContext context, string drawingNumber, string tempDir)
{
LogProgress(context, "Active document is a Drawing");
LogProgress(context, "Finding BOM tables...");
var drawing = context.ActiveDocument.NativeDocument as DrawingDoc;
if (drawing == null)
@@ -216,16 +215,6 @@ namespace ExportDXF.Services
return;
}
var items = _bomExtractor.ExtractFromDrawing(drawing, context.ProgressCallback);
if (items == null || items.Count == 0)
{
LogProgress(context, "Error: Bill of materials not found.", LogLevel.Error);
return;
}
LogProgress(context, $"Found {items.Count} component(s)");
// Export drawing to PDF in temp dir
_drawingExporter.ExportToPdf(drawing, tempDir, context);
@@ -239,7 +228,7 @@ namespace ExportDXF.Services
if (pdfs.Length > 0)
{
var pdfTempPath = pdfs[0];
var pdfHash = ContentHasher.ComputeFileHash(pdfTempPath);
var pdfHash = ContentHasher.ComputePdfContentHash(pdfTempPath);
var uploadResult = await _apiClient.UploadPdfAsync(
pdfTempPath,
@@ -248,6 +237,9 @@ namespace ExportDXF.Services
pdfHash,
exportRecord?.Id);
if (exportRecord != null)
await _apiClient.UpdatePdfHashAsync(exportRecord.Id, pdfHash);
if (uploadResult != null)
{
if (uploadResult.WasUnchanged)
@@ -264,8 +256,74 @@ namespace ExportDXF.Services
LogProgress(context, $"PDF upload error: {ex.Message}", LogLevel.Error);
}
// Export parts to DXF and save BOM items
await ExportItemsAsync(items, tempDir, context, exportRecord?.Id);
// Extract BOM items from drawing tables
LogProgress(context, "Finding BOM tables...");
var items = _bomExtractor.ExtractFromDrawing(drawing, context.ProgressCallback);
if (items != null && items.Count > 0)
{
LogProgress(context, $"Found {items.Count} component(s)");
await ExportItemsAsync(items, tempDir, context, exportRecord?.Id);
}
else
{
// No BOM table — fall back to exporting the part referenced by the drawing views
LogProgress(context, "No BOM table found. Checking drawing views for referenced part...");
var (part, configuration) = GetReferencedPartFromViews(drawing);
if (part == null)
{
LogProgress(context, "No referenced part found in drawing views.", LogLevel.Warning);
return;
}
LogProgress(context, $"Found referenced part, exporting as single part...");
var item = _partExporter.ExportSinglePart(part, tempDir, context);
if (item != null)
{
if (!string.IsNullOrEmpty(configuration))
item.Configuration = configuration;
var existingItemNo = await FindExistingItemNoAsync(exportRecord?.Id, item.PartName, item.Configuration);
item.ItemNo = existingItemNo ?? await GetNextItemNumberAsync(drawingNumber);
var bomItem = new BomItem
{
ExportRecordId = exportRecord?.Id ?? 0,
ItemNo = item.ItemNo,
PartNo = item.FileName ?? item.PartName ?? "",
SortOrder = 0,
Qty = item.Quantity,
TotalQty = item.Quantity,
Description = item.Description ?? "",
PartName = item.PartName ?? "",
ConfigurationName = item.Configuration ?? "",
Material = item.Material ?? ""
};
if (!string.IsNullOrEmpty(item.LocalTempPath))
{
var uploadResult = await UploadDxfAsync(item, context);
if (uploadResult != null)
{
bomItem.CutTemplate = new CutTemplate
{
DxfFilePath = uploadResult.StoredFilePath,
ContentHash = item.ContentHash,
Thickness = item.Thickness > 0 ? item.Thickness : null,
KFactor = item.KFactor > 0 ? item.KFactor : null,
DefaultBendRadius = item.BendRadius > 0 ? item.BendRadius : null
};
}
}
context.BomItemCallback?.Invoke(bomItem);
if (exportRecord != null)
await SaveBomItemAsync(exportRecord.Id, bomItem, context);
}
}
}
#endregion
@@ -600,6 +658,24 @@ namespace ExportDXF.Services
throw new ArgumentException("ProgressCallback cannot be null.", nameof(context));
}
private (PartDoc part, string configuration) GetReferencedPartFromViews(DrawingDoc drawing)
{
var view = (IView)drawing.GetFirstView();
// First view is the sheet itself — skip it
view = (IView)view.GetNextView();
while (view != null)
{
var doc = view.ReferencedDocument;
if (doc is PartDoc part)
return (part, view.ReferencedConfiguration);
view = (IView)view.GetNextView();
}
return (null, null);
}
private void LogProgress(ExportContext context, string message, LogLevel level = LogLevel.Info, string file = null)
{
context.ProgressCallback?.Invoke(message, level, file);

View File

@@ -54,7 +54,7 @@ namespace ExportDXF.Services
try
{
var fileName = GetSinglePartFileName(model, context.FilePrefix);
var fileName = GetSinglePartFileName(model, context.Equipment);
var savePath = Path.Combine(saveDirectory, fileName + ".dxf");
// Build result item with metadata
@@ -139,7 +139,7 @@ namespace ExportDXF.Services
EnrichItemWithMetadata(item, model, part);
var fileName = GetItemFileName(item, context.FilePrefix);
var fileName = GetItemFileName(item, context);
var savePath = Path.Combine(saveDirectory, fileName + ".dxf");
var templateDrawing = context.GetOrCreateTemplateDrawing();
@@ -234,7 +234,7 @@ namespace ExportDXF.Services
var drawingModel = templateDrawing as ModelDoc2;
drawingModel.SaveAs(savePath);
AddEtchLines(savePath);
AddEtchLines(savePath, context);
context.ProgressCallback?.Invoke($"Saved to \"{savePath}\"", LogLevel.Info, partTitle);
@@ -301,7 +301,7 @@ namespace ExportDXF.Services
drawing.DeleteSelection(false);
}
private void AddEtchLines(string dxfPath)
private void AddEtchLines(string dxfPath, ExportContext context)
{
try
{
@@ -309,9 +309,9 @@ namespace ExportDXF.Services
etcher.AddEtchLines(dxfPath);
FixDegreeSymbol(dxfPath);
}
catch (Exception)
catch (Exception ex)
{
// Silently fail if etch lines can't be added
context.ProgressCallback?.Invoke($"Etch lines failed: {ex.Message}", LogLevel.Warning, Path.GetFileName(dxfPath));
}
}
@@ -332,23 +332,33 @@ namespace ExportDXF.Services
}
}
private string GetSinglePartFileName(ModelDoc2 model, string prefix)
private string GetSinglePartFileName(ModelDoc2 model, string equipment)
{
var title = model.GetTitle().Replace(".SLDPRT", "");
var config = model.ConfigurationManager.ActiveConfiguration.Name;
var isDefaultConfig = string.Equals(config, "default", StringComparison.OrdinalIgnoreCase);
return isDefaultConfig ? title : $"{title} [{config}]";
var name = isDefaultConfig ? title : $"{title} [{config}]";
return string.IsNullOrWhiteSpace(equipment) ? name : $"{equipment} {name}";
}
private string GetItemFileName(Item item, string prefix)
private string GetItemFileName(Item item, ExportContext context)
{
if (string.IsNullOrWhiteSpace(context.DrawingNo))
{
// No drawing number: preserve part name, prefix with EquipmentNo
var equipment = context.Equipment;
return string.IsNullOrWhiteSpace(equipment)
? item.PartName
: $"{equipment} {item.PartName}";
}
if (string.IsNullOrWhiteSpace(item.ItemNo))
return item.PartName;
prefix = prefix?.Replace("\"", "''") ?? string.Empty;
var prefix = context.FilePrefix?.Replace("\"", "''") ?? string.Empty;
var num = item.ItemNo.PadLeft(2, '0');
// Expected format: {DrawingNo} PT{ItemNo}
// Expected format: {EquipNo} {DrawingNo} PT{ItemNo}
return string.IsNullOrWhiteSpace(prefix)
? $"PT{num}"
: $"{prefix} PT{num}";

View File

@@ -1,26 +1,65 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using ACadSharp.Entities;
using ACadSharp.IO;
using PDFtoImage;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace ExportDXF.Utilities
{
public static class ContentHasher
{
/// <summary>
/// Computes a SHA256 hash of DXF file content, skipping the HEADER section
/// which contains timestamps that change on every save.
/// Computes a SHA256 hash of DXF geometry, ignoring entity ordering,
/// handle assignments, style names, and floating-point epsilon differences
/// that SolidWorks changes between re-exports of identical geometry.
/// Falls back to a raw file hash if ACadSharp parsing fails.
/// </summary>
public static string ComputeDxfContentHash(string filePath)
{
var text = File.ReadAllText(filePath);
var contentStart = FindEndOfHeader(text);
var content = contentStart >= 0 ? text.Substring(contentStart) : text;
using (var sha = SHA256.Create())
try
{
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(content));
return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
return ComputeGeometricHash(filePath);
}
catch
{
return ComputeFileHash(filePath);
}
}
/// <summary>
/// Computes a perceptual hash of a PDF by rendering page 1 to an image,
/// so only visual changes affect the hash (metadata/timestamp changes are ignored).
/// Falls back to a raw file hash if rendering fails.
/// </summary>
public static string ComputePdfContentHash(string filePath)
{
try
{
using (var pdfStream = File.OpenRead(filePath))
using (var pngStream = new MemoryStream())
{
Conversion.SavePng(pngStream, pdfStream, page: 0,
options: new RenderOptions(Dpi: 150));
pngStream.Position = 0;
using (var image = Image.Load<Rgba32>(pngStream))
{
var hash = ComputeDifferenceHash(image);
return hash.ToString("x16");
}
}
}
catch
{
return ComputeFileHash(filePath);
}
}
@@ -38,81 +77,126 @@ namespace ExportDXF.Utilities
}
/// <summary>
/// Finds the position immediately after the HEADER section's ENDSEC marker.
/// DXF HEADER format:
/// 0\nSECTION\n2\nHEADER\n...variables...\n0\nENDSEC\n
/// Returns -1 if no HEADER section is found.
/// DifferenceHash: resize to 9x8 grayscale, compare adjacent pixels.
/// Produces a 64-bit hash. Implemented directly against ImageSharp 3.x API
/// (CoenM.ImageHash uses the removed GetPixelRowSpan from ImageSharp 2.x).
/// </summary>
private static int FindEndOfHeader(string text)
private static ulong ComputeDifferenceHash(Image<Rgba32> image)
{
// Find the HEADER section start
var headerIndex = FindGroupCode(text, 0, "2", "HEADER");
if (headerIndex < 0)
return -1;
// Resize to 9 wide x 8 tall for 8x8 = 64 bit comparisons
image.Mutate(ctx => ctx.Resize(9, 8));
// Advance past the HEADER value line so pair scanning stays aligned
var headerLineEnd = text.IndexOf('\n', headerIndex);
if (headerLineEnd < 0)
return -1;
ulong hash = 0;
int bit = 0;
// Find the ENDSEC that closes the HEADER section
var pos = headerLineEnd + 1;
while (pos < text.Length)
for (int y = 0; y < 8; y++)
{
var endsecIndex = FindGroupCode(text, pos, "0", "ENDSEC");
if (endsecIndex < 0)
return -1;
for (int x = 0; x < 8; x++)
{
var left = image[x, y];
var right = image[x + 1, y];
var leftGray = 0.299 * left.R + 0.587 * left.G + 0.114 * left.B;
var rightGray = 0.299 * right.R + 0.587 * right.G + 0.114 * right.B;
// Move past the ENDSEC line
var lineEnd = text.IndexOf('\n', endsecIndex);
return lineEnd >= 0 ? lineEnd + 1 : text.Length;
if (leftGray > rightGray)
hash |= (1UL << bit);
bit++;
}
}
return -1;
return hash;
}
/// <summary>
/// Finds a DXF group code pair (code line followed by value line) starting from the given position.
/// Returns the position of the value line, or -1 if not found.
/// </summary>
private static int FindGroupCode(string text, int startIndex, string groupCode, string value)
private static string ComputeGeometricHash(string filePath)
{
var pos = startIndex;
while (pos < text.Length)
using (var reader = new DxfReader(filePath))
{
// Skip whitespace/newlines to find the group code
while (pos < text.Length && (text[pos] == '\r' || text[pos] == '\n' || text[pos] == ' '))
pos++;
var doc = reader.Read();
var signatures = new List<string>();
if (pos >= text.Length)
break;
foreach (var entity in doc.Entities)
{
signatures.Add(GetEntitySignature(entity));
}
// Read the group code line
var codeLineEnd = text.IndexOf('\n', pos);
if (codeLineEnd < 0)
break;
signatures.Sort(StringComparer.Ordinal);
var combined = string.Join("\n", signatures);
var codeLine = text.Substring(pos, codeLineEnd - pos).Trim();
using (var sha = SHA256.Create())
{
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(combined));
return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
}
}
}
// Move to the value line
var valueStart = codeLineEnd + 1;
if (valueStart >= text.Length)
break;
private static string GetEntitySignature(Entity entity)
{
var layer = entity.Layer?.Name ?? "";
var valueLineEnd = text.IndexOf('\n', valueStart);
if (valueLineEnd < 0)
valueLineEnd = text.Length;
switch (entity)
{
case Line line:
return GetLineSignature(line, layer);
case Arc arc:
return GetArcSignature(arc, layer);
case Circle circle:
return GetCircleSignature(circle, layer);
case MText mtext:
return GetMTextSignature(mtext, layer);
default:
return $"{entity.GetType().Name}|{layer}";
}
}
var valueLine = text.Substring(valueStart, valueLineEnd - valueStart).Trim();
private static string GetLineSignature(Line line, string layer)
{
var p1 = FormatPoint(line.StartPoint.X, line.StartPoint.Y);
var p2 = FormatPoint(line.EndPoint.X, line.EndPoint.Y);
if (codeLine == groupCode && string.Equals(valueLine, value, StringComparison.OrdinalIgnoreCase))
return valueStart;
// Move to the next pair
pos = valueLineEnd + 1;
// Normalize endpoint order so direction doesn't affect the hash
if (string.Compare(p1, p2, StringComparison.Ordinal) > 0)
{
var tmp = p1;
p1 = p2;
p2 = tmp;
}
return -1;
return $"LINE|{layer}|{p1}|{p2}";
}
private static string GetArcSignature(Arc arc, string layer)
{
var center = FormatPoint(arc.Center.X, arc.Center.Y);
var r = R(arc.Radius);
var sa = R(arc.StartAngle);
var ea = R(arc.EndAngle);
return $"ARC|{layer}|{center}|{r}|{sa}|{ea}";
}
private static string GetCircleSignature(Circle circle, string layer)
{
var center = FormatPoint(circle.Center.X, circle.Center.Y);
var r = R(circle.Radius);
return $"CIRCLE|{layer}|{center}|{r}";
}
private static string GetMTextSignature(MText mtext, string layer)
{
var point = FormatPoint(mtext.InsertPoint.X, mtext.InsertPoint.Y);
var text = mtext.Value ?? "";
return $"MTEXT|{layer}|{point}|{text}";
}
private static string R(double value)
{
return Math.Round(value, 4).ToString(CultureInfo.InvariantCulture);
}
private static string FormatPoint(double x, double y)
{
return $"{R(x)},{R(y)}";
}
}
}

View File

@@ -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;
@@ -212,11 +214,57 @@ namespace FabWorks.Api.Controllers
if (record == null) return NotFound();
record.PdfContentHash = request.PdfContentHash;
if (!string.IsNullOrEmpty(record.DrawingNumber) && !string.IsNullOrEmpty(request.PdfContentHash))
{
var (drawing, revision) = await ResolveDrawingAsync(record.DrawingNumber, record.Title, request.PdfContentHash);
record.Drawing = drawing;
record.DrawingRevision = revision;
}
await _db.SaveChangesAsync();
return NoContent();
}
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
};
_db.Drawings.Add(drawing);
return (drawing, 1);
}
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")]
public async Task<ActionResult<CutTemplateDto>> GetPreviousCutTemplate(
[FromQuery] string drawingNumber,
@@ -277,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))
{
@@ -302,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,

View File

@@ -38,6 +38,7 @@ namespace FabWorks.Api.Controllers
c.DxfFilePath,
c.ContentHash,
c.Thickness,
c.Revision,
DrawingNumber = c.BomItem.ExportRecord.DrawingNumber,
CreatedAt = c.BomItem.ExportRecord.ExportedAt
});
@@ -70,6 +71,7 @@ namespace FabWorks.Api.Controllers
FileType = "dxf",
DrawingNumber = c.DrawingNumber,
Thickness = c.Thickness,
Revision = c.Revision,
CreatedAt = c.CreatedAt
});
}
@@ -85,7 +87,8 @@ namespace FabWorks.Api.Controllers
r.Id,
r.DrawingNumber,
r.PdfContentHash,
r.ExportedAt
r.ExportedAt,
r.DrawingRevision
});
if (!string.IsNullOrWhiteSpace(search))
@@ -113,6 +116,7 @@ namespace FabWorks.Api.Controllers
ContentHash = r.PdfContentHash,
FileType = "pdf",
DrawingNumber = r.DrawingNumber,
Revision = r.DrawingRevision,
CreatedAt = r.ExportedAt
});
}
@@ -179,6 +183,7 @@ namespace FabWorks.Api.Controllers
public string FileType { get; set; }
public string DrawingNumber { get; set; }
public double? Thickness { get; set; }
public int? Revision { get; set; }
public DateTime CreatedAt { get; set; }
}
}

View File

@@ -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
{

View File

@@ -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))

View File

@@ -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; }

View File

@@ -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>

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);
}

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) {

View File

@@ -32,7 +32,7 @@ const pages = {
setPage('Exports', `${data.items.length} exports`);
const rows = data.items.map((e, i) => `
<tr class="clickable" onclick="router.go('export-detail', {id: ${e.id}})" style="animation: fadeSlideIn 0.2s ease ${0.02 * Math.min(i, 25)}s forwards; opacity: 0">
<tr class="clickable" onclick="router.go('drawing-detail', {id: '${encodeURIComponent(e.drawingNumber)}', eid: '${e.id}'})" style="animation: fadeSlideIn 0.2s ease ${0.02 * Math.min(i, 25)}s forwards; opacity: 0">
<td style="font-family:var(--font-mono);color:var(--text-dim);font-size:13px">${e.id}</td>
<td><strong>${esc(e.drawingNumber) || '<span style="color:var(--text-dim)">\u2014</span>'}</strong></td>
<td style="color:var(--text-secondary);font-size:13px">${esc(e.title) || ''}</td>
@@ -62,85 +62,6 @@ const pages = {
}
},
async exportDetail(id) {
const actions = document.getElementById('topbar-actions');
const content = document.getElementById('page-content');
setPage('Loading...');
actions.innerHTML = '';
content.innerHTML = `<div class="loading">Loading export</div>`;
try {
const exp = await api.get(`/api/exports/${id}`);
setPage(exp.drawingNumber || `Export #${exp.id}`, 'export detail');
const dxfCount = (exp.bomItems || []).filter(b => b.cutTemplate?.contentHash).length;
const bomRows = (exp.bomItems || []).map((b, i) => {
const hasDetails = b.cutTemplate || b.formProgram;
const toggleId = `bom-${b.id}`;
return `
<tr class="${hasDetails ? 'clickable' : ''}" ${hasDetails ? `onclick="toggleBomRow('${toggleId}')"` : ''} style="animation: fadeSlideIn 0.25s ease ${0.03 * i}s forwards; opacity: 0">
<td style="width:32px">${hasDetails ? `<span class="chevron-toggle" id="${toggleId}-icon">${icons.chevron}</span>` : ''}</td>
<td style="font-family:var(--font-mono);font-weight:600;color:var(--cyan)">${esc(b.itemNo)}</td>
<td><strong>${esc(b.partName)}</strong></td>
<td style="color:var(--text-secondary)">${esc(b.description)}</td>
<td><span style="font-family:var(--font-mono);font-size:13px">${esc(b.material)}</span></td>
<td style="font-family:var(--font-mono);text-align:center">${b.qty ?? ''}</td>
<td style="font-family:var(--font-mono);text-align:center">${b.totalQty ?? ''}</td>
<td>
${b.cutTemplate ? `<span class="badge badge-cyan">${icons.laser} DXF</span>` : ''}
${b.formProgram ? `<span class="badge badge-amber">${icons.bend} Form</span>` : ''}
</td>
</tr>
${hasDetails ? `<tr class="bom-expand-row" id="${toggleId}" style="display:none"><td colspan="8">${renderBomDetails(b)}</td></tr>` : ''}`;
}).join('');
content.innerHTML = `
<a class="back-link" onclick="router.go('exports')">${icons.back} Back to exports</a>
<div class="card animate-in" style="margin-bottom:20px">
<div class="card-header">Export Information</div>
<div class="card-body">
<div class="detail-grid">
<div class="detail-field"><label>Drawing Number</label><div class="value">${esc(exp.drawingNumber) || '\u2014'}</div></div>
${exp.title ? `<div class="detail-field"><label>Title</label><div class="value">${esc(exp.title)}</div></div>` : ''}
<div class="detail-field"><label>Exported By</label><div class="value">${esc(exp.exportedBy)}</div></div>
<div class="detail-field"><label>Date</label><div class="value mono">${fmtDate(exp.exportedAt)}</div></div>
<div class="detail-field"><label>Source File</label><div class="value mono">${esc(exp.sourceFilePath)}</div></div>
</div>
</div>
</div>
<div class="card animate-in">
<div class="card-header">
BOM Items
<span class="badge badge-count">${exp.bomItems?.length || 0} items</span>
<span style="margin-left:auto;display:flex;gap:6px">
${exp.pdfContentHash ? `<a class="btn btn-amber btn-sm" href="/api/filebrowser/download?hash=${encodeURIComponent(exp.pdfContentHash)}&ext=pdf&name=${encodeURIComponent((exp.drawingNumber || 'drawing') + '.pdf')}">${icons.download} PDF</a>` : ''}
${dxfCount > 0 ? `<a class="btn btn-cyan btn-sm" href="/api/exports/${exp.id}/download-dxfs">${icons.download} All DXFs</a>` : ''}
<button class="btn btn-red btn-sm" onclick="deleteExport(${exp.id})">${icons.trash} Delete</button>
</span>
</div>
${exp.bomItems?.length ? `
<table>
<thead><tr>
<th style="width:32px"></th>
<th style="width:60px">Item</th>
<th>Part Name</th>
<th>Description</th>
<th>Material</th>
<th style="width:50px;text-align:center">Qty</th>
<th style="width:55px;text-align:center">Total</th>
<th style="width:120px">Data</th>
</tr></thead>
<tbody>${bomRows}</tbody>
</table>` : '<div class="empty">No BOM items for this export.</div>'}
</div>`;
} catch (err) {
content.innerHTML = `<div class="empty">Error: ${esc(err.message)}</div>`;
}
},
async drawings(params) {
const actions = document.getElementById('topbar-actions');
const content = document.getElementById('page-content');
@@ -255,8 +176,9 @@ const pages = {
}
},
async drawingDetail(drawingEncoded) {
async drawingDetail(drawingEncoded, params) {
const drawingNumber = decodeURIComponent(drawingEncoded);
const exportId = params?.eid ? parseInt(params.eid) : null;
const actions = document.getElementById('topbar-actions');
const content = document.getElementById('page-content');
setPage(drawingNumber, 'drawing');
@@ -273,12 +195,26 @@ const pages = {
return;
}
const allBom = [];
exports.forEach(exp => {
(exp.bomItems || []).forEach(b => {
allBom.push({ ...b, exportId: exp.id, exportedAt: exp.exportedAt });
let allBom;
const singleExport = exportId ? exports.find(e => e.id === exportId) : null;
if (singleExport) {
// Viewing a specific export - show only its BOM items
allBom = (singleExport.bomItems || []).map(b => ({ ...b, exportId: singleExport.id, exportedAt: singleExport.exportedAt }));
} else {
// Viewing drawing overview - deduplicate by itemNo, keeping latest revision (exports are newest-first)
const bomByItem = new Map();
exports.forEach(exp => {
(exp.bomItems || []).forEach(b => {
if (!bomByItem.has(b.itemNo)) {
bomByItem.set(b.itemNo, { ...b, exportId: exp.id, exportedAt: exp.exportedAt });
}
});
});
});
allBom = [...bomByItem.values()];
}
// Store for cut list modal
window._currentBom = allBom;
const bomRows = allBom.map((b, i) => {
const hasDetails = b.cutTemplate || b.formProgram;
@@ -288,6 +224,7 @@ const pages = {
<td style="width:32px">${hasDetails ? `<span class="chevron-toggle" id="${toggleId}-icon">${icons.chevron}</span>` : ''}</td>
<td style="font-family:var(--font-mono);font-weight:600;color:var(--cyan)">${esc(b.itemNo)}</td>
<td><strong>${esc(b.partName)}</strong></td>
<td style="font-family:var(--font-mono);text-align:center;font-size:13px">${b.cutTemplate?.revision ?? ''}</td>
<td style="color:var(--text-secondary)">${esc(b.description)}</td>
<td><span style="font-family:var(--font-mono);font-size:13px">${esc(b.material)}</span></td>
<td style="font-family:var(--font-mono);text-align:center">${b.qty ?? ''}</td>
@@ -297,22 +234,47 @@ const pages = {
${b.formProgram ? `<span class="badge badge-amber">${icons.bend} Form</span>` : ''}
</td>
</tr>
${hasDetails ? `<tr class="bom-expand-row" id="${toggleId}" style="display:none"><td colspan="8">${renderBomDetails(b)}</td></tr>` : ''}`;
${hasDetails ? `<tr class="bom-expand-row" id="${toggleId}" style="display:none"><td colspan="9">${renderBomDetails(b)}</td></tr>` : ''}`;
}).join('');
content.innerHTML = `
<a class="back-link" onclick="router.go('drawings')">${icons.back} Back to drawings</a>
const backLink = singleExport
? `<a class="back-link" onclick="router.go('exports')">${icons.back} Back to exports</a>`
: `<a class="back-link" onclick="router.go('drawings')">${icons.back} Back to drawings</a>`;
<div class="stats-grid">
const statsHtml = singleExport
? `<div class="stats-grid">
<div class="stat-card animate-in"><div class="stat-label">Exported By</div><div class="stat-value stat-sm">${esc(singleExport.exportedBy)}</div></div>
<div class="stat-card animate-in"><div class="stat-label">BOM Items</div><div class="stat-value">${allBom.length}</div></div>
<div class="stat-card animate-in"><div class="stat-label">Exported</div><div class="stat-value stat-sm">${fmtDate(singleExport.exportedAt)}</div></div>
</div>`
: `<div class="stats-grid">
<div class="stat-card animate-in"><div class="stat-label">Exports</div><div class="stat-value">${exports.length}</div></div>
<div class="stat-card animate-in"><div class="stat-label">BOM Items</div><div class="stat-value">${allBom.length}</div></div>
<div class="stat-card animate-in"><div class="stat-label">Latest Export</div><div class="stat-value stat-sm">${fmtDate(exports[0].exportedAt)}</div></div>
</div>
</div>`;
const bomHeader = singleExport ? 'BOM Items' : 'All BOM Items';
const activeExport = singleExport || exports[0];
const dxfCount = allBom.filter(b => b.cutTemplate?.contentHash).length;
const pdfHash = activeExport.pdfContentHash;
const pdfName = encodeURIComponent((drawingNumber || 'drawing') + '.pdf');
content.innerHTML = `
${backLink}
${statsHtml}
<div class="card animate-in">
<div class="card-header">
All BOM Items
${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 ? (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 ? `
<table>
@@ -320,6 +282,7 @@ const pages = {
<th style="width:32px"></th>
<th style="width:60px">Item</th>
<th>Part Name</th>
<th style="width:45px;text-align:center">Rev</th>
<th>Description</th>
<th>Material</th>
<th style="width:50px;text-align:center">Qty</th>
@@ -397,6 +360,7 @@ const pages = {
<td><div class="file-name-cell">${ext === 'pdf' ? icons.filePdf : icons.fileDxf}<a href="/api/filebrowser/download?hash=${encodeURIComponent(f.contentHash)}&ext=${ext}&name=${encodeURIComponent(f.fileName)}">${esc(f.fileName)}</a></div></td>
<td><span class="badge ${ext === 'dxf' ? 'badge-cyan' : 'badge-amber'}">${ext.toUpperCase()}</span></td>
<td style="color:var(--text-secondary)">${esc(f.drawingNumber)}</td>
<td style="font-family:var(--font-mono);font-size:13px;text-align:center;color:var(--text-secondary)">${f.revision != null ? f.revision : '\u2014'}</td>
<td style="font-family:var(--font-mono);font-size:13px;color:var(--text-secondary)">${f.thickness != null ? f.thickness.toFixed(4) + '"' : '\u2014'}</td>
<td style="font-family:var(--font-mono);font-size:13px;color:var(--text-secondary)">${fmtDate(f.createdAt)}</td>
<td style="font-family:var(--font-mono);font-size:12px;color:var(--text-dim)">${esc(hashShort)}</td>
@@ -413,6 +377,7 @@ const pages = {
<th>Name</th>
<th style="width:60px">Type</th>
<th>Drawing</th>
<th style="width:45px;text-align:center">Rev</th>
<th style="width:90px">Thickness</th>
<th style="width:170px">Date</th>
<th style="width:100px">Hash</th>

View File

@@ -1,6 +1,9 @@
const router = {
go(page, params = {}) {
const hash = page + (params.id ? '/' + params.id : '') + (params.q ? '?q=' + encodeURIComponent(params.q) : '');
const qParts = [];
if (params.q) qParts.push('q=' + encodeURIComponent(params.q));
if (params.eid) qParts.push('eid=' + encodeURIComponent(params.eid));
const hash = page + (params.id ? '/' + params.id : '') + (qParts.length ? '?' + qParts.join('&') : '');
location.hash = hash;
},
parse() {
@@ -20,12 +23,10 @@ const router = {
document.querySelectorAll('.nav-item').forEach(el => {
el.classList.toggle('active',
el.dataset.page === page ||
(page === 'export-detail' && el.dataset.page === 'exports') ||
(page === 'drawing-detail' && el.dataset.page === 'drawings'));
});
switch(page) {
case 'exports': pages.exports(params); break;
case 'export-detail': pages.exportDetail(id); break;
case 'drawings': pages.drawings(params); break;
case 'drawing-detail': pages.drawingDetail(id, params); break;
case 'files': pages.files(params); break;

View File

@@ -9,6 +9,7 @@ namespace FabWorks.Core.Data
public DbSet<BomItem> BomItems { get; set; }
public DbSet<CutTemplate> CutTemplates { get; set; }
public DbSet<FormProgram> FormPrograms { get; set; }
public DbSet<Drawing> Drawings { get; set; }
public FabWorksDbContext(DbContextOptions<FabWorksDbContext> options) : base(options) { }
@@ -32,6 +33,11 @@ namespace FabWorks.Core.Data
.WithOne(b => b.ExportRecord)
.HasForeignKey(b => b.ExportRecordId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Drawing)
.WithMany(d => d.ExportRecords)
.HasForeignKey(e => e.DrawingId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<BomItem>(entity =>
@@ -74,6 +80,15 @@ namespace FabWorks.Core.Data
entity.Property(e => e.LowerToolNames).HasMaxLength(500);
entity.Property(e => e.SetupNotes).HasMaxLength(2000);
});
modelBuilder.Entity<Drawing>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.DrawingNumber).HasMaxLength(100);
entity.Property(e => e.Title).HasMaxLength(200);
entity.Property(e => e.PdfContentHash).HasMaxLength(64);
entity.HasIndex(e => e.DrawingNumber).IsUnique();
});
}
}
}

View File

@@ -0,0 +1,325 @@
// <auto-generated />
using System;
using FabWorks.Core.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FabWorks.Core.Migrations
{
[DbContext(typeof(FabWorksDbContext))]
[Migration("20260220125334_AddDrawingEntity")]
partial class AddDrawingEntity
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("FabWorks.Core.Models.BomItem", b =>
{
b.Property<int>("ID")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("ID"));
b.Property<string>("ConfigurationName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("ExportRecordId")
.HasColumnType("int");
b.Property<string>("ItemNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Material")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("PartName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("PartNo")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("Qty")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int?>("TotalQty")
.HasColumnType("int");
b.HasKey("ID");
b.HasIndex("ExportRecordId");
b.ToTable("BomItems");
});
modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("BomItemId")
.HasColumnType("int");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("CutTemplateName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<double?>("DefaultBendRadius")
.HasColumnType("float");
b.Property<string>("DxfFilePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<double?>("KFactor")
.HasColumnType("float");
b.Property<int>("Revision")
.HasColumnType("int");
b.Property<double?>("Thickness")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("BomItemId")
.IsUnique();
b.ToTable("CutTemplates");
});
modelBuilder.Entity("FabWorks.Core.Models.Drawing", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("DrawingNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("PdfContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int>("Revision")
.HasColumnType("int");
b.Property<string>("Title")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("DrawingNumber")
.IsUnique()
.HasFilter("[DrawingNumber] IS NOT NULL");
b.ToTable("Drawings");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("DrawingId")
.HasColumnType("int");
b.Property<string>("DrawingNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("DrawingNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("EquipmentNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("ExportedAt")
.HasColumnType("datetime2");
b.Property<string>("ExportedBy")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("OutputFolder")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("PdfContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("SourceFilePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Title")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("DrawingId");
b.ToTable("ExportRecords");
});
modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("BendCount")
.HasColumnType("int");
b.Property<int>("BomItemId")
.HasColumnType("int");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<double?>("KFactor")
.HasColumnType("float");
b.Property<string>("LowerToolNames")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("MaterialType")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ProgramFilePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("ProgramName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("SetupNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<double?>("Thickness")
.HasColumnType("float");
b.Property<string>("UpperToolNames")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.HasIndex("BomItemId")
.IsUnique();
b.ToTable("FormPrograms");
});
modelBuilder.Entity("FabWorks.Core.Models.BomItem", b =>
{
b.HasOne("FabWorks.Core.Models.ExportRecord", "ExportRecord")
.WithMany("BomItems")
.HasForeignKey("ExportRecordId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ExportRecord");
});
modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b =>
{
b.HasOne("FabWorks.Core.Models.BomItem", "BomItem")
.WithOne("CutTemplate")
.HasForeignKey("FabWorks.Core.Models.CutTemplate", "BomItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BomItem");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.HasOne("FabWorks.Core.Models.Drawing", "Drawing")
.WithMany("ExportRecords")
.HasForeignKey("DrawingId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Drawing");
});
modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b =>
{
b.HasOne("FabWorks.Core.Models.BomItem", "BomItem")
.WithOne("FormProgram")
.HasForeignKey("FabWorks.Core.Models.FormProgram", "BomItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BomItem");
});
modelBuilder.Entity("FabWorks.Core.Models.BomItem", b =>
{
b.Navigation("CutTemplate");
b.Navigation("FormProgram");
});
modelBuilder.Entity("FabWorks.Core.Models.Drawing", b =>
{
b.Navigation("ExportRecords");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.Navigation("BomItems");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FabWorks.Core.Migrations
{
/// <inheritdoc />
public partial class AddDrawingEntity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DrawingId",
table: "ExportRecords",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "Drawings",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DrawingNumber = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Title = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
PdfContentHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Revision = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Drawings", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ExportRecords_DrawingId",
table: "ExportRecords",
column: "DrawingId");
migrationBuilder.CreateIndex(
name: "IX_Drawings_DrawingNumber",
table: "Drawings",
column: "DrawingNumber",
unique: true,
filter: "[DrawingNumber] IS NOT NULL");
migrationBuilder.AddForeignKey(
name: "FK_ExportRecords_Drawings_DrawingId",
table: "ExportRecords",
column: "DrawingId",
principalTable: "Drawings",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ExportRecords_Drawings_DrawingId",
table: "ExportRecords");
migrationBuilder.DropTable(
name: "Drawings");
migrationBuilder.DropIndex(
name: "IX_ExportRecords_DrawingId",
table: "ExportRecords");
migrationBuilder.DropColumn(
name: "DrawingId",
table: "ExportRecords");
}
}
}

View File

@@ -0,0 +1,325 @@
// <auto-generated />
using System;
using FabWorks.Core.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FabWorks.Core.Migrations
{
[DbContext(typeof(FabWorksDbContext))]
[Migration("20260220130029_SeedDrawingsFromExistingExports")]
partial class SeedDrawingsFromExistingExports
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("FabWorks.Core.Models.BomItem", b =>
{
b.Property<int>("ID")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("ID"));
b.Property<string>("ConfigurationName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("ExportRecordId")
.HasColumnType("int");
b.Property<string>("ItemNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Material")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("PartName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("PartNo")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("Qty")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int?>("TotalQty")
.HasColumnType("int");
b.HasKey("ID");
b.HasIndex("ExportRecordId");
b.ToTable("BomItems");
});
modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("BomItemId")
.HasColumnType("int");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("CutTemplateName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<double?>("DefaultBendRadius")
.HasColumnType("float");
b.Property<string>("DxfFilePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<double?>("KFactor")
.HasColumnType("float");
b.Property<int>("Revision")
.HasColumnType("int");
b.Property<double?>("Thickness")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("BomItemId")
.IsUnique();
b.ToTable("CutTemplates");
});
modelBuilder.Entity("FabWorks.Core.Models.Drawing", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("DrawingNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("PdfContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int>("Revision")
.HasColumnType("int");
b.Property<string>("Title")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("DrawingNumber")
.IsUnique()
.HasFilter("[DrawingNumber] IS NOT NULL");
b.ToTable("Drawings");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("DrawingId")
.HasColumnType("int");
b.Property<string>("DrawingNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("DrawingNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("EquipmentNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("ExportedAt")
.HasColumnType("datetime2");
b.Property<string>("ExportedBy")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("OutputFolder")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("PdfContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("SourceFilePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Title")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("DrawingId");
b.ToTable("ExportRecords");
});
modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("BendCount")
.HasColumnType("int");
b.Property<int>("BomItemId")
.HasColumnType("int");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<double?>("KFactor")
.HasColumnType("float");
b.Property<string>("LowerToolNames")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("MaterialType")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ProgramFilePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("ProgramName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("SetupNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<double?>("Thickness")
.HasColumnType("float");
b.Property<string>("UpperToolNames")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.HasIndex("BomItemId")
.IsUnique();
b.ToTable("FormPrograms");
});
modelBuilder.Entity("FabWorks.Core.Models.BomItem", b =>
{
b.HasOne("FabWorks.Core.Models.ExportRecord", "ExportRecord")
.WithMany("BomItems")
.HasForeignKey("ExportRecordId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ExportRecord");
});
modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b =>
{
b.HasOne("FabWorks.Core.Models.BomItem", "BomItem")
.WithOne("CutTemplate")
.HasForeignKey("FabWorks.Core.Models.CutTemplate", "BomItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BomItem");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.HasOne("FabWorks.Core.Models.Drawing", "Drawing")
.WithMany("ExportRecords")
.HasForeignKey("DrawingId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Drawing");
});
modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b =>
{
b.HasOne("FabWorks.Core.Models.BomItem", "BomItem")
.WithOne("FormProgram")
.HasForeignKey("FabWorks.Core.Models.FormProgram", "BomItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BomItem");
});
modelBuilder.Entity("FabWorks.Core.Models.BomItem", b =>
{
b.Navigation("CutTemplate");
b.Navigation("FormProgram");
});
modelBuilder.Entity("FabWorks.Core.Models.Drawing", b =>
{
b.Navigation("ExportRecords");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.Navigation("BomItems");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FabWorks.Core.Migrations
{
/// <inheritdoc />
public partial class SeedDrawingsFromExistingExports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Create Drawing records from existing ExportRecords (latest hash per DrawingNumber)
migrationBuilder.Sql(@"
INSERT INTO Drawings (DrawingNumber, Title, PdfContentHash, Revision)
SELECT
sub.DrawingNumber,
sub.Title,
sub.PdfContentHash,
1
FROM (
SELECT
e.DrawingNumber,
e.Title,
e.PdfContentHash,
ROW_NUMBER() OVER (PARTITION BY e.DrawingNumber ORDER BY e.Id DESC) AS rn
FROM ExportRecords e
WHERE e.DrawingNumber IS NOT NULL
AND e.PdfContentHash IS NOT NULL
) sub
WHERE sub.rn = 1;
");
// Link ExportRecords to their Drawing
migrationBuilder.Sql(@"
UPDATE er
SET er.DrawingId = d.Id
FROM ExportRecords er
INNER JOIN Drawings d ON d.DrawingNumber = er.DrawingNumber
WHERE er.PdfContentHash IS NOT NULL;
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE ExportRecords SET DrawingId = NULL;");
migrationBuilder.Sql("DELETE FROM Drawings;");
}
}
}

View File

@@ -0,0 +1,325 @@
// <auto-generated />
using System;
using FabWorks.Core.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FabWorks.Core.Migrations
{
[DbContext(typeof(FabWorksDbContext))]
[Migration("20260220171747_MoveRevisionFromDrawingToExportRecord")]
partial class MoveRevisionFromDrawingToExportRecord
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("FabWorks.Core.Models.BomItem", b =>
{
b.Property<int>("ID")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("ID"));
b.Property<string>("ConfigurationName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("ExportRecordId")
.HasColumnType("int");
b.Property<string>("ItemNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Material")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("PartName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("PartNo")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("Qty")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int?>("TotalQty")
.HasColumnType("int");
b.HasKey("ID");
b.HasIndex("ExportRecordId");
b.ToTable("BomItems");
});
modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("BomItemId")
.HasColumnType("int");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("CutTemplateName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<double?>("DefaultBendRadius")
.HasColumnType("float");
b.Property<string>("DxfFilePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<double?>("KFactor")
.HasColumnType("float");
b.Property<int>("Revision")
.HasColumnType("int");
b.Property<double?>("Thickness")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("BomItemId")
.IsUnique();
b.ToTable("CutTemplates");
});
modelBuilder.Entity("FabWorks.Core.Models.Drawing", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("DrawingNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("PdfContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Title")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("DrawingNumber")
.IsUnique()
.HasFilter("[DrawingNumber] IS NOT NULL");
b.ToTable("Drawings");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("DrawingId")
.HasColumnType("int");
b.Property<string>("DrawingNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("DrawingNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("DrawingRevision")
.HasColumnType("int");
b.Property<string>("EquipmentNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("ExportedAt")
.HasColumnType("datetime2");
b.Property<string>("ExportedBy")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("OutputFolder")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("PdfContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("SourceFilePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Title")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("DrawingId");
b.ToTable("ExportRecords");
});
modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("BendCount")
.HasColumnType("int");
b.Property<int>("BomItemId")
.HasColumnType("int");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<double?>("KFactor")
.HasColumnType("float");
b.Property<string>("LowerToolNames")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("MaterialType")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ProgramFilePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("ProgramName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("SetupNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<double?>("Thickness")
.HasColumnType("float");
b.Property<string>("UpperToolNames")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.HasIndex("BomItemId")
.IsUnique();
b.ToTable("FormPrograms");
});
modelBuilder.Entity("FabWorks.Core.Models.BomItem", b =>
{
b.HasOne("FabWorks.Core.Models.ExportRecord", "ExportRecord")
.WithMany("BomItems")
.HasForeignKey("ExportRecordId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ExportRecord");
});
modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b =>
{
b.HasOne("FabWorks.Core.Models.BomItem", "BomItem")
.WithOne("CutTemplate")
.HasForeignKey("FabWorks.Core.Models.CutTemplate", "BomItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BomItem");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.HasOne("FabWorks.Core.Models.Drawing", "Drawing")
.WithMany("ExportRecords")
.HasForeignKey("DrawingId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Drawing");
});
modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b =>
{
b.HasOne("FabWorks.Core.Models.BomItem", "BomItem")
.WithOne("FormProgram")
.HasForeignKey("FabWorks.Core.Models.FormProgram", "BomItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BomItem");
});
modelBuilder.Entity("FabWorks.Core.Models.BomItem", b =>
{
b.Navigation("CutTemplate");
b.Navigation("FormProgram");
});
modelBuilder.Entity("FabWorks.Core.Models.Drawing", b =>
{
b.Navigation("ExportRecords");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.Navigation("BomItems");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FabWorks.Core.Migrations
{
/// <inheritdoc />
public partial class MoveRevisionFromDrawingToExportRecord : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Revision",
table: "Drawings");
migrationBuilder.AddColumn<int>(
name: "DrawingRevision",
table: "ExportRecords",
type: "int",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DrawingRevision",
table: "ExportRecords");
migrationBuilder.AddColumn<int>(
name: "Revision",
table: "Drawings",
type: "int",
nullable: false,
defaultValue: 0);
}
}
}

View File

@@ -116,6 +116,35 @@ namespace FabWorks.Core.Migrations
b.ToTable("CutTemplates");
});
modelBuilder.Entity("FabWorks.Core.Models.Drawing", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("DrawingNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("PdfContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Title")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("DrawingNumber")
.IsUnique()
.HasFilter("[DrawingNumber] IS NOT NULL");
b.ToTable("Drawings");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.Property<int>("Id")
@@ -124,6 +153,9 @@ namespace FabWorks.Core.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("DrawingId")
.HasColumnType("int");
b.Property<string>("DrawingNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
@@ -132,6 +164,9 @@ namespace FabWorks.Core.Migrations
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("DrawingRevision")
.HasColumnType("int");
b.Property<string>("EquipmentNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
@@ -161,6 +196,8 @@ namespace FabWorks.Core.Migrations
b.HasKey("Id");
b.HasIndex("DrawingId");
b.ToTable("ExportRecords");
});
@@ -242,6 +279,16 @@ namespace FabWorks.Core.Migrations
b.Navigation("BomItem");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.HasOne("FabWorks.Core.Models.Drawing", "Drawing")
.WithMany("ExportRecords")
.HasForeignKey("DrawingId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Drawing");
});
modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b =>
{
b.HasOne("FabWorks.Core.Models.BomItem", "BomItem")
@@ -260,6 +307,11 @@ namespace FabWorks.Core.Migrations
b.Navigation("FormProgram");
});
modelBuilder.Entity("FabWorks.Core.Models.Drawing", b =>
{
b.Navigation("ExportRecords");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.Navigation("BomItems");

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace FabWorks.Core.Models
{
public class Drawing
{
public int Id { get; set; }
public string DrawingNumber { get; set; }
public string Title { get; set; }
public string PdfContentHash { get; set; }
public virtual ICollection<ExportRecord> ExportRecords { get; set; } = new List<ExportRecord>();
}
}

View File

@@ -16,6 +16,10 @@ namespace FabWorks.Core.Models
public string ExportedBy { get; set; }
public string PdfContentHash { get; set; }
public int? DrawingId { get; set; }
public int? DrawingRevision { get; set; }
public virtual Drawing Drawing { get; set; }
public virtual ICollection<BomItem> BomItems { get; set; } = new List<BomItem>();
}
}

View File

@@ -0,0 +1,32 @@
# Auto-fill Equipment/Drawing from Export History
## Problem
When a SolidWorks file is opened that doesn't match the `DrawingInfo` regex (e.g., `Conveyor Frame.sldasm` instead of `5028 A02 Conveyor.slddrw`), the equipment and drawing number dropdowns are left empty even though the file may have been exported before with known values.
## Decision
- **Lookup key:** SolidWorks source file path (`ActiveDocument.FilePath`)
- **Storage:** Query the existing `ExportRecords` table (no new table or migration)
- **Priority:** Database lookup first; fall back to title regex parse if no history found
## Design
### Modify `UpdateActiveDocumentDisplay()` in `MainForm.cs`
When the active document changes:
1. Query the DB for the most recent `ExportRecord` where `SourceFilePath` matches `activeDoc.FilePath` (case-insensitive)
2. If found, parse the stored `DrawingNumber` via `DrawingInfo.Parse()` and auto-fill equipment/drawing dropdowns
3. If not found, fall back to current behavior: `DrawingInfo.Parse(activeDoc.Title)`
### What doesn't change
- No schema changes, no new migration
- Equipment/drawing dropdowns still populated with historical values in `InitializeDrawingDropdowns()`
- Export flow untouched
### Error handling
- DB query wrapped in try/catch so failures don't break the UI
- Case-insensitive path comparison (Windows paths are case-insensitive)

File diff suppressed because it is too large Load Diff