From a17d8cac49f3e8b96a31a47e7edcd2975d265427 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Feb 2026 08:45:53 -0500 Subject: [PATCH 01/34] refactor: consolidate output folder resolution and prefix handling Move ParseDrawingNumber + GetDrawingOutputFolder into Export() before the document-type switch so folder resolution happens once. Extract PrependPrefix helper in PartExporter to deduplicate the prefix guard. Co-Authored-By: Claude Opus 4.6 --- ExportDXF/Services/DxfExportService.cs | 27 ++++++++------------ ExportDXF/Services/PartExporter.cs | 35 +++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/ExportDXF/Services/DxfExportService.cs b/ExportDXF/Services/DxfExportService.cs index ff9bb42..cd0e90d 100644 --- a/ExportDXF/Services/DxfExportService.cs +++ b/ExportDXF/Services/DxfExportService.cs @@ -63,6 +63,9 @@ namespace ExportDXF.Services var startTime = DateTime.Now; + var drawingNumber = ParseDrawingNumber(context); + var outputFolder = _fileExportService.GetDrawingOutputFolder(drawingNumber); + try { _solidWorksService.EnableUserControl(false); @@ -70,15 +73,15 @@ namespace ExportDXF.Services switch (context.ActiveDocument.DocumentType) { case DocumentType.Part: - ExportPart(context); + ExportPart(context, outputFolder); break; case DocumentType.Assembly: - ExportAssembly(context); + ExportAssembly(context, outputFolder); break; case DocumentType.Drawing: - ExportDrawing(context); + ExportDrawing(context, drawingNumber, outputFolder); break; default: @@ -98,7 +101,7 @@ namespace ExportDXF.Services #region Export Methods by Document Type - private void ExportPart(ExportContext context) + private void ExportPart(ExportContext context, string outputFolder) { LogProgress(context, "Active document is a Part"); @@ -109,11 +112,10 @@ namespace ExportDXF.Services return; } - // Export directly to the output folder - _partExporter.ExportSinglePart(part, _fileExportService.OutputFolder, context); + _partExporter.ExportSinglePart(part, outputFolder, context); } - private void ExportAssembly(ExportContext context) + private void ExportAssembly(ExportContext context, string outputFolder) { LogProgress(context, "Active document is an Assembly"); LogProgress(context, "Fetching components..."); @@ -135,11 +137,10 @@ namespace ExportDXF.Services LogProgress(context, $"Found {items.Count} item(s)."); - // Export directly to the output folder - ExportItems(items, _fileExportService.OutputFolder, context); + ExportItems(items, outputFolder, context); } - private void ExportDrawing(ExportContext context) + private void ExportDrawing(ExportContext context, string drawingNumber, string drawingOutputFolder) { LogProgress(context, "Active document is a Drawing"); LogProgress(context, "Finding BOM tables..."); @@ -161,12 +162,6 @@ namespace ExportDXF.Services LogProgress(context, $"Found {items.Count} component(s)"); - // Determine drawing number for file naming - var drawingNumber = ParseDrawingNumber(context); - - // Resolve output folder: /{outputDir}/{equipmentNo}/{drawingNo}/ or flat fallback - var drawingOutputFolder = _fileExportService.GetDrawingOutputFolder(drawingNumber); - // Export drawing to PDF var tempDir = CreateTempWorkDir(); _drawingExporter.ExportToPdf(drawing, tempDir, context); diff --git a/ExportDXF/Services/PartExporter.cs b/ExportDXF/Services/PartExporter.cs index 405bdf5..3683599 100644 --- a/ExportDXF/Services/PartExporter.cs +++ b/ExportDXF/Services/PartExporter.cs @@ -32,6 +32,13 @@ namespace ExportDXF.Services public class PartExporter : IPartExporter { + private readonly IFileExportService _fileExportService; + + public PartExporter(IFileExportService fileExportService) + { + _fileExportService = fileExportService ?? throw new ArgumentNullException(nameof(fileExportService)); + } + public void ExportSinglePart(PartDoc part, string saveDirectory, ExportContext context) { if (part == null) @@ -99,12 +106,22 @@ namespace ExportDXF.Services var templateDrawing = context.GetOrCreateTemplateDrawing(); + // Stash existing file before overwriting + item.StashedFilePath = _fileExportService.StashFile(savePath); + if (ExportPartToDxf(part, item.Component.ReferencedConfiguration, savePath, context)) { item.FileName = Path.GetFileNameWithoutExtension(savePath); + item.ContentHash = Utilities.ContentHasher.ComputeDxfContentHash(savePath); } else { + // Export failed - restore stashed file if we have one + if (item.StashedFilePath != null && File.Exists(item.StashedFilePath)) + { + File.Move(item.StashedFilePath, savePath, overwrite: true); + item.StashedFilePath = null; + } LogExportFailure(item, context); } } @@ -274,7 +291,8 @@ namespace ExportDXF.Services var isDefaultConfig = string.Equals(config, "default", StringComparison.OrdinalIgnoreCase); var name = isDefaultConfig ? title : $"{title} [{config}]"; - return prefix + name; + + return PrependPrefix(name, prefix); } private string GetItemFileName(Item item, string prefix) @@ -282,9 +300,7 @@ namespace ExportDXF.Services prefix = prefix?.Replace("\"", "''") ?? string.Empty; if (string.IsNullOrWhiteSpace(item.ItemNo)) - { - return prefix + item.PartName; - } + return PrependPrefix(item.PartName, prefix); var num = item.ItemNo.PadLeft(2, '0'); // Expected format: {DrawingNo} PT{ItemNo} @@ -293,6 +309,17 @@ namespace ExportDXF.Services : $"{prefix} PT{num}"; } + private string PrependPrefix(string name, string prefix) + { + if (string.IsNullOrEmpty(prefix)) + return name; + + if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return name; + + return prefix + name; + } + private void LogExportFailure(Item item, ExportContext context) { var desc = item.Description?.ToLower() ?? string.Empty; From 719dca1ca55d823f58c9634eff5232e77a2a0a56 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 17 Feb 2026 13:09:02 -0500 Subject: [PATCH 02/34] feat: add export history auto-fill, fix filename prefixes, persist records for all doc types - Add database-first lookup for equipment/drawing number auto-fill when reopening previously exported files - Remove prefix prepending for named parts (only use prefix for PT## BOM items) - Create ExportRecord/BomItem/CutTemplate chains for Part and Assembly exports, not just Drawings - Add auto-incrementing item numbers across drawing numbers - Add content hashing (SHA256) for DXF and PDF versioning with stash/archive pattern - Add EF Core initial migration for ExportDxfDb Co-Authored-By: Claude Opus 4.6 --- ExportDXF/Forms/MainForm.Designer.cs | 30 +-- ExportDXF/Forms/MainForm.cs | 63 +++++- .../20260214044511_InitialCreate.Designer.cs | 153 +++++++++++++ .../20260214044511_InitialCreate.cs | 82 +++++++ ExportDXF/Models/ExportContext.cs | 10 + ExportDXF/Models/ExportRecord.cs | 1 + ExportDXF/Models/Item.cs | 10 + ExportDXF/Program.cs | 4 +- ExportDXF/Services/DxfExportService.cs | 206 +++++++++++++++--- ExportDXF/Services/FileExportService.cs | 58 ++++- ExportDXF/Services/PartExporter.cs | 75 +++++-- ExportDXF/Utilities/ContentHasher.cs | 118 ++++++++++ 12 files changed, 725 insertions(+), 85 deletions(-) create mode 100644 ExportDXF/Migrations/20260214044511_InitialCreate.Designer.cs create mode 100644 ExportDXF/Migrations/20260214044511_InitialCreate.cs create mode 100644 ExportDXF/Utilities/ContentHasher.cs diff --git a/ExportDXF/Forms/MainForm.Designer.cs b/ExportDXF/Forms/MainForm.Designer.cs index f3344c2..2e25fd5 100644 --- a/ExportDXF/Forms/MainForm.Designer.cs +++ b/ExportDXF/Forms/MainForm.Designer.cs @@ -54,10 +54,10 @@ namespace ExportDXF.Forms // runButton // runButton.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right; - runButton.Location = new System.Drawing.Point(514, 13); + runButton.Location = new System.Drawing.Point(821, 13); runButton.Margin = new System.Windows.Forms.Padding(3, 4, 3, 4); runButton.Name = "runButton"; - runButton.Size = new System.Drawing.Size(100, 30); + runButton.Size = new System.Drawing.Size(100, 55); runButton.TabIndex = 11; runButton.Text = "Start"; runButton.UseVisualStyleBackColor = true; @@ -91,7 +91,7 @@ namespace ExportDXF.Forms mainTabControl.Name = "mainTabControl"; mainTabControl.Padding = new System.Drawing.Point(20, 5); mainTabControl.SelectedIndex = 0; - mainTabControl.Size = new System.Drawing.Size(599, 330); + mainTabControl.Size = new System.Drawing.Size(910, 441); mainTabControl.TabIndex = 12; // // logEventsTab @@ -100,7 +100,7 @@ namespace ExportDXF.Forms logEventsTab.Location = new System.Drawing.Point(4, 30); logEventsTab.Name = "logEventsTab"; logEventsTab.Padding = new System.Windows.Forms.Padding(3); - logEventsTab.Size = new System.Drawing.Size(591, 296); + logEventsTab.Size = new System.Drawing.Size(902, 407); logEventsTab.TabIndex = 0; logEventsTab.Text = "Log Events"; logEventsTab.UseVisualStyleBackColor = true; @@ -112,7 +112,7 @@ 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(579, 282); + logEventsDataGrid.Size = new System.Drawing.Size(890, 391); logEventsDataGrid.TabIndex = 0; // // bomTab @@ -121,7 +121,7 @@ namespace ExportDXF.Forms 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(982, 549); + bomTab.Size = new System.Drawing.Size(902, 407); bomTab.TabIndex = 1; bomTab.Text = "Bill Of Materials"; bomTab.UseVisualStyleBackColor = true; @@ -133,30 +133,30 @@ 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(970, 535); + bomDataGrid.Size = new System.Drawing.Size(1281, 644); bomDataGrid.TabIndex = 1; - // + // // cutTemplatesTab - // + // cutTemplatesTab.Controls.Add(cutTemplatesDataGrid); 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(982, 549); + cutTemplatesTab.Size = new System.Drawing.Size(902, 407); cutTemplatesTab.TabIndex = 2; cutTemplatesTab.Text = "Cut Templates"; cutTemplatesTab.UseVisualStyleBackColor = true; - // + // // cutTemplatesDataGrid - // + // cutTemplatesDataGrid.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; cutTemplatesDataGrid.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; cutTemplatesDataGrid.GridColor = System.Drawing.Color.WhiteSmoke; cutTemplatesDataGrid.Location = new System.Drawing.Point(6, 6); cutTemplatesDataGrid.Name = "cutTemplatesDataGrid"; - cutTemplatesDataGrid.Size = new System.Drawing.Size(970, 535); + cutTemplatesDataGrid.Size = new System.Drawing.Size(1281, 644); cutTemplatesDataGrid.TabIndex = 2; - // + // // equipmentBox // equipmentBox.FormattingEnabled = true; @@ -194,7 +194,7 @@ namespace ExportDXF.Forms // MainForm // AutoScaleMode = System.Windows.Forms.AutoScaleMode.None; - ClientSize = new System.Drawing.Size(626, 416); + ClientSize = new System.Drawing.Size(937, 527); Controls.Add(drawingNoBox); Controls.Add(equipmentBox); Controls.Add(mainTabControl); diff --git a/ExportDXF/Forms/MainForm.cs b/ExportDXF/Forms/MainForm.cs index f7d149b..d6a2411 100644 --- a/ExportDXF/Forms/MainForm.cs +++ b/ExportDXF/Forms/MainForm.cs @@ -390,6 +390,8 @@ namespace ExportDXF.Forms ActiveDocument = activeDoc, ViewFlipDecider = viewFlipDecider, FilePrefix = filePrefix, + Equipment = equipment, + DrawingNo = drawingNo, EquipmentId = null, CancellationToken = token, ProgressCallback = (msg, level, file) => LogMessage(msg, level, file), @@ -468,21 +470,58 @@ namespace ExportDXF.Forms var docTitle = activeDoc?.Title ?? "No Document Open"; this.Text = $"ExportDXF - {docTitle}"; - // Parse the file name and fill Equipment/Drawing dropdowns - if (activeDoc != null) - { - var drawingInfo = DrawingInfo.Parse(activeDoc.Title); - if (drawingInfo != null) - { - if (!equipmentBox.Items.Contains(drawingInfo.EquipmentNo)) - equipmentBox.Items.Add(drawingInfo.EquipmentNo); - equipmentBox.Text = drawingInfo.EquipmentNo; + if (activeDoc == null) + return; - if (!drawingNoBox.Items.Contains(drawingInfo.DrawingNo)) - drawingNoBox.Items.Add(drawingInfo.DrawingNo); - drawingNoBox.Text = drawingInfo.DrawingNo; + // Try database first: look up the most recent export for this file path + DrawingInfo drawingInfo = null; + + if (!string.IsNullOrEmpty(activeDoc.FilePath)) + { + drawingInfo = LookupDrawingInfoFromHistory(activeDoc.FilePath); + } + + // Fall back to parsing the document title + if (drawingInfo == null) + { + drawingInfo = DrawingInfo.Parse(activeDoc.Title); + } + + if (drawingInfo != null) + { + if (!equipmentBox.Items.Contains(drawingInfo.EquipmentNo)) + equipmentBox.Items.Add(drawingInfo.EquipmentNo); + equipmentBox.Text = drawingInfo.EquipmentNo; + + if (!drawingNoBox.Items.Contains(drawingInfo.DrawingNo)) + drawingNoBox.Items.Add(drawingInfo.DrawingNo); + drawingNoBox.Text = drawingInfo.DrawingNo; + } + } + + private DrawingInfo LookupDrawingInfoFromHistory(string filePath) + { + try + { + using (var db = _dbContextFactory()) + { + var drawingNumber = db.ExportRecords + .Where(r => r.SourceFilePath.ToLower() == filePath.ToLower() + && !string.IsNullOrEmpty(r.DrawingNumber)) + .OrderByDescending(r => r.Id) + .Select(r => r.DrawingNumber) + .FirstOrDefault(); + + if (drawingNumber != null) + return DrawingInfo.Parse(drawingNumber); } } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to look up drawing info from history: {ex.Message}"); + } + + return null; } private void LogMessage(string message, LogLevel level = LogLevel.Info, string file = null) diff --git a/ExportDXF/Migrations/20260214044511_InitialCreate.Designer.cs b/ExportDXF/Migrations/20260214044511_InitialCreate.Designer.cs new file mode 100644 index 0000000..4c4858b --- /dev/null +++ b/ExportDXF/Migrations/20260214044511_InitialCreate.Designer.cs @@ -0,0 +1,153 @@ +// +using System; +using ExportDXF.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ExportDXF.Migrations +{ + [DbContext(typeof(ExportDxfDbContext))] + [Migration("20260214044511_InitialCreate")] + partial class InitialCreate + { + /// + 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("ExportDXF.Models.BomItem", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ID")); + + b.Property("ConfigurationName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CutTemplateName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DefaultBendRadius") + .HasColumnType("float"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DxfFilePath") + .HasColumnType("nvarchar(max)"); + + b.Property("ExportRecordId") + .HasColumnType("int"); + + b.Property("ItemNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("KFactor") + .HasColumnType("float"); + + b.Property("Material") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PartNo") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Qty") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Thickness") + .HasColumnType("float"); + + b.Property("TotalQty") + .HasColumnType("int"); + + b.HasKey("ID"); + + b.HasIndex("ExportRecordId"); + + b.ToTable("BomItems"); + }); + + modelBuilder.Entity("ExportDXF.Models.ExportRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DrawingNumber") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ExportedAt") + .HasColumnType("datetime2"); + + b.Property("ExportedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OutputFolder") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PdfContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.ToTable("ExportRecords"); + }); + + modelBuilder.Entity("ExportDXF.Models.BomItem", b => + { + b.HasOne("ExportDXF.Models.ExportRecord", "ExportRecord") + .WithMany("BomItems") + .HasForeignKey("ExportRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ExportRecord"); + }); + + modelBuilder.Entity("ExportDXF.Models.ExportRecord", b => + { + b.Navigation("BomItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ExportDXF/Migrations/20260214044511_InitialCreate.cs b/ExportDXF/Migrations/20260214044511_InitialCreate.cs new file mode 100644 index 0000000..e88cc78 --- /dev/null +++ b/ExportDXF/Migrations/20260214044511_InitialCreate.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ExportDXF.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ExportRecords", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + DrawingNumber = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + SourceFilePath = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + OutputFolder = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + ExportedAt = table.Column(type: "datetime2", nullable: false), + ExportedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + PdfContentHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ExportRecords", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "BomItems", + columns: table => new + { + ID = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ItemNo = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + PartNo = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + SortOrder = table.Column(type: "int", nullable: false), + Qty = table.Column(type: "int", nullable: true), + TotalQty = table.Column(type: "int", nullable: true), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + PartName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + ConfigurationName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + Material = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + CutTemplateName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + DxfFilePath = table.Column(type: "nvarchar(max)", nullable: true), + ContentHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + Thickness = table.Column(type: "float", nullable: true), + KFactor = table.Column(type: "float", nullable: true), + DefaultBendRadius = table.Column(type: "float", nullable: true), + ExportRecordId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BomItems", x => x.ID); + table.ForeignKey( + name: "FK_BomItems_ExportRecords_ExportRecordId", + column: x => x.ExportRecordId, + principalTable: "ExportRecords", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_BomItems_ExportRecordId", + table: "BomItems", + column: "ExportRecordId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BomItems"); + + migrationBuilder.DropTable( + name: "ExportRecords"); + } + } +} diff --git a/ExportDXF/Models/ExportContext.cs b/ExportDXF/Models/ExportContext.cs index 837db04..f04ee65 100644 --- a/ExportDXF/Models/ExportContext.cs +++ b/ExportDXF/Models/ExportContext.cs @@ -32,6 +32,16 @@ namespace ExportDXF.Services /// public string FilePrefix { get; set; } + /// + /// Equipment number from the UI (e.g., "5028"). + /// + public string Equipment { get; set; } + + /// + /// Drawing number from the UI (e.g., "A02", "Misc"). + /// + public string DrawingNo { get; set; } + /// /// Selected Equipment ID for API operations (optional). /// diff --git a/ExportDXF/Models/ExportRecord.cs b/ExportDXF/Models/ExportRecord.cs index 77c201c..8ada5f1 100644 --- a/ExportDXF/Models/ExportRecord.cs +++ b/ExportDXF/Models/ExportRecord.cs @@ -11,6 +11,7 @@ namespace ExportDXF.Models public string OutputFolder { get; set; } public DateTime ExportedAt { get; set; } public string ExportedBy { get; set; } + public string PdfContentHash { get; set; } public virtual ICollection BomItems { get; set; } = new List(); } diff --git a/ExportDXF/Models/Item.cs b/ExportDXF/Models/Item.cs index 6b92c68..656446f 100644 --- a/ExportDXF/Models/Item.cs +++ b/ExportDXF/Models/Item.cs @@ -61,5 +61,15 @@ namespace ExportDXF.Services /// The SolidWorks component reference. /// public Component2 Component { get; set; } + + /// + /// SHA256 content hash of the exported DXF (transient, not persisted). + /// + public string ContentHash { get; set; } + + /// + /// Path to the stashed (backed-up) previous DXF file (transient, not persisted). + /// + public string StashedFilePath { get; set; } } } \ No newline at end of file diff --git a/ExportDXF/Program.cs b/ExportDXF/Program.cs index 4e68c80..e773f9d 100644 --- a/ExportDXF/Program.cs +++ b/ExportDXF/Program.cs @@ -39,9 +39,9 @@ namespace ExportDXF { var solidWorksService = new SolidWorksService(); var bomExtractor = new BomExtractor(); - var partExporter = new PartExporter(); - var drawingExporter = new DrawingExporter(); var fileExportService = new FileExportService(_outputFolder); + var partExporter = new PartExporter(fileExportService); + var drawingExporter = new DrawingExporter(); var exportService = new DxfExportService( solidWorksService, diff --git a/ExportDXF/Services/DxfExportService.cs b/ExportDXF/Services/DxfExportService.cs index cd0e90d..791ead6 100644 --- a/ExportDXF/Services/DxfExportService.cs +++ b/ExportDXF/Services/DxfExportService.cs @@ -64,7 +64,7 @@ namespace ExportDXF.Services var startTime = DateTime.Now; var drawingNumber = ParseDrawingNumber(context); - var outputFolder = _fileExportService.GetDrawingOutputFolder(drawingNumber); + var outputFolder = _fileExportService.GetDrawingOutputFolder(context.Equipment, context.DrawingNo); try { @@ -73,11 +73,11 @@ namespace ExportDXF.Services switch (context.ActiveDocument.DocumentType) { case DocumentType.Part: - ExportPart(context, outputFolder); + ExportPart(context, outputFolder, drawingNumber); break; case DocumentType.Assembly: - ExportAssembly(context, outputFolder); + ExportAssembly(context, outputFolder, drawingNumber); break; case DocumentType.Drawing: @@ -101,7 +101,7 @@ namespace ExportDXF.Services #region Export Methods by Document Type - private void ExportPart(ExportContext context, string outputFolder) + private void ExportPart(ExportContext context, string outputFolder, string drawingNumber) { LogProgress(context, "Active document is a Part"); @@ -112,10 +112,52 @@ namespace ExportDXF.Services return; } - _partExporter.ExportSinglePart(part, outputFolder, context); + var exportRecord = CreateExportRecord(context, drawingNumber, outputFolder); + var item = _partExporter.ExportSinglePart(part, outputFolder, context); + + if (item != null) + { + // Assign auto-incremented item number + var nextItemNo = GetNextItemNumber(drawingNumber); + item.ItemNo = nextItemNo; + + 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.FileName)) + { + var dxfPath = Path.Combine(outputFolder, item.FileName + ".dxf"); + bomItem.CutTemplate = new CutTemplate + { + DxfFilePath = dxfPath, + ContentHash = item.ContentHash, + Thickness = item.Thickness > 0 ? item.Thickness : null, + KFactor = item.KFactor > 0 ? item.KFactor : null, + DefaultBendRadius = item.BendRadius > 0 ? item.BendRadius : null + }; + + HandleDxfVersioning(item, dxfPath, context); + } + + context.BomItemCallback?.Invoke(bomItem); + + if (exportRecord != null) + SaveBomItem(bomItem, context); + } } - private void ExportAssembly(ExportContext context, string outputFolder) + private void ExportAssembly(ExportContext context, string outputFolder, string drawingNumber) { LogProgress(context, "Active document is an Assembly"); LogProgress(context, "Fetching components..."); @@ -137,7 +179,20 @@ namespace ExportDXF.Services LogProgress(context, $"Found {items.Count} item(s)."); - ExportItems(items, outputFolder, context); + var exportRecord = CreateExportRecord(context, drawingNumber, outputFolder); + + // Auto-assign item numbers for items that don't have one + var nextNum = int.Parse(GetNextItemNumber(drawingNumber)); + foreach (var item in items) + { + if (string.IsNullOrWhiteSpace(item.ItemNo)) + { + item.ItemNo = nextNum.ToString(); + nextNum++; + } + } + + ExportItems(items, outputFolder, context, exportRecord?.Id); } private void ExportDrawing(ExportContext context, string drawingNumber, string drawingOutputFolder) @@ -190,31 +245,24 @@ namespace ExportDXF.Services } // Create export record in database - ExportRecord exportRecord = null; - try + var exportRecord = CreateExportRecord(context, drawingNumber, drawingOutputFolder); + + // Handle PDF versioning and update export record with hash + if (exportRecord != null && savedPdfPath != null) { - using (var db = _dbContextFactory()) + try { - db.Database.Migrate(); - exportRecord = new ExportRecord - { - DrawingNumber = drawingNumber ?? context.ActiveDocument.Title, - SourceFilePath = context.ActiveDocument.FilePath, - OutputFolder = drawingOutputFolder, - ExportedAt = DateTime.Now, - ExportedBy = System.Environment.UserName - }; + HandlePdfVersioning(savedPdfPath, exportRecord.DrawingNumber, exportRecord, context); - // Handle PDF versioning - compute hash and compare with previous - if (savedPdfPath != null) + // Archive or discard old PDF based on hash comparison + if (pdfStashPath != null) { - HandlePdfVersioning(savedPdfPath, exportRecord.DrawingNumber, exportRecord, context); - - // Archive or discard old PDF based on hash comparison - if (pdfStashPath != null) + using (var db = _dbContextFactory()) { var previousRecord = db.ExportRecords - .Where(r => r.DrawingNumber == exportRecord.DrawingNumber && r.PdfContentHash != null) + .Where(r => r.DrawingNumber == exportRecord.DrawingNumber + && r.PdfContentHash != null + && r.Id != exportRecord.Id) .OrderByDescending(r => r.Id) .FirstOrDefault(); @@ -229,16 +277,24 @@ namespace ExportDXF.Services } } - db.ExportRecords.Add(exportRecord); - db.SaveChanges(); - LogProgress(context, $"Created export record (ID: {exportRecord.Id})", LogLevel.Info); + // Update the record with the PDF hash + using (var db = _dbContextFactory()) + { + db.ExportRecords.Attach(exportRecord); + db.Entry(exportRecord).Property(r => r.PdfContentHash).IsModified = true; + db.SaveChanges(); + } + } + catch (Exception ex) + { + _fileExportService.DiscardStash(pdfStashPath); + LogProgress(context, $"PDF versioning error: {ex.Message}", LogLevel.Error); } } - catch (Exception ex) + else if (pdfStashPath != null) { - // Clean up stash on error + // No export record - discard stash _fileExportService.DiscardStash(pdfStashPath); - LogProgress(context, $"Database error creating export record: {ex.Message}", LogLevel.Error); } // Export parts to DXF and save BOM items @@ -478,6 +534,86 @@ namespace ExportDXF.Services #endregion + #region Database Helpers + + private ExportRecord CreateExportRecord(ExportContext context, string drawingNumber, string outputFolder) + { + try + { + using (var db = _dbContextFactory()) + { + db.Database.Migrate(); + var record = new ExportRecord + { + DrawingNumber = drawingNumber ?? context.ActiveDocument.Title, + SourceFilePath = context.ActiveDocument.FilePath, + OutputFolder = outputFolder, + ExportedAt = DateTime.Now, + ExportedBy = System.Environment.UserName + }; + + db.ExportRecords.Add(record); + db.SaveChanges(); + LogProgress(context, $"Created export record (ID: {record.Id})", LogLevel.Info); + return record; + } + } + catch (Exception ex) + { + LogProgress(context, $"Database error creating export record: {ex.Message}", LogLevel.Error); + return null; + } + } + + private string GetNextItemNumber(string drawingNumber) + { + if (string.IsNullOrEmpty(drawingNumber)) + return "1"; + + try + { + using (var db = _dbContextFactory()) + { + var existingItems = db.ExportRecords + .Where(r => r.DrawingNumber == drawingNumber) + .SelectMany(r => r.BomItems) + .Select(b => b.ItemNo) + .ToList(); + + int maxNum = 0; + foreach (var itemNo in existingItems) + { + if (int.TryParse(itemNo, out var num) && num > maxNum) + maxNum = num; + } + + return (maxNum + 1).ToString(); + } + } + catch + { + return "1"; + } + } + + private void SaveBomItem(BomItem bomItem, ExportContext context) + { + try + { + using (var db = _dbContextFactory()) + { + db.BomItems.Add(bomItem); + db.SaveChanges(); + } + } + catch (Exception dbEx) + { + LogProgress(context, $"Database error saving BOM item: {dbEx.Message}", LogLevel.Error); + } + } + + #endregion + #region Helper Methods private string CreateTempWorkDir() @@ -489,7 +625,11 @@ namespace ExportDXF.Services private string ParseDrawingNumber(ExportContext context) { - // Prefer prefix (e.g., "5007 A02 PT"), fallback to active document title + // Use explicit Equipment/DrawingNo from the UI when available + if (!string.IsNullOrWhiteSpace(context?.Equipment) && !string.IsNullOrWhiteSpace(context?.DrawingNo)) + return $"{context.Equipment} {context.DrawingNo}"; + + // Fallback: parse from prefix or document title var candidate = context?.FilePrefix; var info = string.IsNullOrWhiteSpace(candidate) ? null : DrawingInfo.Parse(candidate); if (info == null) diff --git a/ExportDXF/Services/FileExportService.cs b/ExportDXF/Services/FileExportService.cs index 9e42f6d..80482ee 100644 --- a/ExportDXF/Services/FileExportService.cs +++ b/ExportDXF/Services/FileExportService.cs @@ -6,9 +6,13 @@ namespace ExportDXF.Services public interface IFileExportService { string OutputFolder { get; } + string GetDrawingOutputFolder(string equipment, string drawingNo); string SaveDxfFile(string sourcePath, string drawingNumber, string itemNo); - string SavePdfFile(string sourcePath, string drawingNumber); + string SavePdfFile(string sourcePath, string drawingNumber, string outputFolder = null); void EnsureOutputFolderExists(); + string StashFile(string filePath); + void ArchiveFile(string stashPath, string originalPath); + void DiscardStash(string stashPath); } public class FileExportService : IFileExportService @@ -29,6 +33,18 @@ namespace ExportDXF.Services } } + public string GetDrawingOutputFolder(string equipment, string drawingNo) + { + if (string.IsNullOrEmpty(equipment) || string.IsNullOrEmpty(drawingNo)) + return OutputFolder; + + var folder = Path.Combine(OutputFolder, equipment, drawingNo); + if (!Directory.Exists(folder)) + Directory.CreateDirectory(folder); + + return folder; + } + public string SaveDxfFile(string sourcePath, string drawingNumber, string itemNo) { if (string.IsNullOrEmpty(sourcePath)) @@ -49,16 +65,17 @@ namespace ExportDXF.Services return destPath; } - public string SavePdfFile(string sourcePath, string drawingNumber) + public string SavePdfFile(string sourcePath, string drawingNumber, string outputFolder = null) { if (string.IsNullOrEmpty(sourcePath)) throw new ArgumentNullException(nameof(sourcePath)); + var folder = outputFolder ?? OutputFolder; var fileName = !string.IsNullOrEmpty(drawingNumber) ? $"{drawingNumber}.pdf" : Path.GetFileName(sourcePath); - var destPath = Path.Combine(OutputFolder, fileName); + var destPath = Path.Combine(folder, fileName); // If source and dest are the same, skip copy if (!string.Equals(sourcePath, destPath, StringComparison.OrdinalIgnoreCase)) @@ -68,5 +85,40 @@ namespace ExportDXF.Services return destPath; } + + public string StashFile(string filePath) + { + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) + return null; + + var stashPath = filePath + ".bak"; + File.Move(filePath, stashPath, overwrite: true); + return stashPath; + } + + public void ArchiveFile(string stashPath, string originalPath) + { + if (string.IsNullOrEmpty(stashPath) || !File.Exists(stashPath)) + return; + + var fileDir = Path.GetDirectoryName(originalPath) ?? OutputFolder; + var archiveDir = Path.Combine(fileDir, "_archive"); + if (!Directory.Exists(archiveDir)) + Directory.CreateDirectory(archiveDir); + + var originalName = Path.GetFileNameWithoutExtension(originalPath); + var ext = Path.GetExtension(originalPath); + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var archiveName = $"{originalName} [{timestamp}]{ext}"; + var archivePath = Path.Combine(archiveDir, archiveName); + + File.Move(stashPath, archivePath, overwrite: true); + } + + public void DiscardStash(string stashPath) + { + if (!string.IsNullOrEmpty(stashPath) && File.Exists(stashPath)) + File.Delete(stashPath); + } } } diff --git a/ExportDXF/Services/PartExporter.cs b/ExportDXF/Services/PartExporter.cs index 3683599..9fecf6e 100644 --- a/ExportDXF/Services/PartExporter.cs +++ b/ExportDXF/Services/PartExporter.cs @@ -15,11 +15,12 @@ namespace ExportDXF.Services { /// /// Exports a single part document to DXF. + /// Returns an Item with export metadata (filename, hash, sheet metal properties), or null if export failed. /// /// The part document to export. /// The directory where the DXF file will be saved. /// The export context. - void ExportSinglePart(PartDoc part, string saveDirectory, ExportContext context); + Item ExportSinglePart(PartDoc part, string saveDirectory, ExportContext context); /// /// Exports an item (component from BOM or assembly) to DXF. @@ -39,7 +40,7 @@ namespace ExportDXF.Services _fileExportService = fileExportService ?? throw new ArgumentNullException(nameof(fileExportService)); } - public void ExportSinglePart(PartDoc part, string saveDirectory, ExportContext context) + public Item ExportSinglePart(PartDoc part, string saveDirectory, ExportContext context) { if (part == null) throw new ArgumentNullException(nameof(part)); @@ -59,9 +60,57 @@ namespace ExportDXF.Services var fileName = GetSinglePartFileName(model, context.FilePrefix); var savePath = Path.Combine(saveDirectory, fileName + ".dxf"); + // Build result item with metadata + var item = new Item + { + PartName = model.GetTitle()?.Replace(".SLDPRT", "") ?? "", + Configuration = originalConfigName ?? "", + Quantity = 1 + }; + + // Enrich with sheet metal properties and description + var sheetMetalProps = SolidWorksHelper.GetSheetMetalProperties(model); + if (sheetMetalProps != null) + { + item.Thickness = sheetMetalProps.Thickness; + item.KFactor = sheetMetalProps.KFactor; + item.BendRadius = sheetMetalProps.BendRadius; + } + + // Get description from custom properties + var configPropMgr = model.Extension.CustomPropertyManager[originalConfigName]; + item.Description = configPropMgr?.Get("Description"); + if (string.IsNullOrEmpty(item.Description)) + { + var docPropMgr = model.Extension.CustomPropertyManager[""]; + item.Description = docPropMgr?.Get("Description"); + } + item.Description = TextHelper.RemoveXmlTags(item.Description); + + // Get material + item.Material = part.GetMaterialPropertyName2(originalConfigName, out _); + + // Stash existing file before overwriting + item.StashedFilePath = _fileExportService.StashFile(savePath); + context.GetOrCreateTemplateDrawing(); - ExportPartToDxf(part, originalConfigName, savePath, context); + if (ExportPartToDxf(part, originalConfigName, savePath, context)) + { + item.FileName = Path.GetFileNameWithoutExtension(savePath); + item.ContentHash = Utilities.ContentHasher.ComputeDxfContentHash(savePath); + return item; + } + else + { + // Export failed - restore stashed file + if (item.StashedFilePath != null && File.Exists(item.StashedFilePath)) + { + File.Move(item.StashedFilePath, savePath, overwrite: true); + item.StashedFilePath = null; + } + return null; + } } finally { @@ -290,18 +339,15 @@ namespace ExportDXF.Services var config = model.ConfigurationManager.ActiveConfiguration.Name; var isDefaultConfig = string.Equals(config, "default", StringComparison.OrdinalIgnoreCase); - var name = isDefaultConfig ? title : $"{title} [{config}]"; - - return PrependPrefix(name, prefix); + return isDefaultConfig ? title : $"{title} [{config}]"; } private string GetItemFileName(Item item, string prefix) { - prefix = prefix?.Replace("\"", "''") ?? string.Empty; - if (string.IsNullOrWhiteSpace(item.ItemNo)) - return PrependPrefix(item.PartName, prefix); + return item.PartName; + prefix = prefix?.Replace("\"", "''") ?? string.Empty; var num = item.ItemNo.PadLeft(2, '0'); // Expected format: {DrawingNo} PT{ItemNo} return string.IsNullOrWhiteSpace(prefix) @@ -309,17 +355,6 @@ namespace ExportDXF.Services : $"{prefix} PT{num}"; } - private string PrependPrefix(string name, string prefix) - { - if (string.IsNullOrEmpty(prefix)) - return name; - - if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - return name; - - return prefix + name; - } - private void LogExportFailure(Item item, ExportContext context) { var desc = item.Description?.ToLower() ?? string.Empty; diff --git a/ExportDXF/Utilities/ContentHasher.cs b/ExportDXF/Utilities/ContentHasher.cs new file mode 100644 index 0000000..0be2612 --- /dev/null +++ b/ExportDXF/Utilities/ContentHasher.cs @@ -0,0 +1,118 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace ExportDXF.Utilities +{ + public static class ContentHasher + { + /// + /// Computes a SHA256 hash of DXF file content, skipping the HEADER section + /// which contains timestamps that change on every save. + /// + 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()) + { + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(content)); + return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); + } + } + + /// + /// Computes a SHA256 hash of the entire file contents (for PDFs and other binary files). + /// + public static string ComputeFileHash(string filePath) + { + using (var sha = SHA256.Create()) + using (var stream = File.OpenRead(filePath)) + { + var bytes = sha.ComputeHash(stream); + return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); + } + } + + /// + /// 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. + /// + private static int FindEndOfHeader(string text) + { + // Find the HEADER section start + var headerIndex = FindGroupCode(text, 0, "2", "HEADER"); + if (headerIndex < 0) + return -1; + + // Advance past the HEADER value line so pair scanning stays aligned + var headerLineEnd = text.IndexOf('\n', headerIndex); + if (headerLineEnd < 0) + return -1; + + // Find the ENDSEC that closes the HEADER section + var pos = headerLineEnd + 1; + while (pos < text.Length) + { + var endsecIndex = FindGroupCode(text, pos, "0", "ENDSEC"); + if (endsecIndex < 0) + return -1; + + // Move past the ENDSEC line + var lineEnd = text.IndexOf('\n', endsecIndex); + return lineEnd >= 0 ? lineEnd + 1 : text.Length; + } + + return -1; + } + + /// + /// 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. + /// + private static int FindGroupCode(string text, int startIndex, string groupCode, string value) + { + var pos = startIndex; + while (pos < text.Length) + { + // Skip whitespace/newlines to find the group code + while (pos < text.Length && (text[pos] == '\r' || text[pos] == '\n' || text[pos] == ' ')) + pos++; + + if (pos >= text.Length) + break; + + // Read the group code line + var codeLineEnd = text.IndexOf('\n', pos); + if (codeLineEnd < 0) + break; + + var codeLine = text.Substring(pos, codeLineEnd - pos).Trim(); + + // Move to the value line + var valueStart = codeLineEnd + 1; + if (valueStart >= text.Length) + break; + + var valueLineEnd = text.IndexOf('\n', valueStart); + if (valueLineEnd < 0) + valueLineEnd = text.Length; + + var valueLine = text.Substring(valueStart, valueLineEnd - valueStart).Trim(); + + if (codeLine == groupCode && string.Equals(valueLine, value, StringComparison.OrdinalIgnoreCase)) + return valueStart; + + // Move to the next pair + pos = valueLineEnd + 1; + } + + return -1; + } + } +} From 78a8a2197da6e162f2d0f350c8dcf2ccd495c3ae Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 06:20:13 -0500 Subject: [PATCH 03/34] feat: add FabWorks.Core shared library with entity models and FormProgram Co-Authored-By: Claude Opus 4.6 --- ExportDXF.sln | 42 ++++++++++++++ FabWorks.Core/Data/FabWorksDbContext.cs | 76 +++++++++++++++++++++++++ FabWorks.Core/FabWorks.Core.csproj | 17 ++++++ FabWorks.Core/Models/BomItem.cs | 22 +++++++ FabWorks.Core/Models/CutTemplate.cs | 31 ++++++++++ FabWorks.Core/Models/ExportRecord.cs | 18 ++++++ FabWorks.Core/Models/FormProgram.cs | 20 +++++++ 7 files changed, 226 insertions(+) create mode 100644 FabWorks.Core/Data/FabWorksDbContext.cs create mode 100644 FabWorks.Core/FabWorks.Core.csproj create mode 100644 FabWorks.Core/Models/BomItem.cs create mode 100644 FabWorks.Core/Models/CutTemplate.cs create mode 100644 FabWorks.Core/Models/ExportRecord.cs create mode 100644 FabWorks.Core/Models/FormProgram.cs diff --git a/ExportDXF.sln b/ExportDXF.sln index 24040fd..6f84954 100644 --- a/ExportDXF.sln +++ b/ExportDXF.sln @@ -9,24 +9,66 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EtchBendLines", "EtchBendLi 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {05F21D73-FD31-4E77-8D9B-41C86D4D8305}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {05F21D73-FD31-4E77-8D9B-41C86D4D8305}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05F21D73-FD31-4E77-8D9B-41C86D4D8305}.Debug|x64.ActiveCfg = Debug|Any CPU + {05F21D73-FD31-4E77-8D9B-41C86D4D8305}.Debug|x64.Build.0 = Debug|Any CPU + {05F21D73-FD31-4E77-8D9B-41C86D4D8305}.Debug|x86.ActiveCfg = Debug|Any CPU + {05F21D73-FD31-4E77-8D9B-41C86D4D8305}.Debug|x86.Build.0 = Debug|Any CPU {05F21D73-FD31-4E77-8D9B-41C86D4D8305}.Release|Any CPU.ActiveCfg = Release|Any CPU {05F21D73-FD31-4E77-8D9B-41C86D4D8305}.Release|Any CPU.Build.0 = Release|Any CPU + {05F21D73-FD31-4E77-8D9B-41C86D4D8305}.Release|x64.ActiveCfg = Release|Any CPU + {05F21D73-FD31-4E77-8D9B-41C86D4D8305}.Release|x64.Build.0 = Release|Any CPU + {05F21D73-FD31-4E77-8D9B-41C86D4D8305}.Release|x86.ActiveCfg = Release|Any CPU + {05F21D73-FD31-4E77-8D9B-41C86D4D8305}.Release|x86.Build.0 = Release|Any CPU {229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Debug|x64.ActiveCfg = Debug|Any CPU + {229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Debug|x64.Build.0 = Debug|Any CPU + {229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Debug|x86.ActiveCfg = Debug|Any CPU + {229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Debug|x86.Build.0 = Debug|Any CPU {229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Release|Any CPU.ActiveCfg = Release|Any CPU {229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Release|Any CPU.Build.0 = Release|Any CPU + {229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Release|x64.ActiveCfg = Release|Any CPU + {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 + {24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Debug|x64.Build.0 = Debug|Any CPU + {24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Debug|x86.ActiveCfg = Debug|Any CPU + {24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Debug|x86.Build.0 = Debug|Any CPU + {24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Release|Any CPU.Build.0 = Release|Any CPU + {24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Release|x64.ActiveCfg = Release|Any CPU + {24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Release|x64.Build.0 = Release|Any CPU + {24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Release|x86.ActiveCfg = Release|Any CPU + {24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/FabWorks.Core/Data/FabWorksDbContext.cs b/FabWorks.Core/Data/FabWorksDbContext.cs new file mode 100644 index 0000000..c0a16fc --- /dev/null +++ b/FabWorks.Core/Data/FabWorksDbContext.cs @@ -0,0 +1,76 @@ +using FabWorks.Core.Models; +using Microsoft.EntityFrameworkCore; + +namespace FabWorks.Core.Data +{ + public class FabWorksDbContext : DbContext + { + public DbSet ExportRecords { get; set; } + public DbSet BomItems { get; set; } + public DbSet CutTemplates { get; set; } + public DbSet FormPrograms { get; set; } + + public FabWorksDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.DrawingNumber).HasMaxLength(100); + entity.Property(e => e.SourceFilePath).HasMaxLength(500); + entity.Property(e => e.OutputFolder).HasMaxLength(500); + entity.Property(e => e.ExportedBy).HasMaxLength(100); + entity.Property(e => e.PdfContentHash).HasMaxLength(64); + + entity.HasMany(e => e.BomItems) + .WithOne(b => b.ExportRecord) + .HasForeignKey(b => b.ExportRecordId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.ID); + entity.Property(e => e.ItemNo).HasMaxLength(50); + entity.Property(e => e.PartNo).HasMaxLength(100); + entity.Property(e => e.Description).HasMaxLength(500); + entity.Property(e => e.PartName).HasMaxLength(200); + entity.Property(e => e.ConfigurationName).HasMaxLength(100); + entity.Property(e => e.Material).HasMaxLength(100); + + entity.HasOne(e => e.CutTemplate) + .WithOne(ct => ct.BomItem) + .HasForeignKey(ct => ct.BomItemId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.FormProgram) + .WithOne(fp => fp.BomItem) + .HasForeignKey(fp => fp.BomItemId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.DxfFilePath).HasMaxLength(500); + entity.Property(e => e.CutTemplateName).HasMaxLength(100); + entity.Property(e => e.ContentHash).HasMaxLength(64); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.ProgramFilePath).HasMaxLength(500); + entity.Property(e => e.ContentHash).HasMaxLength(64); + entity.Property(e => e.ProgramName).HasMaxLength(200); + entity.Property(e => e.MaterialType).HasMaxLength(50); + entity.Property(e => e.UpperToolNames).HasMaxLength(500); + entity.Property(e => e.LowerToolNames).HasMaxLength(500); + entity.Property(e => e.SetupNotes).HasMaxLength(2000); + }); + } + } +} diff --git a/FabWorks.Core/FabWorks.Core.csproj b/FabWorks.Core/FabWorks.Core.csproj new file mode 100644 index 0000000..a82a833 --- /dev/null +++ b/FabWorks.Core/FabWorks.Core.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + disable + disable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/FabWorks.Core/Models/BomItem.cs b/FabWorks.Core/Models/BomItem.cs new file mode 100644 index 0000000..863eb47 --- /dev/null +++ b/FabWorks.Core/Models/BomItem.cs @@ -0,0 +1,22 @@ +namespace FabWorks.Core.Models +{ + public class BomItem + { + public int ID { get; set; } + public string ItemNo { get; set; } = ""; + public string PartNo { get; set; } = ""; + public int SortOrder { get; set; } + public int? Qty { get; set; } + public int? TotalQty { get; set; } + public string Description { get; set; } = ""; + public string PartName { get; set; } = ""; + public string ConfigurationName { get; set; } = ""; + public string Material { get; set; } = ""; + + public int ExportRecordId { get; set; } + public virtual ExportRecord ExportRecord { get; set; } + + public virtual CutTemplate CutTemplate { get; set; } + public virtual FormProgram FormProgram { get; set; } + } +} diff --git a/FabWorks.Core/Models/CutTemplate.cs b/FabWorks.Core/Models/CutTemplate.cs new file mode 100644 index 0000000..9a268fb --- /dev/null +++ b/FabWorks.Core/Models/CutTemplate.cs @@ -0,0 +1,31 @@ +using System; + +namespace FabWorks.Core.Models +{ + public class CutTemplate + { + public int Id { get; set; } + public string DxfFilePath { get; set; } = ""; + public string ContentHash { get; set; } + public string CutTemplateName { get; set; } = ""; + + private double? _thickness; + public double? Thickness + { + get => _thickness; + set => _thickness = value.HasValue ? Math.Round(value.Value, 8) : null; + } + + public double? KFactor { get; set; } + + private double? _defaultBendRadius; + public double? DefaultBendRadius + { + get => _defaultBendRadius; + set => _defaultBendRadius = value.HasValue ? Math.Round(value.Value, 8) : null; + } + + public int BomItemId { get; set; } + public virtual BomItem BomItem { get; set; } + } +} diff --git a/FabWorks.Core/Models/ExportRecord.cs b/FabWorks.Core/Models/ExportRecord.cs new file mode 100644 index 0000000..90dfd79 --- /dev/null +++ b/FabWorks.Core/Models/ExportRecord.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; + +namespace FabWorks.Core.Models +{ + public class ExportRecord + { + public int Id { get; set; } + public string DrawingNumber { get; set; } + public string SourceFilePath { get; set; } + public string OutputFolder { get; set; } + public DateTime ExportedAt { get; set; } + public string ExportedBy { get; set; } + public string PdfContentHash { get; set; } + + public virtual ICollection BomItems { get; set; } = new List(); + } +} diff --git a/FabWorks.Core/Models/FormProgram.cs b/FabWorks.Core/Models/FormProgram.cs new file mode 100644 index 0000000..feaa03f --- /dev/null +++ b/FabWorks.Core/Models/FormProgram.cs @@ -0,0 +1,20 @@ +namespace FabWorks.Core.Models +{ + public class FormProgram + { + public int Id { get; set; } + public string ProgramFilePath { get; set; } = ""; + public string ContentHash { get; set; } + public string ProgramName { get; set; } = ""; + public double? Thickness { get; set; } + public string MaterialType { get; set; } = ""; + public double? KFactor { get; set; } + public int BendCount { get; set; } + public string UpperToolNames { get; set; } = ""; + public string LowerToolNames { get; set; } = ""; + public string SetupNotes { get; set; } = ""; + + public int BomItemId { get; set; } + public virtual BomItem BomItem { get; set; } + } +} From 2bef75f548bd7626a8ee6eb8240f6931bffa12f0 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 06:22:46 -0500 Subject: [PATCH 04/34] feat: port CincyLib PressBrake parser to FabWorks.Core (net8.0) Co-Authored-By: Claude Opus 4.6 --- FabWorks.Core/PressBrake/Extensions.cs | 171 ++++++++++++++++++++++ FabWorks.Core/PressBrake/MatType.cs | 11 ++ FabWorks.Core/PressBrake/Program.cs | 59 ++++++++ FabWorks.Core/PressBrake/ProgramReader.cs | 149 +++++++++++++++++++ FabWorks.Core/PressBrake/SegEntry.cs | 7 + FabWorks.Core/PressBrake/Step.cs | 36 +++++ FabWorks.Core/PressBrake/ToolSetup.cs | 24 +++ 7 files changed, 457 insertions(+) create mode 100644 FabWorks.Core/PressBrake/Extensions.cs create mode 100644 FabWorks.Core/PressBrake/MatType.cs create mode 100644 FabWorks.Core/PressBrake/Program.cs create mode 100644 FabWorks.Core/PressBrake/ProgramReader.cs create mode 100644 FabWorks.Core/PressBrake/SegEntry.cs create mode 100644 FabWorks.Core/PressBrake/Step.cs create mode 100644 FabWorks.Core/PressBrake/ToolSetup.cs diff --git a/FabWorks.Core/PressBrake/Extensions.cs b/FabWorks.Core/PressBrake/Extensions.cs new file mode 100644 index 0000000..d86ce26 --- /dev/null +++ b/FabWorks.Core/PressBrake/Extensions.cs @@ -0,0 +1,171 @@ +using System; +using System.Xml.Linq; + +namespace FabWorks.Core.PressBrake +{ + internal static class Extensions + { + private static bool? ToBool(this string s) + { + if (string.IsNullOrWhiteSpace(s)) + return null; + + int intValue; + + if (!int.TryParse(s, out intValue)) + return null; + + return Convert.ToBoolean(intValue); + } + + public static bool ToBool(this XAttribute a, bool defaultValue = false) + { + if (a == null) + return defaultValue; + + var b = a.Value.ToBool(); + + return b != null ? b.Value : defaultValue; + } + + public static bool? ToBoolOrNull(this XAttribute a) + { + if (a == null) + return null; + + return a.Value.ToBool(); + } + + private static int? ToInt(this string s) + { + if (string.IsNullOrWhiteSpace(s)) + return null; + + int intValue; + + if (!int.TryParse(s, out intValue)) + return null; + + return intValue; + } + + public static int ToInt(this XAttribute a, int defaultValue = 0) + { + if (a == null) + return defaultValue; + + var b = a.Value.ToInt(); + + return b != null ? b.Value : defaultValue; + } + + public static int? ToIntOrNull(this XAttribute a) + { + if (a == null) + return null; + + return a.Value.ToInt(); + } + + public static int ToInt(this XElement a, int defaultValue = 0) + { + if (a == null) + return defaultValue; + + var b = a.Value.ToInt(); + + return b != null ? b.Value : defaultValue; + } + + public static int? ToIntOrNull(this XElement a) + { + if (a == null) + return null; + + return a.Value.ToInt(); + } + + private static double? ToDouble(this string s) + { + if (string.IsNullOrWhiteSpace(s)) + return null; + + double d; + + if (!double.TryParse(s, out d)) + return null; + + return d; + } + + public static double ToDouble(this XAttribute a, double defaultValue = 0) + { + if (a == null) + return defaultValue; + + var b = a.Value.ToDouble(); + + return b != null ? b.Value : defaultValue; + } + + public static double? ToDoubleOrNull(this XAttribute a) + { + if (a == null) + return null; + + return a.Value.ToDouble(); + } + + public static double ToDouble(this XElement a, double defaultValue = 0) + { + if (a == null) + return defaultValue; + + var b = a.Value.ToDouble(); + + return b != null ? b.Value : defaultValue; + } + + public static double? ToDoubleOrNull(this XElement a) + { + if (a == null) + return null; + + return a.Value.ToDouble(); + } + + public static DateTime? ToDateTime(this XAttribute a) + { + if (a == null || string.IsNullOrWhiteSpace(a.Value)) + return null; + + DateTime d; + + if (!DateTime.TryParse(a.Value, out d)) + return null; + + return d; + } + + public static TimeSpan? ToTimeSpan(this XElement e) + { + if (e == null || string.IsNullOrWhiteSpace(e.Value)) + return null; + + TimeSpan d; + + if (!TimeSpan.TryParse(e.Value, out d)) + return null; + + return d; + } + + public static DateTime RoundDown(this DateTime dt, TimeSpan d) + { + var modTicks = dt.Ticks % d.Ticks; + var delta = -modTicks; + + return new DateTime(dt.Ticks + delta, dt.Kind); + } + } +} diff --git a/FabWorks.Core/PressBrake/MatType.cs b/FabWorks.Core/PressBrake/MatType.cs new file mode 100644 index 0000000..c2716f8 --- /dev/null +++ b/FabWorks.Core/PressBrake/MatType.cs @@ -0,0 +1,11 @@ +namespace FabWorks.Core.PressBrake +{ + public enum MatType + { + MildSteel, + HighStrengthSteel, + Stainless, + SoftAluminum, + HardAluminum + } +} diff --git a/FabWorks.Core/PressBrake/Program.cs b/FabWorks.Core/PressBrake/Program.cs new file mode 100644 index 0000000..7b36a87 --- /dev/null +++ b/FabWorks.Core/PressBrake/Program.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace FabWorks.Core.PressBrake +{ + public class Program + { + public Program() + { + UpperToolSets = new List(); + LowerToolSets = new List(); + Steps = new List(); + } + + public int Version { get; set; } + + public string ProgName { get; set; } + + public string FilePath { get; set; } + + public double MatThick { get; set; } + + public MatType MatType { get; set; } + + public double KFactor { get; set; } + + public string TeachName { get; set; } + + public string PartName { get; set; } + + public string SetupNotes { get; set; } + + public string ProgNotes { get; set; } + + public bool RZEnabled { get; set; } + + public List UpperToolSets { get; set; } + + public List LowerToolSets { get; set; } + + public List Steps { get; set; } + + public static Program Load(string file) + { + var reader = new ProgramReader(); + reader.Read(file); + return reader.Program; + } + + public static Program Load(Stream stream) + { + var reader = new ProgramReader(); + reader.Read(stream); + return reader.Program; + } + } +} diff --git a/FabWorks.Core/PressBrake/ProgramReader.cs b/FabWorks.Core/PressBrake/ProgramReader.cs new file mode 100644 index 0000000..0267e79 --- /dev/null +++ b/FabWorks.Core/PressBrake/ProgramReader.cs @@ -0,0 +1,149 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace FabWorks.Core.PressBrake +{ + public class ProgramReader + { + public Program Program { get; set; } + + public ProgramReader() + { + Program = new Program(); + } + + public void Read(string file) + { + var xml = XDocument.Load(file); + Program.FilePath = file; + Read(xml); + } + + public void Read(Stream stream) + { + var xml = XDocument.Load(stream); + Read(xml); + } + + private void Read(XDocument doc) + { + var data = doc.Root.Element("PressBrakeProgram"); + + Program.Version = data.Attribute("Version").ToInt(); + Program.ProgName = data.Attribute("ProgName")?.Value; + Program.MatThick = data.Attribute("MatThick").ToDouble(); + Program.MatType = GetMaterialType(data.Attribute("MatType")?.Value); + Program.KFactor = data.Attribute("KFactor").ToDouble(); + Program.TeachName = data.Attribute("TeachName")?.Value; + Program.PartName = data.Attribute("PartName")?.Value; + Program.SetupNotes = data.Attribute("SetupNotes")?.Value; + Program.ProgNotes = data.Attribute("ProgNotes")?.Value; + Program.RZEnabled = Convert.ToBoolean(data.Attribute("RZEnabled").ToInt()); + + foreach (var item in data.Element("UpperToolSets").Descendants("ToolSetup")) + { + var setup = ReadToolSetup(item); + Program.UpperToolSets.Add(setup); + } + + foreach (var item in data.Element("LowerToolSets").Descendants("ToolSetup")) + { + var setup = ReadToolSetup(item); + Program.LowerToolSets.Add(setup); + } + + foreach (var item in data.Element("StepData").Descendants("Step")) + { + var step = ReadStep(item); + step.UpperTool = Program.UpperToolSets.FirstOrDefault(t => t.Id == step.UpperID); + step.LowerTool = Program.LowerToolSets.FirstOrDefault(t => t.Id == step.LowerID); + + Program.Steps.Add(step); + } + } + + private ToolSetup ReadToolSetup(XElement x) + { + var setup = new ToolSetup(); + + setup.Name = x.Attribute("Name").Value; + setup.Id = x.Attribute("ID").ToInt(); + setup.Length = x.Attribute("Length").ToDouble(); + setup.StackedHolderType = x.Attribute("StackedHolderType").ToInt(); + setup.HolderHeight = x.Attribute("HolderHeight").ToDouble(); + + foreach (var item in x.Descendants("SegEntry")) + { + var entry = new SegEntry(); + entry.SegValue = item.Attribute("SegValue").ToDouble(); + setup.Segments.Add(entry); + } + + return setup; + } + + private Step ReadStep(XElement x) + { + var step = new Step(); + + step.RevMode = x.Attribute("RevMode").ToInt(); + step.RevTons = x.Attribute("RevTons").ToDouble(); + step.MaxTons = x.Attribute("MaxTons").ToDouble(); + step.RevAbsPos = x.Attribute("RevAbsPos").ToDouble(); + step.ActualAng = x.Attribute("ActualAng").ToDouble(); + step.AngleAdj = x.Attribute("AngleAdj").ToDouble(); + step.BendLen = x.Attribute("BendLen").ToDouble(); + step.StrokeLen = x.Attribute("StrokeLen").ToDouble(); + step.UpperID = x.Attribute("UpperID").ToInt(); + step.LowerID = x.Attribute("LowerID").ToInt(); + step.SpdChgDwn = x.Attribute("SpdChgDwn").ToDouble(); + step.SpdChgUp = x.Attribute("SpdChgUp").ToDouble(); + step.Tilt = x.Attribute("Tilt").ToDouble(); + step.FormSpeed = x.Attribute("FormSpeed").ToDouble(); + step.XLeft = x.Attribute("XLeft").ToDouble(); + step.XRight = x.Attribute("XRight").ToDouble(); + step.RLeft = x.Attribute("RLeft").ToDouble(); + step.RRight = x.Attribute("RRight").ToDouble(); + step.ZLeft = x.Attribute("ZLeft").ToDouble(); + step.ZRight = x.Attribute("ZRight").ToDouble(); + step.FLeft = x.Attribute("FLeft").ToDouble(); + step.FRight = x.Attribute("FRight").ToDouble(); + step.SSLeft = x.Attribute("SSLeft").ToDouble(); + step.SSRight = x.Attribute("SSRight").ToDouble(); + step.ReturnSpd = x.Attribute("ReturnSpd").ToDouble(); + step.SideFlgHeight = x.Attribute("SideFlgHeight").ToDouble(); + + return step; + } + + private MatType GetMaterialType(string value) + { + if (value == null) + return MatType.MildSteel; + + int i; + + if (!int.TryParse(value, out i)) + return MatType.MildSteel; + + switch (i) + { + case 0: + return MatType.MildSteel; + case 1: + return MatType.HighStrengthSteel; + case 2: + return MatType.Stainless; + case 3: + return MatType.SoftAluminum; + case 4: + return MatType.HardAluminum; + } + + return MatType.MildSteel; + } + } +} diff --git a/FabWorks.Core/PressBrake/SegEntry.cs b/FabWorks.Core/PressBrake/SegEntry.cs new file mode 100644 index 0000000..e6ed33d --- /dev/null +++ b/FabWorks.Core/PressBrake/SegEntry.cs @@ -0,0 +1,7 @@ +namespace FabWorks.Core.PressBrake +{ + public class SegEntry + { + public double SegValue { get; set; } + } +} diff --git a/FabWorks.Core/PressBrake/Step.cs b/FabWorks.Core/PressBrake/Step.cs new file mode 100644 index 0000000..3520a17 --- /dev/null +++ b/FabWorks.Core/PressBrake/Step.cs @@ -0,0 +1,36 @@ +namespace FabWorks.Core.PressBrake +{ + public class Step + { + public int RevMode { get; set; } + public double RevTons { get; set; } + public double MaxTons { get; set; } + public double RevAbsPos { get; set; } + public double ActualAng { get; set; } + public double AngleAdj { get; set; } + public double BendLen { get; set; } + public double StrokeLen { get; set; } + public double Tilt { get; set; } + public int UpperID { get; set; } + public int LowerID { get; set; } + public double SpdChgDwn { get; set; } + public double SpdChgUp { get; set; } + public double FormSpeed { get; set; } + public double XLeft { get; set; } + public double XRight { get; set; } + public double RLeft { get; set; } + public double RRight { get; set; } + public double ZLeft { get; set; } + public double ZRight { get; set; } + public double FLeft { get; set; } + public double FRight { get; set; } + + public double SSLeft { get; set; } + public double SSRight { get; set; } + public double ReturnSpd { get; set; } + public double SideFlgHeight { get; set; } + + public ToolSetup UpperTool { get; set; } + public ToolSetup LowerTool { get; set; } + } +} diff --git a/FabWorks.Core/PressBrake/ToolSetup.cs b/FabWorks.Core/PressBrake/ToolSetup.cs new file mode 100644 index 0000000..c71d4b9 --- /dev/null +++ b/FabWorks.Core/PressBrake/ToolSetup.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace FabWorks.Core.PressBrake +{ + public class ToolSetup + { + public ToolSetup() + { + Segments = new List(); + } + + public string Name { get; set; } + + public int Id { get; set; } + + public double Length { get; set; } + + public int StackedHolderType { get; set; } + + public double HolderHeight { get; set; } + + public List Segments { get; set; } + } +} From 28c9f715beebe6ad647f3b6d17dec12492b8f7a1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 06:27:12 -0500 Subject: [PATCH 05/34] test: add ProgramReader tests validating CincyLib port Co-Authored-By: Claude Opus 4.6 --- ExportDXF.sln | 14 + FabWorks.Tests/FabWorks.Tests.csproj | 31 + .../PressBrake/ProgramReaderTests.cs | 48 ++ FabWorks.Tests/TestData/sample.pgm | 593 ++++++++++++++++++ 4 files changed, 686 insertions(+) create mode 100644 FabWorks.Tests/FabWorks.Tests.csproj create mode 100644 FabWorks.Tests/PressBrake/ProgramReaderTests.cs create mode 100644 FabWorks.Tests/TestData/sample.pgm diff --git a/ExportDXF.sln b/ExportDXF.sln index 6f84954..5c9b6de 100644 --- a/ExportDXF.sln +++ b/ExportDXF.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "netDxf", "EtchBendLines\net 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}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,6 +71,18 @@ Global {24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Release|x64.Build.0 = Release|Any CPU {24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Release|x86.ActiveCfg = Release|Any CPU {24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Release|x86.Build.0 = Release|Any CPU + {6DD89774-D86B-47E9-B982-2794BD95616A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6DD89774-D86B-47E9-B982-2794BD95616A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DD89774-D86B-47E9-B982-2794BD95616A}.Debug|x64.ActiveCfg = Debug|Any CPU + {6DD89774-D86B-47E9-B982-2794BD95616A}.Debug|x64.Build.0 = Debug|Any CPU + {6DD89774-D86B-47E9-B982-2794BD95616A}.Debug|x86.ActiveCfg = Debug|Any CPU + {6DD89774-D86B-47E9-B982-2794BD95616A}.Debug|x86.Build.0 = Debug|Any CPU + {6DD89774-D86B-47E9-B982-2794BD95616A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6DD89774-D86B-47E9-B982-2794BD95616A}.Release|Any CPU.Build.0 = Release|Any CPU + {6DD89774-D86B-47E9-B982-2794BD95616A}.Release|x64.ActiveCfg = Release|Any CPU + {6DD89774-D86B-47E9-B982-2794BD95616A}.Release|x64.Build.0 = Release|Any CPU + {6DD89774-D86B-47E9-B982-2794BD95616A}.Release|x86.ActiveCfg = Release|Any CPU + {6DD89774-D86B-47E9-B982-2794BD95616A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/FabWorks.Tests/FabWorks.Tests.csproj b/FabWorks.Tests/FabWorks.Tests.csproj new file mode 100644 index 0000000..354f1fb --- /dev/null +++ b/FabWorks.Tests/FabWorks.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/FabWorks.Tests/PressBrake/ProgramReaderTests.cs b/FabWorks.Tests/PressBrake/ProgramReaderTests.cs new file mode 100644 index 0000000..b2614cf --- /dev/null +++ b/FabWorks.Tests/PressBrake/ProgramReaderTests.cs @@ -0,0 +1,48 @@ +using FabWorks.Core.PressBrake; +using Xunit; + +namespace FabWorks.Tests.PressBrake +{ + public class ProgramReaderTests + { + [Fact] + public void Load_SamplePgm_ParsesProgramAttributes() + { + var pgm = Program.Load("TestData/sample.pgm"); + + // ProgName may be empty on some exports; verify PartName was parsed instead + Assert.False(string.IsNullOrEmpty(pgm.PartName)); + } + + [Fact] + public void Load_SamplePgm_ParsesThickness() + { + var pgm = Program.Load("TestData/sample.pgm"); + Assert.True(pgm.MatThick > 0); + } + + [Fact] + public void Load_SamplePgm_ParsesSteps() + { + var pgm = Program.Load("TestData/sample.pgm"); + Assert.NotEmpty(pgm.Steps); + } + + [Fact] + public void Load_SamplePgm_ParsesToolSetups() + { + var pgm = Program.Load("TestData/sample.pgm"); + Assert.NotEmpty(pgm.UpperToolSets); + Assert.NotEmpty(pgm.LowerToolSets); + } + + [Fact] + public void Load_SamplePgm_ResolvesStepToolReferences() + { + var pgm = Program.Load("TestData/sample.pgm"); + var step = pgm.Steps[0]; + Assert.NotNull(step.UpperTool); + Assert.NotNull(step.LowerTool); + } + } +} diff --git a/FabWorks.Tests/TestData/sample.pgm b/FabWorks.Tests/TestData/sample.pgm new file mode 100644 index 0000000..76b06ad --- /dev/null +++ b/FabWorks.Tests/TestData/sample.pgm @@ -0,0 +1,593 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From ab76fa61c9d7b2e3e8a2d294f321b20f75fc9030 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 06:32:41 -0500 Subject: [PATCH 06/34] feat: add FabWorks.Api with ExportsController and DTOs Co-Authored-By: Claude Opus 4.6 --- ExportDXF.sln | 14 ++ FabWorks.Api/Controllers/ExportsController.cs | 140 ++++++++++++++++++ FabWorks.Api/DTOs/CreateExportRequest.cs | 9 ++ FabWorks.Api/DTOs/ExportDetailDto.cs | 58 ++++++++ FabWorks.Api/FabWorks.Api.csproj | 13 ++ FabWorks.Api/Program.cs | 15 ++ FabWorks.Api/Properties/launchSettings.json | 41 +++++ FabWorks.Api/appsettings.Development.json | 8 + FabWorks.Api/appsettings.json | 12 ++ 9 files changed, 310 insertions(+) create mode 100644 FabWorks.Api/Controllers/ExportsController.cs create mode 100644 FabWorks.Api/DTOs/CreateExportRequest.cs create mode 100644 FabWorks.Api/DTOs/ExportDetailDto.cs create mode 100644 FabWorks.Api/FabWorks.Api.csproj create mode 100644 FabWorks.Api/Program.cs create mode 100644 FabWorks.Api/Properties/launchSettings.json create mode 100644 FabWorks.Api/appsettings.Development.json create mode 100644 FabWorks.Api/appsettings.json diff --git a/ExportDXF.sln b/ExportDXF.sln index 5c9b6de..e194613 100644 --- a/ExportDXF.sln +++ b/ExportDXF.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FabWorks.Core", "FabWorks.C EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FabWorks.Tests", "FabWorks.Tests\FabWorks.Tests.csproj", "{6DD89774-D86B-47E9-B982-2794BD95616A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FabWorks.Api", "FabWorks.Api\FabWorks.Api.csproj", "{9BD571FA-52D8-430D-8843-FEB6EABD421C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -83,6 +85,18 @@ Global {6DD89774-D86B-47E9-B982-2794BD95616A}.Release|x64.Build.0 = Release|Any CPU {6DD89774-D86B-47E9-B982-2794BD95616A}.Release|x86.ActiveCfg = Release|Any CPU {6DD89774-D86B-47E9-B982-2794BD95616A}.Release|x86.Build.0 = Release|Any CPU + {9BD571FA-52D8-430D-8843-FEB6EABD421C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9BD571FA-52D8-430D-8843-FEB6EABD421C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9BD571FA-52D8-430D-8843-FEB6EABD421C}.Debug|x64.ActiveCfg = Debug|Any CPU + {9BD571FA-52D8-430D-8843-FEB6EABD421C}.Debug|x64.Build.0 = Debug|Any CPU + {9BD571FA-52D8-430D-8843-FEB6EABD421C}.Debug|x86.ActiveCfg = Debug|Any CPU + {9BD571FA-52D8-430D-8843-FEB6EABD421C}.Debug|x86.Build.0 = Debug|Any CPU + {9BD571FA-52D8-430D-8843-FEB6EABD421C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9BD571FA-52D8-430D-8843-FEB6EABD421C}.Release|Any CPU.Build.0 = Release|Any CPU + {9BD571FA-52D8-430D-8843-FEB6EABD421C}.Release|x64.ActiveCfg = Release|Any CPU + {9BD571FA-52D8-430D-8843-FEB6EABD421C}.Release|x64.Build.0 = Release|Any CPU + {9BD571FA-52D8-430D-8843-FEB6EABD421C}.Release|x86.ActiveCfg = Release|Any CPU + {9BD571FA-52D8-430D-8843-FEB6EABD421C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/FabWorks.Api/Controllers/ExportsController.cs b/FabWorks.Api/Controllers/ExportsController.cs new file mode 100644 index 0000000..743c857 --- /dev/null +++ b/FabWorks.Api/Controllers/ExportsController.cs @@ -0,0 +1,140 @@ +using FabWorks.Api.DTOs; +using FabWorks.Core.Data; +using FabWorks.Core.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace FabWorks.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class ExportsController : ControllerBase + { + private readonly FabWorksDbContext _db; + + public ExportsController(FabWorksDbContext db) => _db = db; + + [HttpPost] + public async Task> Create(CreateExportRequest request) + { + var record = new ExportRecord + { + DrawingNumber = request.DrawingNumber, + SourceFilePath = request.SourceFilePath, + OutputFolder = request.OutputFolder, + ExportedAt = DateTime.Now, + ExportedBy = Environment.UserName + }; + + _db.ExportRecords.Add(record); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetById), new { id = record.Id }, MapToDto(record)); + } + + [HttpGet("{id}")] + public async Task> GetById(int id) + { + var record = await _db.ExportRecords + .Include(r => r.BomItems).ThenInclude(b => b.CutTemplate) + .Include(r => r.BomItems).ThenInclude(b => b.FormProgram) + .FirstOrDefaultAsync(r => r.Id == id); + + if (record == null) return NotFound(); + return MapToDto(record); + } + + [HttpGet("by-source")] + public async Task> GetBySourceFile([FromQuery] string path) + { + var record = await _db.ExportRecords + .Where(r => r.SourceFilePath.ToLower() == path.ToLower() + && !string.IsNullOrEmpty(r.DrawingNumber)) + .OrderByDescending(r => r.Id) + .FirstOrDefaultAsync(); + + if (record == null) return NotFound(); + return MapToDto(record); + } + + [HttpGet("by-drawing")] + public async Task>> GetByDrawing([FromQuery] string drawingNumber) + { + var records = await _db.ExportRecords + .Include(r => r.BomItems).ThenInclude(b => b.CutTemplate) + .Include(r => r.BomItems).ThenInclude(b => b.FormProgram) + .Where(r => r.DrawingNumber == drawingNumber) + .OrderByDescending(r => r.ExportedAt) + .ToListAsync(); + + return records.Select(MapToDto).ToList(); + } + + [HttpGet("next-item-number")] + public async Task> GetNextItemNumber([FromQuery] string drawingNumber) + { + if (string.IsNullOrEmpty(drawingNumber)) return "1"; + + var existingItems = await _db.ExportRecords + .Where(r => r.DrawingNumber == drawingNumber) + .SelectMany(r => r.BomItems) + .Select(b => b.ItemNo) + .ToListAsync(); + + int maxNum = 0; + foreach (var itemNo in existingItems) + { + if (int.TryParse(itemNo, out var num) && num > maxNum) + maxNum = num; + } + return (maxNum + 1).ToString(); + } + + private static ExportDetailDto MapToDto(ExportRecord r) => new() + { + Id = r.Id, + DrawingNumber = r.DrawingNumber, + SourceFilePath = r.SourceFilePath, + OutputFolder = r.OutputFolder, + ExportedAt = r.ExportedAt, + ExportedBy = r.ExportedBy, + PdfContentHash = r.PdfContentHash, + BomItems = r.BomItems?.Select(b => new BomItemDto + { + ID = b.ID, + ItemNo = b.ItemNo, + PartNo = b.PartNo, + SortOrder = b.SortOrder, + Qty = b.Qty, + TotalQty = b.TotalQty, + Description = b.Description, + PartName = b.PartName, + ConfigurationName = b.ConfigurationName, + Material = b.Material, + CutTemplate = b.CutTemplate == null ? null : new CutTemplateDto + { + Id = b.CutTemplate.Id, + DxfFilePath = b.CutTemplate.DxfFilePath, + ContentHash = b.CutTemplate.ContentHash, + Thickness = b.CutTemplate.Thickness, + KFactor = b.CutTemplate.KFactor, + DefaultBendRadius = b.CutTemplate.DefaultBendRadius + }, + FormProgram = b.FormProgram == null ? null : new FormProgramDto + { + Id = b.FormProgram.Id, + ProgramFilePath = b.FormProgram.ProgramFilePath, + ContentHash = b.FormProgram.ContentHash, + ProgramName = b.FormProgram.ProgramName, + Thickness = b.FormProgram.Thickness, + MaterialType = b.FormProgram.MaterialType, + KFactor = b.FormProgram.KFactor, + BendCount = b.FormProgram.BendCount, + UpperToolNames = b.FormProgram.UpperToolNames, + LowerToolNames = b.FormProgram.LowerToolNames, + SetupNotes = b.FormProgram.SetupNotes + } + }).ToList() ?? new() + }; + } +} diff --git a/FabWorks.Api/DTOs/CreateExportRequest.cs b/FabWorks.Api/DTOs/CreateExportRequest.cs new file mode 100644 index 0000000..6cb0d6f --- /dev/null +++ b/FabWorks.Api/DTOs/CreateExportRequest.cs @@ -0,0 +1,9 @@ +namespace FabWorks.Api.DTOs +{ + public class CreateExportRequest + { + public string DrawingNumber { get; set; } + public string SourceFilePath { get; set; } + public string OutputFolder { get; set; } + } +} diff --git a/FabWorks.Api/DTOs/ExportDetailDto.cs b/FabWorks.Api/DTOs/ExportDetailDto.cs new file mode 100644 index 0000000..a8efd81 --- /dev/null +++ b/FabWorks.Api/DTOs/ExportDetailDto.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; + +namespace FabWorks.Api.DTOs +{ + public class ExportDetailDto + { + public int Id { get; set; } + public string DrawingNumber { get; set; } + public string SourceFilePath { get; set; } + public string OutputFolder { get; set; } + public DateTime ExportedAt { get; set; } + public string ExportedBy { get; set; } + public string PdfContentHash { get; set; } + public List BomItems { get; set; } = new(); + } + + public class BomItemDto + { + public int ID { get; set; } + public string ItemNo { get; set; } + public string PartNo { get; set; } + public int SortOrder { get; set; } + public int? Qty { get; set; } + public int? TotalQty { get; set; } + public string Description { get; set; } + public string PartName { get; set; } + public string ConfigurationName { get; set; } + public string Material { get; set; } + public CutTemplateDto CutTemplate { get; set; } + public FormProgramDto FormProgram { get; set; } + } + + public class CutTemplateDto + { + public int Id { get; set; } + public string DxfFilePath { get; set; } + public string ContentHash { get; set; } + public double? Thickness { get; set; } + public double? KFactor { get; set; } + public double? DefaultBendRadius { get; set; } + } + + public class FormProgramDto + { + public int Id { get; set; } + public string ProgramFilePath { get; set; } + public string ContentHash { get; set; } + public string ProgramName { get; set; } + public double? Thickness { get; set; } + public string MaterialType { get; set; } + public double? KFactor { get; set; } + public int BendCount { get; set; } + public string UpperToolNames { get; set; } + public string LowerToolNames { get; set; } + public string SetupNotes { get; set; } + } +} diff --git a/FabWorks.Api/FabWorks.Api.csproj b/FabWorks.Api/FabWorks.Api.csproj new file mode 100644 index 0000000..e4f3a2f --- /dev/null +++ b/FabWorks.Api/FabWorks.Api.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + disable + enable + + + + + + + diff --git a/FabWorks.Api/Program.cs b/FabWorks.Api/Program.cs new file mode 100644 index 0000000..dc2cc89 --- /dev/null +++ b/FabWorks.Api/Program.cs @@ -0,0 +1,15 @@ +using FabWorks.Api.Services; +using FabWorks.Core.Data; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("FabWorksDb"))); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +app.MapControllers(); +app.Run(); diff --git a/FabWorks.Api/Properties/launchSettings.json b/FabWorks.Api/Properties/launchSettings.json new file mode 100644 index 0000000..b41988c --- /dev/null +++ b/FabWorks.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:45483", + "sslPort": 44397 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "http://localhost:5206", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "https://localhost:7182;http://localhost:5206", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "weatherforecast", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/FabWorks.Api/appsettings.Development.json b/FabWorks.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/FabWorks.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/FabWorks.Api/appsettings.json b/FabWorks.Api/appsettings.json new file mode 100644 index 0000000..7326dc1 --- /dev/null +++ b/FabWorks.Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "FabWorksDb": "Server=localhost;Database=ExportDxfDb;Trusted_Connection=True;TrustServerCertificate=True;" + } +} From 9e5e44c1ed5ccd80d75fa23e2983a8112976733a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 06:32:52 -0500 Subject: [PATCH 07/34] feat: add BomItems and FormPrograms controllers with parse service Co-Authored-By: Claude Opus 4.6 --- .../Controllers/BomItemsController.cs | 122 ++++++++++++++++++ .../Controllers/FormProgramsController.cs | 106 +++++++++++++++ FabWorks.Api/Services/FormProgramService.cs | 39 ++++++ 3 files changed, 267 insertions(+) create mode 100644 FabWorks.Api/Controllers/BomItemsController.cs create mode 100644 FabWorks.Api/Controllers/FormProgramsController.cs create mode 100644 FabWorks.Api/Services/FormProgramService.cs diff --git a/FabWorks.Api/Controllers/BomItemsController.cs b/FabWorks.Api/Controllers/BomItemsController.cs new file mode 100644 index 0000000..e58668f --- /dev/null +++ b/FabWorks.Api/Controllers/BomItemsController.cs @@ -0,0 +1,122 @@ +using FabWorks.Api.DTOs; +using FabWorks.Core.Data; +using FabWorks.Core.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace FabWorks.Api.Controllers +{ + [ApiController] + [Route("api/exports/{exportId}/bom-items")] + public class BomItemsController : ControllerBase + { + private readonly FabWorksDbContext _db; + + public BomItemsController(FabWorksDbContext db) => _db = db; + + [HttpGet] + public async Task>> GetByExport(int exportId) + { + var items = await _db.BomItems + .Include(b => b.CutTemplate) + .Include(b => b.FormProgram) + .Where(b => b.ExportRecordId == exportId) + .OrderBy(b => b.SortOrder) + .ToListAsync(); + + return items.Select(MapToDto).ToList(); + } + + [HttpPost] + public async Task> Create(int exportId, BomItemDto dto) + { + var export = await _db.ExportRecords.FindAsync(exportId); + if (export == null) return NotFound("Export record not found"); + + var item = new BomItem + { + ExportRecordId = exportId, + ItemNo = dto.ItemNo ?? "", + PartNo = dto.PartNo ?? "", + SortOrder = dto.SortOrder, + Qty = dto.Qty, + TotalQty = dto.TotalQty, + Description = dto.Description ?? "", + PartName = dto.PartName ?? "", + ConfigurationName = dto.ConfigurationName ?? "", + Material = dto.Material ?? "" + }; + + if (dto.CutTemplate != null) + { + item.CutTemplate = new CutTemplate + { + DxfFilePath = dto.CutTemplate.DxfFilePath ?? "", + ContentHash = dto.CutTemplate.ContentHash, + Thickness = dto.CutTemplate.Thickness, + KFactor = dto.CutTemplate.KFactor, + DefaultBendRadius = dto.CutTemplate.DefaultBendRadius + }; + } + + if (dto.FormProgram != null) + { + item.FormProgram = new FormProgram + { + ProgramFilePath = dto.FormProgram.ProgramFilePath ?? "", + ContentHash = dto.FormProgram.ContentHash, + ProgramName = dto.FormProgram.ProgramName ?? "", + Thickness = dto.FormProgram.Thickness, + MaterialType = dto.FormProgram.MaterialType ?? "", + KFactor = dto.FormProgram.KFactor, + BendCount = dto.FormProgram.BendCount, + UpperToolNames = dto.FormProgram.UpperToolNames ?? "", + LowerToolNames = dto.FormProgram.LowerToolNames ?? "", + SetupNotes = dto.FormProgram.SetupNotes ?? "" + }; + } + + _db.BomItems.Add(item); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetByExport), new { exportId }, MapToDto(item)); + } + + private static BomItemDto MapToDto(BomItem b) => new() + { + ID = b.ID, + ItemNo = b.ItemNo, + PartNo = b.PartNo, + SortOrder = b.SortOrder, + Qty = b.Qty, + TotalQty = b.TotalQty, + Description = b.Description, + PartName = b.PartName, + ConfigurationName = b.ConfigurationName, + Material = b.Material, + CutTemplate = b.CutTemplate == null ? null : new CutTemplateDto + { + Id = b.CutTemplate.Id, + DxfFilePath = b.CutTemplate.DxfFilePath, + ContentHash = b.CutTemplate.ContentHash, + Thickness = b.CutTemplate.Thickness, + KFactor = b.CutTemplate.KFactor, + DefaultBendRadius = b.CutTemplate.DefaultBendRadius + }, + FormProgram = b.FormProgram == null ? null : new FormProgramDto + { + Id = b.FormProgram.Id, + ProgramFilePath = b.FormProgram.ProgramFilePath, + ContentHash = b.FormProgram.ContentHash, + ProgramName = b.FormProgram.ProgramName, + Thickness = b.FormProgram.Thickness, + MaterialType = b.FormProgram.MaterialType, + KFactor = b.FormProgram.KFactor, + BendCount = b.FormProgram.BendCount, + UpperToolNames = b.FormProgram.UpperToolNames, + LowerToolNames = b.FormProgram.LowerToolNames, + SetupNotes = b.FormProgram.SetupNotes + } + }; + } +} diff --git a/FabWorks.Api/Controllers/FormProgramsController.cs b/FabWorks.Api/Controllers/FormProgramsController.cs new file mode 100644 index 0000000..39ab191 --- /dev/null +++ b/FabWorks.Api/Controllers/FormProgramsController.cs @@ -0,0 +1,106 @@ +using FabWorks.Api.DTOs; +using FabWorks.Api.Services; +using FabWorks.Core.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace FabWorks.Api.Controllers +{ + [ApiController] + [Route("api/form-programs")] + public class FormProgramsController : ControllerBase + { + private readonly FabWorksDbContext _db; + private readonly FormProgramService _formService; + + public FormProgramsController(FabWorksDbContext db, FormProgramService formService) + { + _db = db; + _formService = formService; + } + + [HttpGet("by-drawing")] + public async Task>> GetByDrawing([FromQuery] string drawingNumber) + { + var programs = await _db.FormPrograms + .Include(fp => fp.BomItem) + .ThenInclude(b => b.ExportRecord) + .Where(fp => fp.BomItem.ExportRecord.DrawingNumber == drawingNumber) + .ToListAsync(); + + return programs.Select(fp => new FormProgramDto + { + Id = fp.Id, + ProgramFilePath = fp.ProgramFilePath, + ContentHash = fp.ContentHash, + ProgramName = fp.ProgramName, + Thickness = fp.Thickness, + MaterialType = fp.MaterialType, + KFactor = fp.KFactor, + BendCount = fp.BendCount, + UpperToolNames = fp.UpperToolNames, + LowerToolNames = fp.LowerToolNames, + SetupNotes = fp.SetupNotes + }).ToList(); + } + + [HttpPost("parse")] + public ActionResult Parse([FromQuery] string filePath) + { + if (!System.IO.File.Exists(filePath)) + return NotFound($"File not found: {filePath}"); + + var fp = _formService.ParseFromFile(filePath); + return new FormProgramDto + { + ProgramFilePath = fp.ProgramFilePath, + ContentHash = fp.ContentHash, + ProgramName = fp.ProgramName, + Thickness = fp.Thickness, + MaterialType = fp.MaterialType, + KFactor = fp.KFactor, + BendCount = fp.BendCount, + UpperToolNames = fp.UpperToolNames, + LowerToolNames = fp.LowerToolNames, + SetupNotes = fp.SetupNotes + }; + } + + [HttpPost("{bomItemId}")] + public async Task> AttachToItem(int bomItemId, [FromQuery] string filePath) + { + var bomItem = await _db.BomItems + .Include(b => b.FormProgram) + .FirstOrDefaultAsync(b => b.ID == bomItemId); + + if (bomItem == null) return NotFound("BOM item not found"); + + if (!System.IO.File.Exists(filePath)) + return NotFound($"File not found: {filePath}"); + + var fp = _formService.ParseFromFile(filePath); + fp.BomItemId = bomItemId; + + if (bomItem.FormProgram != null) + _db.FormPrograms.Remove(bomItem.FormProgram); + + bomItem.FormProgram = fp; + await _db.SaveChangesAsync(); + + return new FormProgramDto + { + Id = fp.Id, + ProgramFilePath = fp.ProgramFilePath, + ContentHash = fp.ContentHash, + ProgramName = fp.ProgramName, + Thickness = fp.Thickness, + MaterialType = fp.MaterialType, + KFactor = fp.KFactor, + BendCount = fp.BendCount, + UpperToolNames = fp.UpperToolNames, + LowerToolNames = fp.LowerToolNames, + SetupNotes = fp.SetupNotes + }; + } + } +} diff --git a/FabWorks.Api/Services/FormProgramService.cs b/FabWorks.Api/Services/FormProgramService.cs new file mode 100644 index 0000000..01cb56e --- /dev/null +++ b/FabWorks.Api/Services/FormProgramService.cs @@ -0,0 +1,39 @@ +using FabWorks.Core.Models; +using FabWorks.Core.PressBrake; +using System.Security.Cryptography; + +namespace FabWorks.Api.Services +{ + public class FormProgramService + { + public FormProgram ParseFromFile(string filePath) + { + var pgm = FabWorks.Core.PressBrake.Program.Load(filePath); + var hash = ComputeFileHash(filePath); + + return new FormProgram + { + ProgramFilePath = filePath, + ContentHash = hash, + ProgramName = pgm.ProgName ?? "", + Thickness = pgm.MatThick > 0 ? pgm.MatThick : null, + MaterialType = pgm.MatType.ToString(), + KFactor = pgm.KFactor > 0 ? pgm.KFactor : null, + BendCount = pgm.Steps.Count, + UpperToolNames = string.Join(", ", pgm.UpperToolSets + .Select(t => t.Name).Where(n => !string.IsNullOrEmpty(n)).Distinct()), + LowerToolNames = string.Join(", ", pgm.LowerToolSets + .Select(t => t.Name).Where(n => !string.IsNullOrEmpty(n)).Distinct()), + SetupNotes = pgm.SetupNotes ?? "" + }; + } + + private static string ComputeFileHash(string filePath) + { + using var sha = SHA256.Create(); + using var stream = File.OpenRead(filePath); + var bytes = sha.ComputeHash(stream); + return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); + } + } +} From 16dc74c35d9420feec1334c112e1a93f063f054a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 06:36:02 -0500 Subject: [PATCH 08/34] test: add FormProgramService tests Co-Authored-By: Claude Opus 4.6 --- FabWorks.Tests/FabWorks.Tests.csproj | 1 + FabWorks.Tests/FormProgramServiceTests.cs | 53 +++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 FabWorks.Tests/FormProgramServiceTests.cs diff --git a/FabWorks.Tests/FabWorks.Tests.csproj b/FabWorks.Tests/FabWorks.Tests.csproj index 354f1fb..12e226c 100644 --- a/FabWorks.Tests/FabWorks.Tests.csproj +++ b/FabWorks.Tests/FabWorks.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/FabWorks.Tests/FormProgramServiceTests.cs b/FabWorks.Tests/FormProgramServiceTests.cs new file mode 100644 index 0000000..146355c --- /dev/null +++ b/FabWorks.Tests/FormProgramServiceTests.cs @@ -0,0 +1,53 @@ +using FabWorks.Api.Services; +using Xunit; + +namespace FabWorks.Tests +{ + public class FormProgramServiceTests + { + [Fact] + public void ParseFromFile_SamplePgm_PopulatesMaterialType() + { + var service = new FormProgramService(); + var fp = service.ParseFromFile("TestData/sample.pgm"); + + // ProgName is empty in the sample file, so verify MaterialType instead + Assert.False(string.IsNullOrEmpty(fp.MaterialType)); + } + + [Fact] + public void ParseFromFile_SamplePgm_PopulatesThickness() + { + var service = new FormProgramService(); + var fp = service.ParseFromFile("TestData/sample.pgm"); + Assert.NotNull(fp.Thickness); + Assert.True(fp.Thickness > 0); + } + + [Fact] + public void ParseFromFile_SamplePgm_PopulatesBendCount() + { + var service = new FormProgramService(); + var fp = service.ParseFromFile("TestData/sample.pgm"); + Assert.True(fp.BendCount > 0); + } + + [Fact] + public void ParseFromFile_SamplePgm_PopulatesToolNames() + { + var service = new FormProgramService(); + var fp = service.ParseFromFile("TestData/sample.pgm"); + Assert.False(string.IsNullOrEmpty(fp.UpperToolNames)); + Assert.False(string.IsNullOrEmpty(fp.LowerToolNames)); + } + + [Fact] + public void ParseFromFile_SamplePgm_ComputesContentHash() + { + var service = new FormProgramService(); + var fp = service.ParseFromFile("TestData/sample.pgm"); + Assert.NotNull(fp.ContentHash); + Assert.Equal(64, fp.ContentHash.Length); // SHA256 hex = 64 chars + } + } +} From e10a7ed0edebe00cc1d595583ce661081040e63e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 06:37:03 -0500 Subject: [PATCH 09/34] feat: add EF migration for FormPrograms table Add initial FabWorksDbContext migration that creates the FormPrograms table with FK to BomItems. Existing tables (ExportRecords, BomItems, CutTemplates) are excluded from the migration since they were already created by ExportDXF's ExportDxfDbContext. Also adds EF Core Design package to FabWorks.Api for migration tooling support. Co-Authored-By: Claude Opus 4.6 --- FabWorks.Api/FabWorks.Api.csproj | 7 + .../20260218113525_InitialCreate.Designer.cs | 258 ++++++++++++++++++ .../20260218113525_InitialCreate.cs | 61 +++++ .../FabWorksDbContextModelSnapshot.cs | 255 +++++++++++++++++ 4 files changed, 581 insertions(+) create mode 100644 FabWorks.Core/Migrations/20260218113525_InitialCreate.Designer.cs create mode 100644 FabWorks.Core/Migrations/20260218113525_InitialCreate.cs create mode 100644 FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs diff --git a/FabWorks.Api/FabWorks.Api.csproj b/FabWorks.Api/FabWorks.Api.csproj index e4f3a2f..921afe9 100644 --- a/FabWorks.Api/FabWorks.Api.csproj +++ b/FabWorks.Api/FabWorks.Api.csproj @@ -10,4 +10,11 @@ + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/FabWorks.Core/Migrations/20260218113525_InitialCreate.Designer.cs b/FabWorks.Core/Migrations/20260218113525_InitialCreate.Designer.cs new file mode 100644 index 0000000..57ec80a --- /dev/null +++ b/FabWorks.Core/Migrations/20260218113525_InitialCreate.Designer.cs @@ -0,0 +1,258 @@ +// +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("20260218113525_InitialCreate")] + partial class InitialCreate + { + /// + 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("ID") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ID")); + + b.Property("ConfigurationName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ExportRecordId") + .HasColumnType("int"); + + b.Property("ItemNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Material") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PartNo") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Qty") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TotalQty") + .HasColumnType("int"); + + b.HasKey("ID"); + + b.HasIndex("ExportRecordId"); + + b.ToTable("BomItems"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BomItemId") + .HasColumnType("int"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CutTemplateName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DefaultBendRadius") + .HasColumnType("float"); + + b.Property("DxfFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("KFactor") + .HasColumnType("float"); + + b.Property("Thickness") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("BomItemId") + .IsUnique(); + + b.ToTable("CutTemplates"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DrawingNumber") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ExportedAt") + .HasColumnType("datetime2"); + + b.Property("ExportedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OutputFolder") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PdfContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.ToTable("ExportRecords"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BendCount") + .HasColumnType("int"); + + b.Property("BomItemId") + .HasColumnType("int"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("KFactor") + .HasColumnType("float"); + + b.Property("LowerToolNames") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaterialType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProgramFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ProgramName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SetupNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Thickness") + .HasColumnType("float"); + + b.Property("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.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.ExportRecord", b => + { + b.Navigation("BomItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FabWorks.Core/Migrations/20260218113525_InitialCreate.cs b/FabWorks.Core/Migrations/20260218113525_InitialCreate.cs new file mode 100644 index 0000000..1a41881 --- /dev/null +++ b/FabWorks.Core/Migrations/20260218113525_InitialCreate.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FabWorks.Core.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // NOTE: ExportRecords, BomItems, and CutTemplates tables already exist + // in the database (created by ExportDXF's ExportDxfDbContext migrations). + // This migration only adds the new FormPrograms table. + + migrationBuilder.CreateTable( + name: "FormPrograms", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ProgramFilePath = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + ContentHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + ProgramName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + Thickness = table.Column(type: "float", nullable: true), + MaterialType = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + KFactor = table.Column(type: "float", nullable: true), + BendCount = table.Column(type: "int", nullable: false), + UpperToolNames = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + LowerToolNames = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + SetupNotes = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + BomItemId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FormPrograms", x => x.Id); + table.ForeignKey( + name: "FK_FormPrograms_BomItems_BomItemId", + column: x => x.BomItemId, + principalTable: "BomItems", + principalColumn: "ID", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_FormPrograms_BomItemId", + table: "FormPrograms", + column: "BomItemId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FormPrograms"); + } + } +} diff --git a/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs b/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs new file mode 100644 index 0000000..1096f92 --- /dev/null +++ b/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs @@ -0,0 +1,255 @@ +// +using System; +using FabWorks.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FabWorks.Core.Migrations +{ + [DbContext(typeof(FabWorksDbContext))] + partial class FabWorksDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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("ID") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ID")); + + b.Property("ConfigurationName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ExportRecordId") + .HasColumnType("int"); + + b.Property("ItemNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Material") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PartNo") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Qty") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TotalQty") + .HasColumnType("int"); + + b.HasKey("ID"); + + b.HasIndex("ExportRecordId"); + + b.ToTable("BomItems"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BomItemId") + .HasColumnType("int"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CutTemplateName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DefaultBendRadius") + .HasColumnType("float"); + + b.Property("DxfFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("KFactor") + .HasColumnType("float"); + + b.Property("Thickness") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("BomItemId") + .IsUnique(); + + b.ToTable("CutTemplates"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DrawingNumber") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ExportedAt") + .HasColumnType("datetime2"); + + b.Property("ExportedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OutputFolder") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PdfContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.ToTable("ExportRecords"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BendCount") + .HasColumnType("int"); + + b.Property("BomItemId") + .HasColumnType("int"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("KFactor") + .HasColumnType("float"); + + b.Property("LowerToolNames") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaterialType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProgramFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ProgramName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SetupNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Thickness") + .HasColumnType("float"); + + b.Property("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.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.ExportRecord", b => + { + b.Navigation("BomItems"); + }); +#pragma warning restore 612, 618 + } + } +} From 2273a83e4266bfbeff8540e37f8b3cb8bd7f26d3 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 20:35:55 -0500 Subject: [PATCH 10/34] refactor: remove local DB and file export from ExportDXF Remove ExportDxfDbContext, EF migrations, FileExportService, and SqlServer/EF Tools packages. ExportDXF will now use the FabWorks API for persistence and file storage instead of direct DB access. Co-Authored-By: Claude Opus 4.6 --- ExportDXF/Data/ExportDxfDbContext.cs | 74 ------- ExportDXF/ExportDXF.csproj | 5 - .../20260214044511_InitialCreate.Designer.cs | 153 -------------- .../20260214044511_InitialCreate.cs | 82 -------- ...60214195856_ExtractCutTemplate.Designer.cs | 188 ------------------ .../20260214195856_ExtractCutTemplate.cs | 114 ----------- .../ExportDxfDbContextModelSnapshot.cs | 185 ----------------- ExportDXF/Services/FileExportService.cs | 124 ------------ ExportDXF/app.config | 7 +- 9 files changed, 1 insertion(+), 931 deletions(-) delete mode 100644 ExportDXF/Data/ExportDxfDbContext.cs delete mode 100644 ExportDXF/Migrations/20260214044511_InitialCreate.Designer.cs delete mode 100644 ExportDXF/Migrations/20260214044511_InitialCreate.cs delete mode 100644 ExportDXF/Migrations/20260214195856_ExtractCutTemplate.Designer.cs delete mode 100644 ExportDXF/Migrations/20260214195856_ExtractCutTemplate.cs delete mode 100644 ExportDXF/Migrations/ExportDxfDbContextModelSnapshot.cs delete mode 100644 ExportDXF/Services/FileExportService.cs diff --git a/ExportDXF/Data/ExportDxfDbContext.cs b/ExportDXF/Data/ExportDxfDbContext.cs deleted file mode 100644 index b0024ac..0000000 --- a/ExportDXF/Data/ExportDxfDbContext.cs +++ /dev/null @@ -1,74 +0,0 @@ -using ExportDXF.Models; -using Microsoft.EntityFrameworkCore; -using System.Configuration; - -namespace ExportDXF.Data -{ - public class ExportDxfDbContext : DbContext - { - public DbSet ExportRecords { get; set; } - public DbSet BomItems { get; set; } - public DbSet CutTemplates { get; set; } - - public ExportDxfDbContext() : base() - { - } - - public ExportDxfDbContext(DbContextOptions options) : base(options) - { - } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - if (!optionsBuilder.IsConfigured) - { - var connectionString = ConfigurationManager.ConnectionStrings["ExportDxfDb"]?.ConnectionString - ?? "Server=localhost;Database=ExportDxfDb;Trusted_Connection=True;TrustServerCertificate=True;"; - optionsBuilder.UseSqlServer(connectionString); - } - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Id); - entity.Property(e => e.DrawingNumber).HasMaxLength(100); - entity.Property(e => e.SourceFilePath).HasMaxLength(500); - entity.Property(e => e.OutputFolder).HasMaxLength(500); - entity.Property(e => e.ExportedBy).HasMaxLength(100); - entity.Property(e => e.PdfContentHash).HasMaxLength(64); - - entity.HasMany(e => e.BomItems) - .WithOne(b => b.ExportRecord) - .HasForeignKey(b => b.ExportRecordId) - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.ID); - entity.Property(e => e.ItemNo).HasMaxLength(50); - entity.Property(e => e.PartNo).HasMaxLength(100); - entity.Property(e => e.Description).HasMaxLength(500); - entity.Property(e => e.PartName).HasMaxLength(200); - entity.Property(e => e.ConfigurationName).HasMaxLength(100); - entity.Property(e => e.Material).HasMaxLength(100); - - entity.HasOne(e => e.CutTemplate) - .WithOne(ct => ct.BomItem) - .HasForeignKey(ct => ct.BomItemId) - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Id); - entity.Property(e => e.CutTemplateName).HasMaxLength(100); - entity.Property(e => e.ContentHash).HasMaxLength(64); - }); - } - } -} diff --git a/ExportDXF/ExportDXF.csproj b/ExportDXF/ExportDXF.csproj index 4a45d38..fd1c36f 100644 --- a/ExportDXF/ExportDXF.csproj +++ b/ExportDXF/ExportDXF.csproj @@ -14,11 +14,6 @@ - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/ExportDXF/Migrations/20260214044511_InitialCreate.Designer.cs b/ExportDXF/Migrations/20260214044511_InitialCreate.Designer.cs deleted file mode 100644 index 4c4858b..0000000 --- a/ExportDXF/Migrations/20260214044511_InitialCreate.Designer.cs +++ /dev/null @@ -1,153 +0,0 @@ -// -using System; -using ExportDXF.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace ExportDXF.Migrations -{ - [DbContext(typeof(ExportDxfDbContext))] - [Migration("20260214044511_InitialCreate")] - partial class InitialCreate - { - /// - 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("ExportDXF.Models.BomItem", b => - { - b.Property("ID") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ID")); - - b.Property("ConfigurationName") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("ContentHash") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CutTemplateName") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("DefaultBendRadius") - .HasColumnType("float"); - - b.Property("Description") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("DxfFilePath") - .HasColumnType("nvarchar(max)"); - - b.Property("ExportRecordId") - .HasColumnType("int"); - - b.Property("ItemNo") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("KFactor") - .HasColumnType("float"); - - b.Property("Material") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("PartName") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("PartNo") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Qty") - .HasColumnType("int"); - - b.Property("SortOrder") - .HasColumnType("int"); - - b.Property("Thickness") - .HasColumnType("float"); - - b.Property("TotalQty") - .HasColumnType("int"); - - b.HasKey("ID"); - - b.HasIndex("ExportRecordId"); - - b.ToTable("BomItems"); - }); - - modelBuilder.Entity("ExportDXF.Models.ExportRecord", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("DrawingNumber") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("ExportedAt") - .HasColumnType("datetime2"); - - b.Property("ExportedBy") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("OutputFolder") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("PdfContentHash") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("SourceFilePath") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.HasKey("Id"); - - b.ToTable("ExportRecords"); - }); - - modelBuilder.Entity("ExportDXF.Models.BomItem", b => - { - b.HasOne("ExportDXF.Models.ExportRecord", "ExportRecord") - .WithMany("BomItems") - .HasForeignKey("ExportRecordId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ExportRecord"); - }); - - modelBuilder.Entity("ExportDXF.Models.ExportRecord", b => - { - b.Navigation("BomItems"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/ExportDXF/Migrations/20260214044511_InitialCreate.cs b/ExportDXF/Migrations/20260214044511_InitialCreate.cs deleted file mode 100644 index e88cc78..0000000 --- a/ExportDXF/Migrations/20260214044511_InitialCreate.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace ExportDXF.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "ExportRecords", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - DrawingNumber = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), - SourceFilePath = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), - OutputFolder = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), - ExportedAt = table.Column(type: "datetime2", nullable: false), - ExportedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), - PdfContentHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_ExportRecords", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "BomItems", - columns: table => new - { - ID = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - ItemNo = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), - PartNo = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), - SortOrder = table.Column(type: "int", nullable: false), - Qty = table.Column(type: "int", nullable: true), - TotalQty = table.Column(type: "int", nullable: true), - Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), - PartName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), - ConfigurationName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), - Material = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), - CutTemplateName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), - DxfFilePath = table.Column(type: "nvarchar(max)", nullable: true), - ContentHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), - Thickness = table.Column(type: "float", nullable: true), - KFactor = table.Column(type: "float", nullable: true), - DefaultBendRadius = table.Column(type: "float", nullable: true), - ExportRecordId = table.Column(type: "int", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_BomItems", x => x.ID); - table.ForeignKey( - name: "FK_BomItems_ExportRecords_ExportRecordId", - column: x => x.ExportRecordId, - principalTable: "ExportRecords", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_BomItems_ExportRecordId", - table: "BomItems", - column: "ExportRecordId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "BomItems"); - - migrationBuilder.DropTable( - name: "ExportRecords"); - } - } -} diff --git a/ExportDXF/Migrations/20260214195856_ExtractCutTemplate.Designer.cs b/ExportDXF/Migrations/20260214195856_ExtractCutTemplate.Designer.cs deleted file mode 100644 index 6c06ec6..0000000 --- a/ExportDXF/Migrations/20260214195856_ExtractCutTemplate.Designer.cs +++ /dev/null @@ -1,188 +0,0 @@ -// -using System; -using ExportDXF.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace ExportDXF.Migrations -{ - [DbContext(typeof(ExportDxfDbContext))] - [Migration("20260214195856_ExtractCutTemplate")] - partial class ExtractCutTemplate - { - /// - 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("ExportDXF.Models.BomItem", b => - { - b.Property("ID") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ID")); - - b.Property("ConfigurationName") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("ExportRecordId") - .HasColumnType("int"); - - b.Property("ItemNo") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Material") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("PartName") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("PartNo") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Qty") - .HasColumnType("int"); - - b.Property("SortOrder") - .HasColumnType("int"); - - b.Property("TotalQty") - .HasColumnType("int"); - - b.HasKey("ID"); - - b.HasIndex("ExportRecordId"); - - b.ToTable("BomItems"); - }); - - modelBuilder.Entity("ExportDXF.Models.CutTemplate", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("BomItemId") - .HasColumnType("int"); - - b.Property("ContentHash") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CutTemplateName") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("DefaultBendRadius") - .HasColumnType("float"); - - b.Property("DxfFilePath") - .HasColumnType("nvarchar(max)"); - - b.Property("KFactor") - .HasColumnType("float"); - - b.Property("Thickness") - .HasColumnType("float"); - - b.HasKey("Id"); - - b.HasIndex("BomItemId") - .IsUnique(); - - b.ToTable("CutTemplates"); - }); - - modelBuilder.Entity("ExportDXF.Models.ExportRecord", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("DrawingNumber") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("ExportedAt") - .HasColumnType("datetime2"); - - b.Property("ExportedBy") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("OutputFolder") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("PdfContentHash") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("SourceFilePath") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.HasKey("Id"); - - b.ToTable("ExportRecords"); - }); - - modelBuilder.Entity("ExportDXF.Models.BomItem", b => - { - b.HasOne("ExportDXF.Models.ExportRecord", "ExportRecord") - .WithMany("BomItems") - .HasForeignKey("ExportRecordId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ExportRecord"); - }); - - modelBuilder.Entity("ExportDXF.Models.CutTemplate", b => - { - b.HasOne("ExportDXF.Models.BomItem", "BomItem") - .WithOne("CutTemplate") - .HasForeignKey("ExportDXF.Models.CutTemplate", "BomItemId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("BomItem"); - }); - - modelBuilder.Entity("ExportDXF.Models.BomItem", b => - { - b.Navigation("CutTemplate"); - }); - - modelBuilder.Entity("ExportDXF.Models.ExportRecord", b => - { - b.Navigation("BomItems"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/ExportDXF/Migrations/20260214195856_ExtractCutTemplate.cs b/ExportDXF/Migrations/20260214195856_ExtractCutTemplate.cs deleted file mode 100644 index bdb1d95..0000000 --- a/ExportDXF/Migrations/20260214195856_ExtractCutTemplate.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace ExportDXF.Migrations -{ - /// - public partial class ExtractCutTemplate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "ContentHash", - table: "BomItems"); - - migrationBuilder.DropColumn( - name: "CutTemplateName", - table: "BomItems"); - - migrationBuilder.DropColumn( - name: "DefaultBendRadius", - table: "BomItems"); - - migrationBuilder.DropColumn( - name: "DxfFilePath", - table: "BomItems"); - - migrationBuilder.DropColumn( - name: "KFactor", - table: "BomItems"); - - migrationBuilder.DropColumn( - name: "Thickness", - table: "BomItems"); - - migrationBuilder.CreateTable( - name: "CutTemplates", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - DxfFilePath = table.Column(type: "nvarchar(max)", nullable: true), - ContentHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), - CutTemplateName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), - Thickness = table.Column(type: "float", nullable: true), - KFactor = table.Column(type: "float", nullable: true), - DefaultBendRadius = table.Column(type: "float", nullable: true), - BomItemId = table.Column(type: "int", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_CutTemplates", x => x.Id); - table.ForeignKey( - name: "FK_CutTemplates_BomItems_BomItemId", - column: x => x.BomItemId, - principalTable: "BomItems", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_CutTemplates_BomItemId", - table: "CutTemplates", - column: "BomItemId", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "CutTemplates"); - - migrationBuilder.AddColumn( - name: "ContentHash", - table: "BomItems", - type: "nvarchar(64)", - maxLength: 64, - nullable: true); - - migrationBuilder.AddColumn( - name: "CutTemplateName", - table: "BomItems", - type: "nvarchar(100)", - maxLength: 100, - nullable: true); - - migrationBuilder.AddColumn( - name: "DefaultBendRadius", - table: "BomItems", - type: "float", - nullable: true); - - migrationBuilder.AddColumn( - name: "DxfFilePath", - table: "BomItems", - type: "nvarchar(max)", - nullable: true); - - migrationBuilder.AddColumn( - name: "KFactor", - table: "BomItems", - type: "float", - nullable: true); - - migrationBuilder.AddColumn( - name: "Thickness", - table: "BomItems", - type: "float", - nullable: true); - } - } -} diff --git a/ExportDXF/Migrations/ExportDxfDbContextModelSnapshot.cs b/ExportDXF/Migrations/ExportDxfDbContextModelSnapshot.cs deleted file mode 100644 index 130ebad..0000000 --- a/ExportDXF/Migrations/ExportDxfDbContextModelSnapshot.cs +++ /dev/null @@ -1,185 +0,0 @@ -// -using System; -using ExportDXF.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace ExportDXF.Migrations -{ - [DbContext(typeof(ExportDxfDbContext))] - partial class ExportDxfDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.11") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("ExportDXF.Models.BomItem", b => - { - b.Property("ID") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ID")); - - b.Property("ConfigurationName") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Description") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("ExportRecordId") - .HasColumnType("int"); - - b.Property("ItemNo") - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Material") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("PartName") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("PartNo") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Qty") - .HasColumnType("int"); - - b.Property("SortOrder") - .HasColumnType("int"); - - b.Property("TotalQty") - .HasColumnType("int"); - - b.HasKey("ID"); - - b.HasIndex("ExportRecordId"); - - b.ToTable("BomItems"); - }); - - modelBuilder.Entity("ExportDXF.Models.CutTemplate", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("BomItemId") - .HasColumnType("int"); - - b.Property("ContentHash") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CutTemplateName") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("DefaultBendRadius") - .HasColumnType("float"); - - b.Property("DxfFilePath") - .HasColumnType("nvarchar(max)"); - - b.Property("KFactor") - .HasColumnType("float"); - - b.Property("Thickness") - .HasColumnType("float"); - - b.HasKey("Id"); - - b.HasIndex("BomItemId") - .IsUnique(); - - b.ToTable("CutTemplates"); - }); - - modelBuilder.Entity("ExportDXF.Models.ExportRecord", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("DrawingNumber") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("ExportedAt") - .HasColumnType("datetime2"); - - b.Property("ExportedBy") - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("OutputFolder") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("PdfContentHash") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("SourceFilePath") - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.HasKey("Id"); - - b.ToTable("ExportRecords"); - }); - - modelBuilder.Entity("ExportDXF.Models.BomItem", b => - { - b.HasOne("ExportDXF.Models.ExportRecord", "ExportRecord") - .WithMany("BomItems") - .HasForeignKey("ExportRecordId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ExportRecord"); - }); - - modelBuilder.Entity("ExportDXF.Models.CutTemplate", b => - { - b.HasOne("ExportDXF.Models.BomItem", "BomItem") - .WithOne("CutTemplate") - .HasForeignKey("ExportDXF.Models.CutTemplate", "BomItemId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("BomItem"); - }); - - modelBuilder.Entity("ExportDXF.Models.BomItem", b => - { - b.Navigation("CutTemplate"); - }); - - modelBuilder.Entity("ExportDXF.Models.ExportRecord", b => - { - b.Navigation("BomItems"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/ExportDXF/Services/FileExportService.cs b/ExportDXF/Services/FileExportService.cs deleted file mode 100644 index 80482ee..0000000 --- a/ExportDXF/Services/FileExportService.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.IO; - -namespace ExportDXF.Services -{ - public interface IFileExportService - { - string OutputFolder { get; } - string GetDrawingOutputFolder(string equipment, string drawingNo); - string SaveDxfFile(string sourcePath, string drawingNumber, string itemNo); - string SavePdfFile(string sourcePath, string drawingNumber, string outputFolder = null); - void EnsureOutputFolderExists(); - string StashFile(string filePath); - void ArchiveFile(string stashPath, string originalPath); - void DiscardStash(string stashPath); - } - - public class FileExportService : IFileExportService - { - public string OutputFolder { get; } - - public FileExportService(string outputFolder) - { - OutputFolder = outputFolder ?? throw new ArgumentNullException(nameof(outputFolder)); - EnsureOutputFolderExists(); - } - - public void EnsureOutputFolderExists() - { - if (!Directory.Exists(OutputFolder)) - { - Directory.CreateDirectory(OutputFolder); - } - } - - public string GetDrawingOutputFolder(string equipment, string drawingNo) - { - if (string.IsNullOrEmpty(equipment) || string.IsNullOrEmpty(drawingNo)) - return OutputFolder; - - var folder = Path.Combine(OutputFolder, equipment, drawingNo); - if (!Directory.Exists(folder)) - Directory.CreateDirectory(folder); - - return folder; - } - - public string SaveDxfFile(string sourcePath, string drawingNumber, string itemNo) - { - if (string.IsNullOrEmpty(sourcePath)) - throw new ArgumentNullException(nameof(sourcePath)); - - var fileName = !string.IsNullOrEmpty(drawingNumber) && !string.IsNullOrEmpty(itemNo) - ? $"{drawingNumber} PT{itemNo}.dxf" - : Path.GetFileName(sourcePath); - - var destPath = Path.Combine(OutputFolder, fileName); - - // If source and dest are the same, skip copy - if (!string.Equals(sourcePath, destPath, StringComparison.OrdinalIgnoreCase)) - { - File.Copy(sourcePath, destPath, overwrite: true); - } - - return destPath; - } - - public string SavePdfFile(string sourcePath, string drawingNumber, string outputFolder = null) - { - if (string.IsNullOrEmpty(sourcePath)) - throw new ArgumentNullException(nameof(sourcePath)); - - var folder = outputFolder ?? OutputFolder; - var fileName = !string.IsNullOrEmpty(drawingNumber) - ? $"{drawingNumber}.pdf" - : Path.GetFileName(sourcePath); - - var destPath = Path.Combine(folder, fileName); - - // If source and dest are the same, skip copy - if (!string.Equals(sourcePath, destPath, StringComparison.OrdinalIgnoreCase)) - { - File.Copy(sourcePath, destPath, overwrite: true); - } - - return destPath; - } - - public string StashFile(string filePath) - { - if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) - return null; - - var stashPath = filePath + ".bak"; - File.Move(filePath, stashPath, overwrite: true); - return stashPath; - } - - public void ArchiveFile(string stashPath, string originalPath) - { - if (string.IsNullOrEmpty(stashPath) || !File.Exists(stashPath)) - return; - - var fileDir = Path.GetDirectoryName(originalPath) ?? OutputFolder; - var archiveDir = Path.Combine(fileDir, "_archive"); - if (!Directory.Exists(archiveDir)) - Directory.CreateDirectory(archiveDir); - - var originalName = Path.GetFileNameWithoutExtension(originalPath); - var ext = Path.GetExtension(originalPath); - var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); - var archiveName = $"{originalName} [{timestamp}]{ext}"; - var archivePath = Path.Combine(archiveDir, archiveName); - - File.Move(stashPath, archivePath, overwrite: true); - } - - public void DiscardStash(string stashPath) - { - if (!string.IsNullOrEmpty(stashPath) && File.Exists(stashPath)) - File.Delete(stashPath); - } - } -} diff --git a/ExportDXF/app.config b/ExportDXF/app.config index f67a830..169406a 100644 --- a/ExportDXF/app.config +++ b/ExportDXF/app.config @@ -5,11 +5,6 @@ - + - - - From f75b83d4832a9032ac6e7ae017ff658056050181 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 20:36:06 -0500 Subject: [PATCH 11/34] feat: add FabWorks API client for ExportDXF Add IFabWorksApiClient interface, FabWorksApiClient implementation, and DTO classes for communicating with the FabWorks API from the SolidWorks add-in. Co-Authored-By: Claude Opus 4.6 --- ExportDXF/ApiClient/FabWorksApiClient.cs | 157 ++++++++++++++++++++++ ExportDXF/ApiClient/FabWorksApiDtos.cs | 85 ++++++++++++ ExportDXF/ApiClient/IFabWorksApiClient.cs | 22 +++ 3 files changed, 264 insertions(+) create mode 100644 ExportDXF/ApiClient/FabWorksApiClient.cs create mode 100644 ExportDXF/ApiClient/FabWorksApiDtos.cs create mode 100644 ExportDXF/ApiClient/IFabWorksApiClient.cs diff --git a/ExportDXF/ApiClient/FabWorksApiClient.cs b/ExportDXF/ApiClient/FabWorksApiClient.cs new file mode 100644 index 0000000..e608e55 --- /dev/null +++ b/ExportDXF/ApiClient/FabWorksApiClient.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; + +namespace ExportDXF.ApiClient +{ + public class FabWorksApiClient : IFabWorksApiClient + { + private readonly HttpClient _http; + + public FabWorksApiClient(HttpClient httpClient) + { + _http = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public async Task CreateExportAsync(string drawingNumber, string equipmentNo, string drawingNo, string sourceFilePath, string outputFolder, string title = null) + { + var request = new ApiCreateExportRequest + { + DrawingNumber = drawingNumber, + Title = title, + EquipmentNo = equipmentNo, + DrawingNo = drawingNo, + SourceFilePath = sourceFilePath, + OutputFolder = outputFolder + }; + + var response = await _http.PostAsJsonAsync("api/exports", request); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + public async Task GetExportBySourceFileAsync(string filePath) + { + var response = await _http.GetAsync($"api/exports/by-source?path={Uri.EscapeDataString(filePath)}"); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + public async Task> GetDrawingNumbersAsync() + { + var response = await _http.GetAsync("api/exports/drawing-numbers"); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync>(); + } + + public async Task> GetEquipmentNumbersAsync() + { + var response = await _http.GetAsync("api/exports/equipment-numbers"); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync>(); + } + + public async Task> GetDrawingNumbersByEquipmentAsync(string equipmentNo = null) + { + var url = "api/exports/drawing-numbers-by-equipment"; + if (!string.IsNullOrEmpty(equipmentNo)) + url += $"?equipmentNo={Uri.EscapeDataString(equipmentNo)}"; + + var response = await _http.GetAsync(url); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync>(); + } + + public async Task GetNextItemNumberAsync(string drawingNumber) + { + var response = await _http.GetAsync($"api/exports/next-item-number?drawingNumber={Uri.EscapeDataString(drawingNumber)}"); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } + + public async Task UpdatePdfHashAsync(int exportId, string pdfContentHash) + { + var request = new ApiUpdatePdfHashRequest { PdfContentHash = pdfContentHash }; + var response = await _http.PatchAsJsonAsync($"api/exports/{exportId}/pdf-hash", request); + response.EnsureSuccessStatusCode(); + } + + public async Task GetPreviousPdfHashAsync(string drawingNumber, int? excludeId = null) + { + var url = $"api/exports/previous-pdf-hash?drawingNumber={Uri.EscapeDataString(drawingNumber)}"; + if (excludeId.HasValue) + url += $"&excludeId={excludeId.Value}"; + + var response = await _http.GetAsync(url); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } + + public async Task FindExistingBomItemAsync(int exportId, string partName, string configurationName) + { + var url = $"api/exports/{exportId}/bom-items/find?partName={Uri.EscapeDataString(partName ?? "")}&configurationName={Uri.EscapeDataString(configurationName ?? "")}"; + var response = await _http.GetAsync(url); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + public async Task CreateBomItemAsync(int exportId, ApiBomItem bomItem) + { + var response = await _http.PostAsJsonAsync($"api/exports/{exportId}/bom-items", bomItem); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + public async Task GetPreviousCutTemplateAsync(string drawingNumber, string itemNo) + { + var response = await _http.GetAsync($"api/exports/previous-cut-template?drawingNumber={Uri.EscapeDataString(drawingNumber)}&itemNo={Uri.EscapeDataString(itemNo)}"); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + public async Task UploadDxfAsync(string localFilePath, string equipment, string drawingNo, string itemNo, string contentHash) + { + using var content = new MultipartFormDataContent(); + using var fileStream = new FileStream(localFilePath, FileMode.Open, FileAccess.Read); + var fileContent = new StreamContent(fileStream); + content.Add(fileContent, "file", Path.GetFileName(localFilePath)); + content.Add(new StringContent(equipment ?? ""), "equipment"); + content.Add(new StringContent(drawingNo ?? ""), "drawingNo"); + content.Add(new StringContent(itemNo ?? ""), "itemNo"); + content.Add(new StringContent(contentHash ?? ""), "contentHash"); + + var response = await _http.PostAsync("api/files/dxf", content); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + public async Task UploadPdfAsync(string localFilePath, string equipment, string drawingNo, string contentHash, int? exportRecordId = null) + { + using var content = new MultipartFormDataContent(); + using var fileStream = new FileStream(localFilePath, FileMode.Open, FileAccess.Read); + var fileContent = new StreamContent(fileStream); + content.Add(fileContent, "file", Path.GetFileName(localFilePath)); + content.Add(new StringContent(equipment ?? ""), "equipment"); + content.Add(new StringContent(drawingNo ?? ""), "drawingNo"); + content.Add(new StringContent(contentHash ?? ""), "contentHash"); + if (exportRecordId.HasValue) + content.Add(new StringContent(exportRecordId.Value.ToString()), "exportRecordId"); + + var response = await _http.PostAsync("api/files/pdf", content); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + } +} diff --git a/ExportDXF/ApiClient/FabWorksApiDtos.cs b/ExportDXF/ApiClient/FabWorksApiDtos.cs new file mode 100644 index 0000000..2893306 --- /dev/null +++ b/ExportDXF/ApiClient/FabWorksApiDtos.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; + +namespace ExportDXF.ApiClient +{ + public class ApiExportDetail + { + public int Id { get; set; } + public string DrawingNumber { get; set; } + public string Title { get; set; } + public string EquipmentNo { get; set; } + public string DrawingNo { get; set; } + public string SourceFilePath { get; set; } + public string OutputFolder { get; set; } + public DateTime ExportedAt { get; set; } + public string ExportedBy { get; set; } + public string PdfContentHash { get; set; } + public List BomItems { get; set; } = new(); + } + + public class ApiBomItem + { + public int ID { get; set; } + public string ItemNo { get; set; } + public string PartNo { get; set; } + public int SortOrder { get; set; } + public int? Qty { get; set; } + public int? TotalQty { get; set; } + public string Description { get; set; } + public string PartName { get; set; } + public string ConfigurationName { get; set; } + public string Material { get; set; } + public ApiCutTemplate CutTemplate { get; set; } + public ApiFormProgram FormProgram { get; set; } + } + + public class ApiCutTemplate + { + public int Id { get; set; } + public string DxfFilePath { get; set; } + public string ContentHash { get; set; } + public double? Thickness { get; set; } + public double? KFactor { get; set; } + public double? DefaultBendRadius { get; set; } + } + + public class ApiFormProgram + { + public int Id { get; set; } + public string ProgramFilePath { get; set; } + public string ContentHash { get; set; } + public string ProgramName { get; set; } + public double? Thickness { get; set; } + public string MaterialType { get; set; } + public double? KFactor { get; set; } + public int BendCount { get; set; } + public string UpperToolNames { get; set; } + public string LowerToolNames { get; set; } + public string SetupNotes { get; set; } + } + + public class ApiCreateExportRequest + { + public string DrawingNumber { get; set; } + public string Title { get; set; } + public string EquipmentNo { get; set; } + public string DrawingNo { get; set; } + public string SourceFilePath { get; set; } + public string OutputFolder { get; set; } + } + + public class ApiUpdatePdfHashRequest + { + public string PdfContentHash { get; set; } + } + + public class ApiFileUploadResponse + { + public string StoredFilePath { get; set; } + public string ContentHash { get; set; } + public string FileName { get; set; } + public bool WasUnchanged { get; set; } + public bool IsNewFile { get; set; } + } +} diff --git a/ExportDXF/ApiClient/IFabWorksApiClient.cs b/ExportDXF/ApiClient/IFabWorksApiClient.cs new file mode 100644 index 0000000..45caaca --- /dev/null +++ b/ExportDXF/ApiClient/IFabWorksApiClient.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace ExportDXF.ApiClient +{ + public interface IFabWorksApiClient + { + Task CreateExportAsync(string drawingNumber, string equipmentNo, string drawingNo, string sourceFilePath, string outputFolder, string title = null); + Task GetExportBySourceFileAsync(string filePath); + Task> GetDrawingNumbersAsync(); + Task> GetEquipmentNumbersAsync(); + Task> GetDrawingNumbersByEquipmentAsync(string equipmentNo = null); + Task GetNextItemNumberAsync(string drawingNumber); + Task UpdatePdfHashAsync(int exportId, string pdfContentHash); + Task GetPreviousPdfHashAsync(string drawingNumber, int? excludeId = null); + Task FindExistingBomItemAsync(int exportId, string partName, string configurationName); + Task CreateBomItemAsync(int exportId, ApiBomItem bomItem); + Task GetPreviousCutTemplateAsync(string drawingNumber, string itemNo); + Task UploadDxfAsync(string localFilePath, string equipment, string drawingNo, string itemNo, string contentHash); + Task UploadPdfAsync(string localFilePath, string equipment, string drawingNo, string contentHash, int? exportRecordId = null); + } +} From dba68ecc7175b68583cbccbd99f8d7c20c0a4111 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 20:36:18 -0500 Subject: [PATCH 12/34] feat: add file storage service with content-addressed blob store Add FileStorageService for DXF/PDF storage using content hashing, FileStorageOptions config, FilesController for uploads, and FileBrowserController for browsing stored files. Co-Authored-By: Claude Opus 4.6 --- .../Configuration/FileStorageOptions.cs | 9 + .../Controllers/FileBrowserController.cs | 184 ++++++++++++++++++ FabWorks.Api/Controllers/FilesController.cs | 93 +++++++++ FabWorks.Api/DTOs/FileUploadResponse.cs | 11 ++ FabWorks.Api/Properties/launchSettings.json | 6 +- FabWorks.Api/Services/FileStorageService.cs | 148 ++++++++++++++ FabWorks.Api/appsettings.json | 3 + 7 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 FabWorks.Api/Configuration/FileStorageOptions.cs create mode 100644 FabWorks.Api/Controllers/FileBrowserController.cs create mode 100644 FabWorks.Api/Controllers/FilesController.cs create mode 100644 FabWorks.Api/DTOs/FileUploadResponse.cs create mode 100644 FabWorks.Api/Services/FileStorageService.cs diff --git a/FabWorks.Api/Configuration/FileStorageOptions.cs b/FabWorks.Api/Configuration/FileStorageOptions.cs new file mode 100644 index 0000000..8204f99 --- /dev/null +++ b/FabWorks.Api/Configuration/FileStorageOptions.cs @@ -0,0 +1,9 @@ +namespace FabWorks.Api.Configuration +{ + public class FileStorageOptions + { + public const string SectionName = "FileStorage"; + + public string OutputFolder { get; set; } = @"C:\ExportDXF\Output"; + } +} diff --git a/FabWorks.Api/Controllers/FileBrowserController.cs b/FabWorks.Api/Controllers/FileBrowserController.cs new file mode 100644 index 0000000..895b20f --- /dev/null +++ b/FabWorks.Api/Controllers/FileBrowserController.cs @@ -0,0 +1,184 @@ +using FabWorks.Api.Services; +using FabWorks.Core.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.EntityFrameworkCore; + +namespace FabWorks.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class FileBrowserController : ControllerBase + { + private readonly IFileStorageService _fileStorage; + private readonly FabWorksDbContext _db; + private readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); + + public FileBrowserController(IFileStorageService fileStorage, FabWorksDbContext db) + { + _fileStorage = fileStorage; + _db = db; + } + + [HttpGet("files")] + public async Task> ListFiles( + [FromQuery] string search = null, + [FromQuery] string type = null) + { + var files = new List(); + + // Query DXF files from CutTemplates + if (type == null || type.Equals("dxf", StringComparison.OrdinalIgnoreCase)) + { + var dxfQuery = _db.CutTemplates + .Where(c => c.ContentHash != null) + .Select(c => new + { + c.Id, + c.DxfFilePath, + c.ContentHash, + c.Thickness, + DrawingNumber = c.BomItem.ExportRecord.DrawingNumber, + CreatedAt = c.BomItem.ExportRecord.ExportedAt + }); + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim().ToLower(); + dxfQuery = dxfQuery.Where(c => + c.DxfFilePath.ToLower().Contains(term) || + c.DrawingNumber.ToLower().Contains(term)); + } + + var dxfResults = await dxfQuery + .OrderByDescending(c => c.CreatedAt) + .Take(500) + .ToListAsync(); + + // Deduplicate by content hash (keep latest) + var seenDxf = new HashSet(); + foreach (var c in dxfResults) + { + if (seenDxf.Contains(c.ContentHash)) continue; + seenDxf.Add(c.ContentHash); + + var fileName = c.DxfFilePath?.Split(new[] { '/', '\\' }).LastOrDefault() ?? c.DxfFilePath; + files.Add(new StoredFileEntry + { + FileName = fileName, + ContentHash = c.ContentHash, + FileType = "dxf", + DrawingNumber = c.DrawingNumber, + Thickness = c.Thickness, + CreatedAt = c.CreatedAt + }); + } + } + + // Query PDF files from ExportRecords + if (type == null || type.Equals("pdf", StringComparison.OrdinalIgnoreCase)) + { + var pdfQuery = _db.ExportRecords + .Where(r => r.PdfContentHash != null) + .Select(r => new + { + r.Id, + r.DrawingNumber, + r.PdfContentHash, + r.ExportedAt + }); + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim().ToLower(); + pdfQuery = pdfQuery.Where(r => + r.DrawingNumber.ToLower().Contains(term)); + } + + var pdfResults = await pdfQuery + .OrderByDescending(r => r.ExportedAt) + .Take(500) + .ToListAsync(); + + // Deduplicate by content hash + var seenPdf = new HashSet(); + foreach (var r in pdfResults) + { + if (seenPdf.Contains(r.PdfContentHash)) continue; + seenPdf.Add(r.PdfContentHash); + + files.Add(new StoredFileEntry + { + FileName = $"{r.DrawingNumber}.pdf", + ContentHash = r.PdfContentHash, + FileType = "pdf", + DrawingNumber = r.DrawingNumber, + CreatedAt = r.ExportedAt + }); + } + } + + return new FileListResult + { + Total = files.Count, + Files = files.OrderByDescending(f => f.CreatedAt).ToList() + }; + } + + [HttpGet("preview")] + public IActionResult PreviewFile([FromQuery] string hash, [FromQuery] string ext = "dxf") + { + if (string.IsNullOrEmpty(hash) || hash.Length < 4) + return BadRequest("Invalid hash."); + + if (!_fileStorage.BlobExists(hash, ext)) + return NotFound("File not found."); + + var stream = _fileStorage.OpenBlob(hash, ext); + if (stream == null) + return NotFound("File not found."); + + var virtualName = $"file.{ext}"; + if (!_contentTypeProvider.TryGetContentType(virtualName, out var contentType)) + contentType = "application/octet-stream"; + + return File(stream, contentType); + } + + [HttpGet("download")] + public IActionResult DownloadFile([FromQuery] string hash, [FromQuery] string ext = "dxf", [FromQuery] string name = null) + { + if (string.IsNullOrEmpty(hash) || hash.Length < 4) + return BadRequest("Invalid hash."); + + if (!_fileStorage.BlobExists(hash, ext)) + return NotFound("File not found."); + + var stream = _fileStorage.OpenBlob(hash, ext); + if (stream == null) + return NotFound("File not found."); + + var fileName = name ?? $"{hash[..8]}.{ext}"; + if (!_contentTypeProvider.TryGetContentType(fileName, out var contentType)) + contentType = "application/octet-stream"; + + return File(stream, contentType, fileName); + } + } + + public class FileListResult + { + public int Total { get; set; } + public List Files { get; set; } + } + + public class StoredFileEntry + { + public string FileName { get; set; } + public string ContentHash { get; set; } + public string FileType { get; set; } + public string DrawingNumber { get; set; } + public double? Thickness { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/FabWorks.Api/Controllers/FilesController.cs b/FabWorks.Api/Controllers/FilesController.cs new file mode 100644 index 0000000..b8263f1 --- /dev/null +++ b/FabWorks.Api/Controllers/FilesController.cs @@ -0,0 +1,93 @@ +using FabWorks.Api.DTOs; +using FabWorks.Api.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; + +namespace FabWorks.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class FilesController : ControllerBase + { + private readonly IFileStorageService _fileStorage; + private readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); + + public FilesController(IFileStorageService fileStorage) + { + _fileStorage = fileStorage; + } + + [HttpPost("dxf")] + [RequestSizeLimit(50_000_000)] // 50 MB + public async Task> UploadDxf( + IFormFile file, + [FromForm] string equipment, + [FromForm] string drawingNo, + [FromForm] string itemNo, + [FromForm] string contentHash) + { + if (file == null || file.Length == 0) + return BadRequest("No file uploaded."); + + using var stream = file.OpenReadStream(); + var result = await _fileStorage.StoreDxfAsync(stream, equipment, drawingNo, itemNo, contentHash); + + return Ok(new FileUploadResponse + { + StoredFilePath = result.FileName, + ContentHash = result.ContentHash, + FileName = result.FileName, + WasUnchanged = result.WasUnchanged, + IsNewFile = result.IsNewFile + }); + } + + [HttpPost("pdf")] + [RequestSizeLimit(100_000_000)] // 100 MB + public async Task> UploadPdf( + IFormFile file, + [FromForm] string equipment, + [FromForm] string drawingNo, + [FromForm] string contentHash, + [FromForm] int? exportRecordId = null) + { + if (file == null || file.Length == 0) + return BadRequest("No file uploaded."); + + using var stream = file.OpenReadStream(); + var result = await _fileStorage.StorePdfAsync(stream, equipment, drawingNo, contentHash, exportRecordId); + + return Ok(new FileUploadResponse + { + StoredFilePath = result.FileName, + ContentHash = result.ContentHash, + FileName = result.FileName, + WasUnchanged = result.WasUnchanged, + IsNewFile = result.IsNewFile + }); + } + + [HttpGet("blob/{hash}")] + public IActionResult GetBlob(string hash, [FromQuery] string ext = "dxf", [FromQuery] bool download = false, [FromQuery] string name = null) + { + if (string.IsNullOrEmpty(hash) || hash.Length < 4) + return BadRequest("Invalid hash."); + + if (!_fileStorage.BlobExists(hash, ext)) + return NotFound("Blob not found."); + + var stream = _fileStorage.OpenBlob(hash, ext); + if (stream == null) + return NotFound("Blob not found."); + + var fileName = !string.IsNullOrEmpty(name) ? name : $"{hash[..8]}.{ext}"; + if (!_contentTypeProvider.TryGetContentType(fileName, out var contentType)) + contentType = "application/octet-stream"; + + if (download) + return File(stream, contentType, fileName); + + return File(stream, contentType); + } + } +} diff --git a/FabWorks.Api/DTOs/FileUploadResponse.cs b/FabWorks.Api/DTOs/FileUploadResponse.cs new file mode 100644 index 0000000..7a1fad0 --- /dev/null +++ b/FabWorks.Api/DTOs/FileUploadResponse.cs @@ -0,0 +1,11 @@ +namespace FabWorks.Api.DTOs +{ + public class FileUploadResponse + { + public string StoredFilePath { get; set; } // kept for client compat, contains logical filename + public string ContentHash { get; set; } + public string FileName { get; set; } + public bool WasUnchanged { get; set; } + public bool IsNewFile { get; set; } + } +} diff --git a/FabWorks.Api/Properties/launchSettings.json b/FabWorks.Api/Properties/launchSettings.json index b41988c..65e9483 100644 --- a/FabWorks.Api/Properties/launchSettings.json +++ b/FabWorks.Api/Properties/launchSettings.json @@ -13,7 +13,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "", "applicationUrl": "http://localhost:5206", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -23,7 +23,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "", "applicationUrl": "https://localhost:7182;http://localhost:5206", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -32,7 +32,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/FabWorks.Api/Services/FileStorageService.cs b/FabWorks.Api/Services/FileStorageService.cs new file mode 100644 index 0000000..f55231e --- /dev/null +++ b/FabWorks.Api/Services/FileStorageService.cs @@ -0,0 +1,148 @@ +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 StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash); + Task 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 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 StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash) + { + var fileName = BuildDxfFileName(drawingNo, equipment, itemNo); + + // 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 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"); + + 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 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) + { + 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"; + } + } +} diff --git a/FabWorks.Api/appsettings.json b/FabWorks.Api/appsettings.json index 7326dc1..b9b4456 100644 --- a/FabWorks.Api/appsettings.json +++ b/FabWorks.Api/appsettings.json @@ -8,5 +8,8 @@ "AllowedHosts": "*", "ConnectionStrings": { "FabWorksDb": "Server=localhost;Database=ExportDxfDb;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "FileStorage": { + "OutputFolder": "C:\\ExportDXF\\Output" } } From 8b6950ef28b272d6f7068ee57b8888a24bbd3e11 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 20:36:30 -0500 Subject: [PATCH 13/34] feat: add Title, EquipmentNo, DrawingNo to ExportRecord Add separate EquipmentNo and DrawingNo fields alongside the combined DrawingNumber, plus a Title field for labeling exports. Updated across Core model, DbContext, API DTOs, and ExportDXF models. Co-Authored-By: Claude Opus 4.6 --- ExportDXF/Models/ExportContext.cs | 5 +++++ ExportDXF/Models/ExportRecord.cs | 2 ++ FabWorks.Api/DTOs/CreateExportRequest.cs | 8 ++++++++ FabWorks.Api/DTOs/ExportDetailDto.cs | 3 +++ FabWorks.Core/Data/FabWorksDbContext.cs | 3 +++ FabWorks.Core/Models/ExportRecord.cs | 3 +++ 6 files changed, 24 insertions(+) diff --git a/ExportDXF/Models/ExportContext.cs b/ExportDXF/Models/ExportContext.cs index f04ee65..8a0ab2f 100644 --- a/ExportDXF/Models/ExportContext.cs +++ b/ExportDXF/Models/ExportContext.cs @@ -42,6 +42,11 @@ namespace ExportDXF.Services /// public string DrawingNo { get; set; } + /// + /// Optional title/label for the export. + /// + public string Title { get; set; } + /// /// Selected Equipment ID for API operations (optional). /// diff --git a/ExportDXF/Models/ExportRecord.cs b/ExportDXF/Models/ExportRecord.cs index 8ada5f1..4e6adda 100644 --- a/ExportDXF/Models/ExportRecord.cs +++ b/ExportDXF/Models/ExportRecord.cs @@ -7,6 +7,8 @@ namespace ExportDXF.Models { public int Id { get; set; } public string DrawingNumber { get; set; } + public string EquipmentNo { get; set; } + public string DrawingNo { get; set; } public string SourceFilePath { get; set; } public string OutputFolder { get; set; } public DateTime ExportedAt { get; set; } diff --git a/FabWorks.Api/DTOs/CreateExportRequest.cs b/FabWorks.Api/DTOs/CreateExportRequest.cs index 6cb0d6f..db9e94d 100644 --- a/FabWorks.Api/DTOs/CreateExportRequest.cs +++ b/FabWorks.Api/DTOs/CreateExportRequest.cs @@ -3,7 +3,15 @@ namespace FabWorks.Api.DTOs public class CreateExportRequest { public string DrawingNumber { get; set; } + public string Title { get; set; } + public string EquipmentNo { get; set; } + public string DrawingNo { get; set; } public string SourceFilePath { get; set; } public string OutputFolder { get; set; } } + + public class UpdatePdfHashRequest + { + public string PdfContentHash { get; set; } + } } diff --git a/FabWorks.Api/DTOs/ExportDetailDto.cs b/FabWorks.Api/DTOs/ExportDetailDto.cs index a8efd81..870ae52 100644 --- a/FabWorks.Api/DTOs/ExportDetailDto.cs +++ b/FabWorks.Api/DTOs/ExportDetailDto.cs @@ -7,6 +7,9 @@ namespace FabWorks.Api.DTOs { public int Id { get; set; } public string DrawingNumber { get; set; } + public string Title { get; set; } + public string EquipmentNo { get; set; } + public string DrawingNo { get; set; } public string SourceFilePath { get; set; } public string OutputFolder { get; set; } public DateTime ExportedAt { get; set; } diff --git a/FabWorks.Core/Data/FabWorksDbContext.cs b/FabWorks.Core/Data/FabWorksDbContext.cs index c0a16fc..f2d6a40 100644 --- a/FabWorks.Core/Data/FabWorksDbContext.cs +++ b/FabWorks.Core/Data/FabWorksDbContext.cs @@ -20,6 +20,9 @@ namespace FabWorks.Core.Data { entity.HasKey(e => e.Id); entity.Property(e => e.DrawingNumber).HasMaxLength(100); + entity.Property(e => e.Title).HasMaxLength(200); + entity.Property(e => e.EquipmentNo).HasMaxLength(50); + entity.Property(e => e.DrawingNo).HasMaxLength(50); entity.Property(e => e.SourceFilePath).HasMaxLength(500); entity.Property(e => e.OutputFolder).HasMaxLength(500); entity.Property(e => e.ExportedBy).HasMaxLength(100); diff --git a/FabWorks.Core/Models/ExportRecord.cs b/FabWorks.Core/Models/ExportRecord.cs index 90dfd79..29b5937 100644 --- a/FabWorks.Core/Models/ExportRecord.cs +++ b/FabWorks.Core/Models/ExportRecord.cs @@ -7,6 +7,9 @@ namespace FabWorks.Core.Models { public int Id { get; set; } public string DrawingNumber { get; set; } + public string Title { get; set; } + public string EquipmentNo { get; set; } + public string DrawingNo { get; set; } public string SourceFilePath { get; set; } public string OutputFolder { get; set; } public DateTime ExportedAt { get; set; } From 8de441e126074c82e74e3ca05382415b5afe9ffb Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 20:36:42 -0500 Subject: [PATCH 14/34] feat: expand ExportsController with search and file endpoints Add list/search, equipment/drawing number lookups, PDF hash tracking, cut template lookup, DXF zip download, and wire up FileStorageService and static files in Program.cs. Co-Authored-By: Claude Opus 4.6 --- FabWorks.Api/Controllers/ExportsController.cs | 210 +++++++++++++++++- FabWorks.Api/Program.cs | 7 + 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/FabWorks.Api/Controllers/ExportsController.cs b/FabWorks.Api/Controllers/ExportsController.cs index 743c857..1873aee 100644 --- a/FabWorks.Api/Controllers/ExportsController.cs +++ b/FabWorks.Api/Controllers/ExportsController.cs @@ -1,4 +1,6 @@ +using System.IO.Compression; using FabWorks.Api.DTOs; +using FabWorks.Api.Services; using FabWorks.Core.Data; using FabWorks.Core.Models; using Microsoft.AspNetCore.Mvc; @@ -11,8 +13,58 @@ namespace FabWorks.Api.Controllers public class ExportsController : ControllerBase { private readonly FabWorksDbContext _db; + private readonly IFileStorageService _fileStorage; - public ExportsController(FabWorksDbContext db) => _db = db; + public ExportsController(FabWorksDbContext db, IFileStorageService fileStorage) + { + _db = db; + _fileStorage = fileStorage; + } + + [HttpGet] + public async Task> List( + [FromQuery] string search = null, + [FromQuery] int skip = 0, + [FromQuery] int take = 50) + { + var query = _db.ExportRecords + .Include(r => r.BomItems) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim().ToLower(); + query = query.Where(r => + r.DrawingNumber.ToLower().Contains(term) || + (r.Title != null && r.Title.ToLower().Contains(term)) || + r.ExportedBy.ToLower().Contains(term) || + r.BomItems.Any(b => b.PartName.ToLower().Contains(term) || + b.Description.ToLower().Contains(term))); + } + + var total = await query.CountAsync(); + + var records = await query + .OrderByDescending(r => r.ExportedAt) + .Skip(skip) + .Take(take) + .ToListAsync(); + + return new + { + total, + items = records.Select(r => new + { + r.Id, + r.DrawingNumber, + r.Title, + r.SourceFilePath, + r.ExportedAt, + r.ExportedBy, + BomItemCount = r.BomItems?.Count ?? 0 + }) + }; + } [HttpPost] public async Task> Create(CreateExportRequest request) @@ -20,6 +72,9 @@ namespace FabWorks.Api.Controllers var record = new ExportRecord { DrawingNumber = request.DrawingNumber, + Title = request.Title, + EquipmentNo = request.EquipmentNo, + DrawingNo = request.DrawingNo, SourceFilePath = request.SourceFilePath, OutputFolder = request.OutputFolder, ExportedAt = DateTime.Now, @@ -90,10 +145,163 @@ namespace FabWorks.Api.Controllers return (maxNum + 1).ToString(); } + [HttpGet("drawing-numbers")] + public async Task>> GetDrawingNumbers() + { + var numbers = await _db.ExportRecords + .Select(r => r.DrawingNumber) + .Where(d => !string.IsNullOrEmpty(d)) + .Distinct() + .ToListAsync(); + + return numbers; + } + + [HttpGet("equipment-numbers")] + public async Task>> GetEquipmentNumbers() + { + var numbers = await _db.ExportRecords + .Select(r => r.EquipmentNo) + .Where(e => !string.IsNullOrEmpty(e)) + .Distinct() + .OrderBy(e => e) + .ToListAsync(); + + return numbers; + } + + [HttpGet("drawing-numbers-by-equipment")] + public async Task>> GetDrawingNumbersByEquipment([FromQuery] string equipmentNo) + { + var query = _db.ExportRecords + .Where(r => !string.IsNullOrEmpty(r.DrawingNo)); + + if (!string.IsNullOrEmpty(equipmentNo)) + query = query.Where(r => r.EquipmentNo == equipmentNo); + + var numbers = await query + .Select(r => r.DrawingNo) + .Distinct() + .OrderBy(d => d) + .ToListAsync(); + + return numbers; + } + + [HttpGet("previous-pdf-hash")] + public async Task> GetPreviousPdfHash( + [FromQuery] string drawingNumber, + [FromQuery] int? excludeId = null) + { + var hash = await _db.ExportRecords + .Where(r => r.DrawingNumber == drawingNumber + && r.PdfContentHash != null + && (excludeId == null || r.Id != excludeId)) + .OrderByDescending(r => r.Id) + .Select(r => r.PdfContentHash) + .FirstOrDefaultAsync(); + + if (hash == null) return NotFound(); + return hash; + } + + [HttpPatch("{id}/pdf-hash")] + public async Task UpdatePdfHash(int id, [FromBody] UpdatePdfHashRequest request) + { + var record = await _db.ExportRecords.FindAsync(id); + if (record == null) return NotFound(); + + record.PdfContentHash = request.PdfContentHash; + await _db.SaveChangesAsync(); + + return NoContent(); + } + + [HttpGet("previous-cut-template")] + public async Task> GetPreviousCutTemplate( + [FromQuery] string drawingNumber, + [FromQuery] string itemNo) + { + if (string.IsNullOrEmpty(drawingNumber) || string.IsNullOrEmpty(itemNo)) + return BadRequest("drawingNumber and itemNo are required."); + + var ct = await _db.CutTemplates + .Where(c => c.BomItem.ExportRecord.DrawingNumber == drawingNumber + && c.BomItem.ItemNo == itemNo + && c.ContentHash != null) + .OrderByDescending(c => c.Id) + .FirstOrDefaultAsync(); + + if (ct == null) return NotFound(); + + return new CutTemplateDto + { + Id = ct.Id, + DxfFilePath = ct.DxfFilePath, + ContentHash = ct.ContentHash, + Thickness = ct.Thickness, + KFactor = ct.KFactor, + DefaultBendRadius = ct.DefaultBendRadius + }; + } + + [HttpGet("{id}/download-dxfs")] + public async Task DownloadAllDxfs(int id) + { + var record = await _db.ExportRecords + .Include(r => r.BomItems).ThenInclude(b => b.CutTemplate) + .FirstOrDefaultAsync(r => r.Id == id); + + if (record == null) return NotFound(); + + var dxfItems = record.BomItems + .Where(b => b.CutTemplate?.ContentHash != null) + .ToList(); + + if (dxfItems.Count == 0) return NotFound("No DXF files for this export."); + + var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var b in dxfItems) + { + var ct = b.CutTemplate; + var fileName = ct.DxfFilePath?.Split(new[] { '/', '\\' }).LastOrDefault() + ?? $"PT{(b.ItemNo ?? "").PadLeft(2, '0')}.dxf"; + + // Ensure unique names in zip + if (!usedNames.Add(fileName)) + { + var baseName = Path.GetFileNameWithoutExtension(fileName); + var ext = Path.GetExtension(fileName); + var counter = 2; + do { fileName = $"{baseName}_{counter++}{ext}"; } + while (!usedNames.Add(fileName)); + } + + var blobStream = _fileStorage.OpenBlob(ct.ContentHash, "dxf"); + if (blobStream == null) continue; + + var entry = zip.CreateEntry(fileName, CompressionLevel.Fastest); + using var entryStream = entry.Open(); + await blobStream.CopyToAsync(entryStream); + blobStream.Dispose(); + } + } + + ms.Position = 0; + var zipName = $"{record.DrawingNumber ?? $"Export-{id}"} DXFs.zip"; + return File(ms, "application/zip", zipName); + } + private static ExportDetailDto MapToDto(ExportRecord r) => new() { Id = r.Id, DrawingNumber = r.DrawingNumber, + Title = r.Title, + EquipmentNo = r.EquipmentNo, + DrawingNo = r.DrawingNo, SourceFilePath = r.SourceFilePath, OutputFolder = r.OutputFolder, ExportedAt = r.ExportedAt, diff --git a/FabWorks.Api/Program.cs b/FabWorks.Api/Program.cs index dc2cc89..8121da5 100644 --- a/FabWorks.Api/Program.cs +++ b/FabWorks.Api/Program.cs @@ -1,3 +1,4 @@ +using FabWorks.Api.Configuration; using FabWorks.Api.Services; using FabWorks.Core.Data; using Microsoft.EntityFrameworkCore; @@ -9,7 +10,13 @@ builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("FabWorksDb"))); builder.Services.AddSingleton(); +builder.Services.Configure( + builder.Configuration.GetSection(FileStorageOptions.SectionName)); +builder.Services.AddScoped(); + var app = builder.Build(); +app.UseDefaultFiles(); +app.UseStaticFiles(); app.MapControllers(); app.Run(); From 696bf2f72c753342007f049aa8377ef4f79d3ec0 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 20:36:52 -0500 Subject: [PATCH 15/34] feat: add BomItem upsert and find endpoints Add find-existing endpoint and upsert logic to POST so re-exporting a part updates the existing BomItem rather than creating duplicates. Co-Authored-By: Claude Opus 4.6 --- .../Controllers/BomItemsController.cs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/FabWorks.Api/Controllers/BomItemsController.cs b/FabWorks.Api/Controllers/BomItemsController.cs index e58668f..be41f59 100644 --- a/FabWorks.Api/Controllers/BomItemsController.cs +++ b/FabWorks.Api/Controllers/BomItemsController.cs @@ -14,6 +14,26 @@ namespace FabWorks.Api.Controllers public BomItemsController(FabWorksDbContext db) => _db = db; + [HttpGet("find")] + public async Task> FindExisting(int exportId, [FromQuery] string partName, [FromQuery] string configurationName) + { + var export = await _db.ExportRecords.FindAsync(exportId); + if (export == null) return NotFound(); + + var existing = await _db.BomItems + .Include(b => b.CutTemplate) + .Include(b => b.FormProgram) + .Include(b => b.ExportRecord) + .Where(b => b.ExportRecord.DrawingNumber == export.DrawingNumber + && b.PartName == (partName ?? "") + && b.ConfigurationName == (configurationName ?? "")) + .OrderByDescending(b => b.ID) + .FirstOrDefaultAsync(); + + if (existing == null) return NotFound(); + return MapToDto(existing); + } + [HttpGet] public async Task>> GetByExport(int exportId) { @@ -33,6 +53,89 @@ namespace FabWorks.Api.Controllers var export = await _db.ExportRecords.FindAsync(exportId); if (export == null) return NotFound("Export record not found"); + // Look for existing BomItem with same PartName + ConfigurationName under the same drawing + var existing = await _db.BomItems + .Include(b => b.CutTemplate) + .Include(b => b.FormProgram) + .Include(b => b.ExportRecord) + .Where(b => b.ExportRecord.DrawingNumber == export.DrawingNumber + && b.PartName == (dto.PartName ?? "") + && b.ConfigurationName == (dto.ConfigurationName ?? "")) + .OrderByDescending(b => b.ID) + .FirstOrDefaultAsync(); + + if (existing != null) + { + // Update existing: move to new export record and refresh fields + existing.ExportRecordId = exportId; + existing.PartNo = dto.PartNo ?? ""; + existing.SortOrder = dto.SortOrder; + existing.Qty = dto.Qty; + existing.TotalQty = dto.TotalQty; + existing.Description = dto.Description ?? ""; + existing.Material = dto.Material ?? ""; + + if (dto.CutTemplate != null) + { + if (existing.CutTemplate != null) + { + existing.CutTemplate.DxfFilePath = dto.CutTemplate.DxfFilePath ?? ""; + existing.CutTemplate.ContentHash = dto.CutTemplate.ContentHash; + existing.CutTemplate.Thickness = dto.CutTemplate.Thickness; + existing.CutTemplate.KFactor = dto.CutTemplate.KFactor; + existing.CutTemplate.DefaultBendRadius = dto.CutTemplate.DefaultBendRadius; + } + else + { + existing.CutTemplate = new CutTemplate + { + DxfFilePath = dto.CutTemplate.DxfFilePath ?? "", + ContentHash = dto.CutTemplate.ContentHash, + Thickness = dto.CutTemplate.Thickness, + KFactor = dto.CutTemplate.KFactor, + DefaultBendRadius = dto.CutTemplate.DefaultBendRadius + }; + } + } + + if (dto.FormProgram != null) + { + if (existing.FormProgram != null) + { + existing.FormProgram.ProgramFilePath = dto.FormProgram.ProgramFilePath ?? ""; + existing.FormProgram.ContentHash = dto.FormProgram.ContentHash; + existing.FormProgram.ProgramName = dto.FormProgram.ProgramName ?? ""; + existing.FormProgram.Thickness = dto.FormProgram.Thickness; + existing.FormProgram.MaterialType = dto.FormProgram.MaterialType ?? ""; + existing.FormProgram.KFactor = dto.FormProgram.KFactor; + existing.FormProgram.BendCount = dto.FormProgram.BendCount; + existing.FormProgram.UpperToolNames = dto.FormProgram.UpperToolNames ?? ""; + existing.FormProgram.LowerToolNames = dto.FormProgram.LowerToolNames ?? ""; + existing.FormProgram.SetupNotes = dto.FormProgram.SetupNotes ?? ""; + } + else + { + existing.FormProgram = new FormProgram + { + ProgramFilePath = dto.FormProgram.ProgramFilePath ?? "", + ContentHash = dto.FormProgram.ContentHash, + ProgramName = dto.FormProgram.ProgramName ?? "", + Thickness = dto.FormProgram.Thickness, + MaterialType = dto.FormProgram.MaterialType ?? "", + KFactor = dto.FormProgram.KFactor, + BendCount = dto.FormProgram.BendCount, + UpperToolNames = dto.FormProgram.UpperToolNames ?? "", + LowerToolNames = dto.FormProgram.LowerToolNames ?? "", + SetupNotes = dto.FormProgram.SetupNotes ?? "" + }; + } + } + + await _db.SaveChangesAsync(); + return Ok(MapToDto(existing)); + } + + // No existing match — create new var item = new BomItem { ExportRecordId = exportId, From cf76ca8bb192bb198a0374d63d9c694fc0eca48e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 20:37:05 -0500 Subject: [PATCH 16/34] refactor: wire ExportDXF to use FabWorks API Replace direct DB access with API client calls throughout MainForm, DxfExportService, PartExporter, and Program. Add title field to UI, async export flow, API-based dropdown loading, and file uploads. Co-Authored-By: Claude Opus 4.6 --- ExportDXF/Forms/MainForm.Designer.cs | 48 ++- ExportDXF/Forms/MainForm.cs | 161 ++++----- ExportDXF/Models/Item.cs | 5 +- ExportDXF/Program.cs | 20 +- ExportDXF/Services/DxfExportService.cs | 475 +++++++++++-------------- ExportDXF/Services/PartExporter.cs | 31 +- 6 files changed, 346 insertions(+), 394 deletions(-) diff --git a/ExportDXF/Forms/MainForm.Designer.cs b/ExportDXF/Forms/MainForm.Designer.cs index 2e25fd5..bc17a3e 100644 --- a/ExportDXF/Forms/MainForm.Designer.cs +++ b/ExportDXF/Forms/MainForm.Designer.cs @@ -42,6 +42,8 @@ namespace ExportDXF.Forms label1 = new System.Windows.Forms.Label(); label2 = new System.Windows.Forms.Label(); drawingNoBox = new System.Windows.Forms.ComboBox(); + titleLabel = new System.Windows.Forms.Label(); + titleBox = new System.Windows.Forms.TextBox(); mainTabControl.SuspendLayout(); logEventsTab.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)logEventsDataGrid).BeginInit(); @@ -54,10 +56,10 @@ namespace ExportDXF.Forms // runButton // runButton.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right; - runButton.Location = new System.Drawing.Point(821, 13); + runButton.Location = new System.Drawing.Point(508, 12); runButton.Margin = new System.Windows.Forms.Padding(3, 4, 3, 4); runButton.Name = "runButton"; - runButton.Size = new System.Drawing.Size(100, 55); + runButton.Size = new System.Drawing.Size(65, 87); runButton.TabIndex = 11; runButton.Text = "Start"; runButton.UseVisualStyleBackColor = true; @@ -66,7 +68,7 @@ namespace ExportDXF.Forms // label3 // label3.AutoSize = true; - label3.Location = new System.Drawing.Point(26, 46); + label3.Location = new System.Drawing.Point(26, 77); label3.Name = "label3"; label3.Size = new System.Drawing.Size(105, 17); label3.TabIndex = 2; @@ -76,7 +78,7 @@ namespace ExportDXF.Forms // viewFlipDeciderBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; viewFlipDeciderBox.FormattingEnabled = true; - viewFlipDeciderBox.Location = new System.Drawing.Point(137, 43); + viewFlipDeciderBox.Location = new System.Drawing.Point(137, 74); viewFlipDeciderBox.Name = "viewFlipDeciderBox"; viewFlipDeciderBox.Size = new System.Drawing.Size(365, 25); viewFlipDeciderBox.TabIndex = 3; @@ -87,11 +89,11 @@ namespace ExportDXF.Forms mainTabControl.Controls.Add(logEventsTab); mainTabControl.Controls.Add(bomTab); mainTabControl.Controls.Add(cutTemplatesTab); - mainTabControl.Location = new System.Drawing.Point(15, 74); + mainTabControl.Location = new System.Drawing.Point(15, 105); mainTabControl.Name = "mainTabControl"; mainTabControl.Padding = new System.Drawing.Point(20, 5); mainTabControl.SelectedIndex = 0; - mainTabControl.Size = new System.Drawing.Size(910, 441); + mainTabControl.Size = new System.Drawing.Size(910, 492); mainTabControl.TabIndex = 12; // // logEventsTab @@ -100,7 +102,7 @@ namespace ExportDXF.Forms logEventsTab.Location = new System.Drawing.Point(4, 30); logEventsTab.Name = "logEventsTab"; logEventsTab.Padding = new System.Windows.Forms.Padding(3); - logEventsTab.Size = new System.Drawing.Size(902, 407); + logEventsTab.Size = new System.Drawing.Size(902, 458); logEventsTab.TabIndex = 0; logEventsTab.Text = "Log Events"; logEventsTab.UseVisualStyleBackColor = true; @@ -112,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, 391); + logEventsDataGrid.Size = new System.Drawing.Size(890, 440); logEventsDataGrid.TabIndex = 0; // // bomTab // bomTab.Controls.Add(bomDataGrid); - bomTab.Location = new System.Drawing.Point(4, 30); + bomTab.Location = new System.Drawing.Point(4, 28); bomTab.Name = "bomTab"; bomTab.Padding = new System.Windows.Forms.Padding(3); - bomTab.Size = new System.Drawing.Size(902, 407); + bomTab.Size = new System.Drawing.Size(902, 409); bomTab.TabIndex = 1; bomTab.Text = "Bill Of Materials"; bomTab.UseVisualStyleBackColor = true; @@ -139,10 +141,10 @@ namespace ExportDXF.Forms // cutTemplatesTab // cutTemplatesTab.Controls.Add(cutTemplatesDataGrid); - cutTemplatesTab.Location = new System.Drawing.Point(4, 30); + cutTemplatesTab.Location = new System.Drawing.Point(4, 28); cutTemplatesTab.Name = "cutTemplatesTab"; cutTemplatesTab.Padding = new System.Windows.Forms.Padding(3); - cutTemplatesTab.Size = new System.Drawing.Size(902, 407); + cutTemplatesTab.Size = new System.Drawing.Size(902, 409); cutTemplatesTab.TabIndex = 2; cutTemplatesTab.Text = "Cut Templates"; cutTemplatesTab.UseVisualStyleBackColor = true; @@ -191,10 +193,28 @@ namespace ExportDXF.Forms drawingNoBox.Size = new System.Drawing.Size(119, 25); drawingNoBox.TabIndex = 13; // + // titleLabel + // + titleLabel.AutoSize = true; + titleLabel.Location = new System.Drawing.Point(99, 46); + titleLabel.Name = "titleLabel"; + titleLabel.Size = new System.Drawing.Size(32, 17); + titleLabel.TabIndex = 14; + titleLabel.Text = "Title"; + // + // titleBox + // + titleBox.Location = new System.Drawing.Point(137, 43); + titleBox.Name = "titleBox"; + titleBox.Size = new System.Drawing.Size(365, 25); + titleBox.TabIndex = 15; + // // MainForm // AutoScaleMode = System.Windows.Forms.AutoScaleMode.None; - ClientSize = new System.Drawing.Size(937, 527); + ClientSize = new System.Drawing.Size(937, 609); + Controls.Add(titleBox); + Controls.Add(titleLabel); Controls.Add(drawingNoBox); Controls.Add(equipmentBox); Controls.Add(mainTabControl); @@ -237,5 +257,7 @@ namespace ExportDXF.Forms private System.Windows.Forms.Label label1; private System.Windows.Forms.Label label2; private System.Windows.Forms.ComboBox drawingNoBox; + private System.Windows.Forms.Label titleLabel; + private System.Windows.Forms.TextBox titleBox; } } diff --git a/ExportDXF/Forms/MainForm.cs b/ExportDXF/Forms/MainForm.cs index d6a2411..1871506 100644 --- a/ExportDXF/Forms/MainForm.cs +++ b/ExportDXF/Forms/MainForm.cs @@ -1,4 +1,4 @@ -using ExportDXF.Data; +using ExportDXF.ApiClient; using ExportDXF.Extensions; using ExportDXF.Models; using ExportDXF.Services; @@ -18,15 +18,14 @@ namespace ExportDXF.Forms { private readonly ISolidWorksService _solidWorksService; private readonly IDxfExportService _exportService; - private readonly IFileExportService _fileExportService; - private readonly Func _dbContextFactory; + private readonly IFabWorksApiClient _apiClient; private CancellationTokenSource _cancellationTokenSource; private readonly BindingList _logEvents; private readonly BindingList _bomItems; private readonly BindingList _cutTemplates; private List _allDrawings; - public MainForm(ISolidWorksService solidWorksService, IDxfExportService exportService, IFileExportService fileExportService, Func dbContextFactory = null) + public MainForm(ISolidWorksService solidWorksService, IDxfExportService exportService, IFabWorksApiClient apiClient) { InitializeComponent(); _solidWorksService = solidWorksService ?? @@ -34,9 +33,8 @@ namespace ExportDXF.Forms _solidWorksService.ActiveDocumentChanged += OnActiveDocumentChanged; _exportService = exportService ?? throw new ArgumentNullException(nameof(exportService)); - _fileExportService = fileExportService ?? - throw new ArgumentNullException(nameof(fileExportService)); - _dbContextFactory = dbContextFactory ?? (() => new ExportDxfDbContext()); + _apiClient = apiClient ?? + throw new ArgumentNullException(nameof(apiClient)); _logEvents = new BindingList(); _bomItems = new BindingList(); _cutTemplates = new BindingList(); @@ -70,9 +68,10 @@ namespace ExportDXF.Forms LogMessage("Connecting to SolidWorks, this may take a minute..."); await _solidWorksService.ConnectAsync(); _solidWorksService.ActiveDocumentChanged += OnActiveDocumentChanged; - LogMessage($"Output folder: {_fileExportService.OutputFolder}"); + LogMessage("Files will be uploaded to FabWorks API"); + await LoadDrawingDropdownsAsync(); LogMessage("Ready"); - UpdateActiveDocumentDisplay(); + await UpdateActiveDocumentDisplayAsync(); runButton.Enabled = true; } catch (Exception ex) @@ -274,74 +273,60 @@ namespace ExportDXF.Forms } private void InitializeDrawingDropdowns() + { + // Wire up event handler; actual data loading happens in LoadDrawingDropdownsAsync + equipmentBox.SelectedIndexChanged += EquipmentBox_SelectedIndexChanged; + } + + private async Task LoadDrawingDropdownsAsync() { try { - using (var db = _dbContextFactory()) + var equipmentNumbers = await _apiClient.GetEquipmentNumbersAsync(); + + equipmentBox.Items.Clear(); + equipmentBox.Items.Add(""); + foreach (var eq in equipmentNumbers) { - // Get all drawing numbers from the database - var drawingNumbers = db.ExportRecords - .Select(r => r.DrawingNumber) - .Where(d => !string.IsNullOrEmpty(d)) - .Distinct() - .ToList(); - - // Parse into DrawingInfo objects - _allDrawings = drawingNumbers - .Select(DrawingInfo.Parse) - .Where(d => d != null) - .Distinct() - .OrderBy(d => d.EquipmentNo) - .ThenBy(d => d.DrawingNo) - .ToList(); - - // Get distinct equipment numbers - var equipmentNumbers = _allDrawings - .Select(d => d.EquipmentNo) - .Distinct() - .OrderBy(e => e) - .ToList(); - - // Populate equipment dropdown - equipmentBox.Items.Clear(); - equipmentBox.Items.Add(""); // Empty option for "all" - foreach (var eq in equipmentNumbers) - { - equipmentBox.Items.Add(eq); - } - - // Populate drawing dropdown with all drawings initially - UpdateDrawingDropdown(); - - // Wire up event handler for equipment selection change - equipmentBox.SelectedIndexChanged += EquipmentBox_SelectedIndexChanged; + equipmentBox.Items.Add(eq); } + + // Clear _allDrawings — drawing list is now loaded on equipment selection + _allDrawings = new List(); + await UpdateDrawingDropdownAsync(); } catch (Exception ex) { - // Database might not exist yet - that's OK - System.Diagnostics.Debug.WriteLine($"Failed to load drawings from database: {ex.Message}"); + // API might not be available yet - that's OK + System.Diagnostics.Debug.WriteLine($"Failed to load equipment numbers from API: {ex.Message}"); } } - private void EquipmentBox_SelectedIndexChanged(object sender, EventArgs e) + private async void EquipmentBox_SelectedIndexChanged(object sender, EventArgs e) { - UpdateDrawingDropdown(); + await UpdateDrawingDropdownAsync(); } - private void UpdateDrawingDropdown() + private async Task UpdateDrawingDropdownAsync() { var selectedEquipment = equipmentBox.SelectedItem?.ToString(); - var filteredDrawings = string.IsNullOrEmpty(selectedEquipment) - ? _allDrawings - : _allDrawings.Where(d => d.EquipmentNo == selectedEquipment).ToList(); - drawingNoBox.Items.Clear(); - drawingNoBox.Items.Add(""); // Empty option - foreach (var drawing in filteredDrawings) + drawingNoBox.Items.Add(""); + + try { - drawingNoBox.Items.Add(drawing.DrawingNo); + var drawingNumbers = await _apiClient.GetDrawingNumbersByEquipmentAsync( + string.IsNullOrEmpty(selectedEquipment) ? null : selectedEquipment); + + foreach (var dn in drawingNumbers) + { + drawingNoBox.Items.Add(dn); + } + } + catch + { + // API might not be available } if (drawingNoBox.Items.Count > 0) @@ -380,11 +365,13 @@ namespace ExportDXF.Forms // Use equipment/drawing values from the UI dropdowns var equipment = equipmentBox.Text?.Trim(); var drawingNo = drawingNoBox.Text?.Trim(); - var filePrefix = !string.IsNullOrEmpty(equipment) && !string.IsNullOrEmpty(drawingNo) - ? $"{equipment} {drawingNo}" + var filePrefix = !string.IsNullOrEmpty(equipment) + ? (!string.IsNullOrEmpty(drawingNo) ? $"{equipment} {drawingNo}" : equipment) : activeDoc.Title; var viewFlipDecider = GetSelectedViewFlipDecider(); + var title = titleBox.Text?.Trim(); + var exportContext = new ExportContext { ActiveDocument = activeDoc, @@ -392,6 +379,7 @@ namespace ExportDXF.Forms FilePrefix = filePrefix, Equipment = equipment, DrawingNo = drawingNo, + Title = string.IsNullOrEmpty(title) ? null : title, EquipmentId = null, CancellationToken = token, ProgressCallback = (msg, level, file) => LogMessage(msg, level, file), @@ -403,11 +391,11 @@ namespace ExportDXF.Forms _cutTemplates.Clear(); LogMessage($"Started at {DateTime.Now:t}"); - LogMessage($"Exporting to: {_fileExportService.OutputFolder}"); + LogMessage("Exporting (files will be uploaded to API)..."); _solidWorksService.SetCommandInProgress(true); - await Task.Run(() => _exportService.Export(exportContext), token); + await Task.Run(async () => await _exportService.ExportAsync(exportContext), token); LogMessage("Done."); } @@ -454,17 +442,17 @@ namespace ExportDXF.Forms runButton.Enabled = true; } - private void OnActiveDocumentChanged(object sender, EventArgs e) + private async void OnActiveDocumentChanged(object sender, EventArgs e) { if (InvokeRequired) { - Invoke(new Action(() => OnActiveDocumentChanged(sender, e))); + Invoke(new Action(async () => await UpdateActiveDocumentDisplayAsync())); return; } - UpdateActiveDocumentDisplay(); + await UpdateActiveDocumentDisplayAsync(); } - private void UpdateActiveDocumentDisplay() + private async Task UpdateActiveDocumentDisplayAsync() { var activeDoc = _solidWorksService.GetActiveDocument(); var docTitle = activeDoc?.Title ?? "No Document Open"; @@ -473,12 +461,12 @@ namespace ExportDXF.Forms if (activeDoc == null) return; - // Try database first: look up the most recent export for this file path + // Try API first: look up the most recent export for this file path DrawingInfo drawingInfo = null; if (!string.IsNullOrEmpty(activeDoc.FilePath)) { - drawingInfo = LookupDrawingInfoFromHistory(activeDoc.FilePath); + drawingInfo = await LookupDrawingInfoFromHistoryAsync(activeDoc.FilePath); } // Fall back to parsing the document title @@ -489,36 +477,37 @@ namespace ExportDXF.Forms if (drawingInfo != null) { - if (!equipmentBox.Items.Contains(drawingInfo.EquipmentNo)) - equipmentBox.Items.Add(drawingInfo.EquipmentNo); - equipmentBox.Text = drawingInfo.EquipmentNo; + if (!string.IsNullOrEmpty(drawingInfo.EquipmentNo)) + { + if (!equipmentBox.Items.Contains(drawingInfo.EquipmentNo)) + equipmentBox.Items.Add(drawingInfo.EquipmentNo); + equipmentBox.Text = drawingInfo.EquipmentNo; + } - if (!drawingNoBox.Items.Contains(drawingInfo.DrawingNo)) - drawingNoBox.Items.Add(drawingInfo.DrawingNo); - drawingNoBox.Text = drawingInfo.DrawingNo; + if (!string.IsNullOrEmpty(drawingInfo.DrawingNo)) + { + if (!drawingNoBox.Items.Contains(drawingInfo.DrawingNo)) + drawingNoBox.Items.Add(drawingInfo.DrawingNo); + drawingNoBox.Text = drawingInfo.DrawingNo; + } } } - private DrawingInfo LookupDrawingInfoFromHistory(string filePath) + private async Task LookupDrawingInfoFromHistoryAsync(string filePath) { try { - using (var db = _dbContextFactory()) + var dto = await _apiClient.GetExportBySourceFileAsync(filePath); + if (dto != null && !string.IsNullOrEmpty(dto.DrawingNumber)) { - var drawingNumber = db.ExportRecords - .Where(r => r.SourceFilePath.ToLower() == filePath.ToLower() - && !string.IsNullOrEmpty(r.DrawingNumber)) - .OrderByDescending(r => r.Id) - .Select(r => r.DrawingNumber) - .FirstOrDefault(); - - if (drawingNumber != null) - return DrawingInfo.Parse(drawingNumber); + if (!string.IsNullOrEmpty(dto.Title)) + titleBox.Text = dto.Title; + return DrawingInfo.Parse(dto.DrawingNumber); } } catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"Failed to look up drawing info from history: {ex.Message}"); + System.Diagnostics.Debug.WriteLine($"Failed to look up drawing info from API: {ex.Message}"); } return null; diff --git a/ExportDXF/Models/Item.cs b/ExportDXF/Models/Item.cs index 656446f..629d537 100644 --- a/ExportDXF/Models/Item.cs +++ b/ExportDXF/Models/Item.cs @@ -68,8 +68,9 @@ namespace ExportDXF.Services public string ContentHash { get; set; } /// - /// Path to the stashed (backed-up) previous DXF file (transient, not persisted). + /// Full path to the locally-exported DXF temp file (transient, not persisted). + /// Set after successful export; used for upload to the API. /// - public string StashedFilePath { get; set; } + public string LocalTempPath { get; set; } } } \ No newline at end of file diff --git a/ExportDXF/Program.cs b/ExportDXF/Program.cs index e773f9d..05b5db5 100644 --- a/ExportDXF/Program.cs +++ b/ExportDXF/Program.cs @@ -1,7 +1,9 @@ +using ExportDXF.ApiClient; using ExportDXF.Forms; using ExportDXF.Services; using System; using System.Configuration; +using System.Net.Http; using System.Windows.Forms; namespace ExportDXF @@ -28,29 +30,35 @@ namespace ExportDXF /// public class ServiceContainer { - private readonly string _outputFolder; + private readonly string _apiBaseUrl; public ServiceContainer() { - _outputFolder = ConfigurationManager.AppSettings["ExportOutputFolder"] ?? @"C:\ExportDXF\Output"; + _apiBaseUrl = ConfigurationManager.AppSettings["FabWorksApiUrl"] ?? "http://localhost:5206"; } public MainForm ResolveMainForm() { var solidWorksService = new SolidWorksService(); var bomExtractor = new BomExtractor(); - var fileExportService = new FileExportService(_outputFolder); - var partExporter = new PartExporter(fileExportService); + var partExporter = new PartExporter(); var drawingExporter = new DrawingExporter(); + var httpClient = new HttpClient + { + BaseAddress = new Uri(_apiBaseUrl), + Timeout = TimeSpan.FromSeconds(30) + }; + var apiClient = new FabWorksApiClient(httpClient); + var exportService = new DxfExportService( solidWorksService, bomExtractor, partExporter, drawingExporter, - fileExportService); + apiClient); - return new MainForm(solidWorksService, exportService, fileExportService); + return new MainForm(solidWorksService, exportService, apiClient); } } } diff --git a/ExportDXF/Services/DxfExportService.cs b/ExportDXF/Services/DxfExportService.cs index 791ead6..5f8a67b 100644 --- a/ExportDXF/Services/DxfExportService.cs +++ b/ExportDXF/Services/DxfExportService.cs @@ -1,15 +1,15 @@ -using ExportDXF.Data; +using ExportDXF.ApiClient; using ExportDXF.Extensions; using ExportDXF.ItemExtractors; using ExportDXF.Models; using ExportDXF.Utilities; using ExportDXF; -using Microsoft.EntityFrameworkCore; using SolidWorks.Interop.sldworks; using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; namespace ExportDXF.Services { @@ -19,11 +19,12 @@ namespace ExportDXF.Services /// Exports the document specified in the context to DXF format. /// /// The export context containing all necessary information. - void Export(ExportContext context); + Task ExportAsync(ExportContext context); } /// /// Service responsible for orchestrating the export of SolidWorks documents to DXF format. + /// Files are generated locally in a temp directory, then uploaded to the API for storage and versioning. /// public class DxfExportService : IDxfExportService { @@ -31,29 +32,26 @@ namespace ExportDXF.Services private readonly IBomExtractor _bomExtractor; private readonly IPartExporter _partExporter; private readonly IDrawingExporter _drawingExporter; - private readonly IFileExportService _fileExportService; - private readonly Func _dbContextFactory; + private readonly IFabWorksApiClient _apiClient; public DxfExportService( ISolidWorksService solidWorksService, IBomExtractor bomExtractor, IPartExporter partExporter, IDrawingExporter drawingExporter, - IFileExportService fileExportService, - Func dbContextFactory = null) + IFabWorksApiClient apiClient) { _solidWorksService = solidWorksService ?? throw new ArgumentNullException(nameof(solidWorksService)); _bomExtractor = bomExtractor ?? throw new ArgumentNullException(nameof(bomExtractor)); _partExporter = partExporter ?? throw new ArgumentNullException(nameof(partExporter)); _drawingExporter = drawingExporter ?? throw new ArgumentNullException(nameof(drawingExporter)); - _fileExportService = fileExportService ?? throw new ArgumentNullException(nameof(fileExportService)); - _dbContextFactory = dbContextFactory ?? (() => new ExportDxfDbContext()); + _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); } /// /// Exports the document specified in the context to DXF format. /// - public void Export(ExportContext context) + public async Task ExportAsync(ExportContext context) { if (context == null) throw new ArgumentNullException(nameof(context)); @@ -62,26 +60,26 @@ namespace ExportDXF.Services SetupExportContext(context); var startTime = DateTime.Now; - - var drawingNumber = ParseDrawingNumber(context); - var outputFolder = _fileExportService.GetDrawingOutputFolder(context.Equipment, context.DrawingNo); + var tempDir = CreateTempWorkDir(); try { _solidWorksService.EnableUserControl(false); + var drawingNumber = ParseDrawingNumber(context); + switch (context.ActiveDocument.DocumentType) { case DocumentType.Part: - ExportPart(context, outputFolder, drawingNumber); + await ExportPartAsync(context, tempDir, drawingNumber); break; case DocumentType.Assembly: - ExportAssembly(context, outputFolder, drawingNumber); + await ExportAssemblyAsync(context, tempDir, drawingNumber); break; case DocumentType.Drawing: - ExportDrawing(context, drawingNumber, outputFolder); + await ExportDrawingAsync(context, drawingNumber, tempDir); break; default: @@ -93,6 +91,7 @@ namespace ExportDXF.Services { CleanupExportContext(context); _solidWorksService.EnableUserControl(true); + CleanupTempDir(tempDir); var duration = DateTime.Now - startTime; LogProgress(context, $"Run time: {duration.ToReadableFormat()}"); @@ -101,7 +100,7 @@ namespace ExportDXF.Services #region Export Methods by Document Type - private void ExportPart(ExportContext context, string outputFolder, string drawingNumber) + private async Task ExportPartAsync(ExportContext context, string tempDir, string drawingNumber) { LogProgress(context, "Active document is a Part"); @@ -112,14 +111,14 @@ namespace ExportDXF.Services return; } - var exportRecord = CreateExportRecord(context, drawingNumber, outputFolder); - var item = _partExporter.ExportSinglePart(part, outputFolder, context); + var exportRecord = await CreateExportRecordAsync(context, drawingNumber); + var item = _partExporter.ExportSinglePart(part, tempDir, context); if (item != null) { - // Assign auto-incremented item number - var nextItemNo = GetNextItemNumber(drawingNumber); - item.ItemNo = nextItemNo; + // Check if this part+config already has a BOM item for this drawing + var existingItemNo = await FindExistingItemNoAsync(exportRecord?.Id, item.PartName, item.Configuration); + item.ItemNo = existingItemNo ?? await GetNextItemNumberAsync(drawingNumber); var bomItem = new BomItem { @@ -135,29 +134,31 @@ namespace ExportDXF.Services Material = item.Material ?? "" }; - if (!string.IsNullOrEmpty(item.FileName)) + // Upload DXF to API and get stored path + if (!string.IsNullOrEmpty(item.LocalTempPath)) { - var dxfPath = Path.Combine(outputFolder, item.FileName + ".dxf"); - bomItem.CutTemplate = new CutTemplate + var uploadResult = await UploadDxfAsync(item, context); + if (uploadResult != null) { - DxfFilePath = dxfPath, - ContentHash = item.ContentHash, - Thickness = item.Thickness > 0 ? item.Thickness : null, - KFactor = item.KFactor > 0 ? item.KFactor : null, - DefaultBendRadius = item.BendRadius > 0 ? item.BendRadius : null - }; - - HandleDxfVersioning(item, dxfPath, context); + 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) - SaveBomItem(bomItem, context); + await SaveBomItemAsync(exportRecord.Id, bomItem, context); } } - private void ExportAssembly(ExportContext context, string outputFolder, string drawingNumber) + private async Task ExportAssemblyAsync(ExportContext context, string tempDir, string drawingNumber) { LogProgress(context, "Active document is an Assembly"); LogProgress(context, "Fetching components..."); @@ -179,23 +180,31 @@ namespace ExportDXF.Services LogProgress(context, $"Found {items.Count} item(s)."); - var exportRecord = CreateExportRecord(context, drawingNumber, outputFolder); + var exportRecord = await CreateExportRecordAsync(context, drawingNumber); - // Auto-assign item numbers for items that don't have one - var nextNum = int.Parse(GetNextItemNumber(drawingNumber)); + // Check existing BOM items and reuse item numbers, or assign new ones + var nextNum = int.Parse(await GetNextItemNumberAsync(drawingNumber)); foreach (var item in items) { if (string.IsNullOrWhiteSpace(item.ItemNo)) { - item.ItemNo = nextNum.ToString(); - nextNum++; + var existingItemNo = await FindExistingItemNoAsync(exportRecord?.Id, item.PartName, item.Configuration); + if (existingItemNo != null) + { + item.ItemNo = existingItemNo; + } + else + { + item.ItemNo = nextNum.ToString(); + nextNum++; + } } } - ExportItems(items, outputFolder, context, exportRecord?.Id); + await ExportItemsAsync(items, tempDir, context, exportRecord?.Id); } - private void ExportDrawing(ExportContext context, string drawingNumber, string drawingOutputFolder) + private async Task ExportDrawingAsync(ExportContext context, string drawingNumber, string tempDir) { LogProgress(context, "Active document is a Drawing"); LogProgress(context, "Finding BOM tables..."); @@ -217,88 +226,46 @@ namespace ExportDXF.Services LogProgress(context, $"Found {items.Count} component(s)"); - // Export drawing to PDF - var tempDir = CreateTempWorkDir(); + // Export drawing to PDF in temp dir _drawingExporter.ExportToPdf(drawing, tempDir, context); - // Copy PDF to output folder with versioning - string pdfStashPath = null; - string savedPdfPath = null; + // Create export record via API + var exportRecord = await CreateExportRecordAsync(context, drawingNumber); + + // Upload PDF to API with versioning try { var pdfs = Directory.GetFiles(tempDir, "*.pdf"); if (pdfs.Length > 0) { - // Determine the destination path to stash the existing file - var pdfFileName = !string.IsNullOrEmpty(drawingNumber) - ? $"{drawingNumber}.pdf" - : Path.GetFileName(pdfs[0]); - var pdfDestPath = Path.Combine(drawingOutputFolder, pdfFileName); + var pdfTempPath = pdfs[0]; + var pdfHash = ContentHasher.ComputeFileHash(pdfTempPath); - pdfStashPath = _fileExportService.StashFile(pdfDestPath); - savedPdfPath = _fileExportService.SavePdfFile(pdfs[0], drawingNumber, drawingOutputFolder); + var uploadResult = await _apiClient.UploadPdfAsync( + pdfTempPath, + context.Equipment, + context.DrawingNo, + pdfHash, + exportRecord?.Id); + + if (uploadResult != null) + { + if (uploadResult.WasUnchanged) + LogProgress(context, $"PDF unchanged: {uploadResult.FileName}", LogLevel.Info); + else if (uploadResult.IsNewFile) + LogProgress(context, $"Saved PDF: {uploadResult.FileName}", LogLevel.Info); + else + LogProgress(context, $"PDF updated: {uploadResult.FileName}", LogLevel.Info); + } } } catch (Exception ex) { - LogProgress(context, $"PDF save error: {ex.Message}", LogLevel.Error); - } - - // Create export record in database - var exportRecord = CreateExportRecord(context, drawingNumber, drawingOutputFolder); - - // Handle PDF versioning and update export record with hash - if (exportRecord != null && savedPdfPath != null) - { - try - { - HandlePdfVersioning(savedPdfPath, exportRecord.DrawingNumber, exportRecord, context); - - // Archive or discard old PDF based on hash comparison - if (pdfStashPath != null) - { - using (var db = _dbContextFactory()) - { - var previousRecord = db.ExportRecords - .Where(r => r.DrawingNumber == exportRecord.DrawingNumber - && r.PdfContentHash != null - && r.Id != exportRecord.Id) - .OrderByDescending(r => r.Id) - .FirstOrDefault(); - - if (previousRecord != null && previousRecord.PdfContentHash == exportRecord.PdfContentHash) - { - _fileExportService.DiscardStash(pdfStashPath); - } - else - { - _fileExportService.ArchiveFile(pdfStashPath, savedPdfPath); - } - } - } - - // Update the record with the PDF hash - using (var db = _dbContextFactory()) - { - db.ExportRecords.Attach(exportRecord); - db.Entry(exportRecord).Property(r => r.PdfContentHash).IsModified = true; - db.SaveChanges(); - } - } - catch (Exception ex) - { - _fileExportService.DiscardStash(pdfStashPath); - LogProgress(context, $"PDF versioning error: {ex.Message}", LogLevel.Error); - } - } - else if (pdfStashPath != null) - { - // No export record - discard stash - _fileExportService.DiscardStash(pdfStashPath); + LogProgress(context, $"PDF upload error: {ex.Message}", LogLevel.Error); } // Export parts to DXF and save BOM items - ExportItems(items, drawingOutputFolder, context, exportRecord?.Id); + await ExportItemsAsync(items, tempDir, context, exportRecord?.Id); } #endregion @@ -357,7 +324,7 @@ namespace ExportDXF.Services } } - private void ExportItems(List items, string saveDirectory, ExportContext context, int? exportRecordId = null) + private async Task ExportItemsAsync(List items, string tempDir, ExportContext context, int? exportRecordId = null) { int successCount = 0; int skippedCount = 0; @@ -375,7 +342,7 @@ namespace ExportDXF.Services try { // PartExporter will handle template drawing creation through context - _partExporter.ExportItem(item, saveDirectory, context); + _partExporter.ExportItem(item, tempDir, context); // Always create BomItem for every item (sheet metal or not) var bomItem = new BomItem @@ -392,23 +359,23 @@ namespace ExportDXF.Services Material = item.Material ?? "" }; - // Only create CutTemplate if DXF was exported successfully - if (!string.IsNullOrEmpty(item.FileName)) + // Only upload and create CutTemplate if DXF was exported successfully + if (!string.IsNullOrEmpty(item.LocalTempPath)) { successCount++; - var dxfPath = Path.Combine(saveDirectory, item.FileName + ".dxf"); - bomItem.CutTemplate = new CutTemplate + var uploadResult = await UploadDxfAsync(item, context); + if (uploadResult != null) { - DxfFilePath = dxfPath, - ContentHash = item.ContentHash, - Thickness = item.Thickness > 0 ? item.Thickness : null, - KFactor = item.KFactor > 0 ? item.KFactor : null, - DefaultBendRadius = item.BendRadius > 0 ? item.BendRadius : null - }; - - // Compare hash with previous export to decide archive/discard - HandleDxfVersioning(item, dxfPath, context); + 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 + }; + } } else { @@ -418,21 +385,10 @@ namespace ExportDXF.Services // Add to UI context.BomItemCallback?.Invoke(bomItem); - // Save BOM item to database if we have an export record + // Save BOM item via API if we have an export record if (exportRecordId.HasValue) { - try - { - using (var db = _dbContextFactory()) - { - db.BomItems.Add(bomItem); - db.SaveChanges(); - } - } - catch (Exception dbEx) - { - LogProgress(context, $"Database error saving BOM item: {dbEx.Message}", LogLevel.Error); - } + await SaveBomItemAsync(exportRecordId.Value, bomItem, context); } } catch (Exception ex) @@ -449,146 +405,103 @@ namespace ExportDXF.Services if (exportRecordId.HasValue) { - LogProgress(context, $"BOM items saved to database (ExportRecord ID: {exportRecordId.Value})", LogLevel.Info); + LogProgress(context, $"BOM items saved (ExportRecord ID: {exportRecordId.Value})", LogLevel.Info); } } #endregion - #region Versioning + #region File Upload - private void HandleDxfVersioning(Item item, string dxfPath, ExportContext context) - { - if (string.IsNullOrEmpty(item.ContentHash)) - return; - - try - { - using (var db = _dbContextFactory()) - { - var previousCutTemplate = db.CutTemplates - .Where(ct => ct.DxfFilePath == dxfPath && ct.ContentHash != null) - .OrderByDescending(ct => ct.Id) - .FirstOrDefault(); - - if (previousCutTemplate != null && previousCutTemplate.ContentHash == item.ContentHash) - { - // Content unchanged - discard the stashed file - _fileExportService.DiscardStash(item.StashedFilePath); - LogProgress(context, $"DXF unchanged: {item.FileName}.dxf", LogLevel.Info); - } - else - { - // Content changed or first export - archive the old file - if (!string.IsNullOrEmpty(item.StashedFilePath)) - { - _fileExportService.ArchiveFile(item.StashedFilePath, dxfPath); - LogProgress(context, $"DXF updated, previous version archived: {item.FileName}.dxf", LogLevel.Info); - } - else - { - LogProgress(context, $"Exported: {item.FileName}.dxf", LogLevel.Info); - } - } - } - } - catch (Exception ex) - { - // Don't fail the export if versioning fails - just discard the stash - _fileExportService.DiscardStash(item.StashedFilePath); - LogProgress(context, $"Versioning check failed for {item.FileName}: {ex.Message}", LogLevel.Warning); - } - } - - private void HandlePdfVersioning(string pdfPath, string drawingNumber, ExportRecord exportRecord, ExportContext context) + private async Task UploadDxfAsync(Item item, ExportContext context) { try { - var newHash = ContentHasher.ComputeFileHash(pdfPath); + var result = await _apiClient.UploadDxfAsync( + item.LocalTempPath, + context.Equipment, + context.DrawingNo, + item.ItemNo, + item.ContentHash); - using (var db = _dbContextFactory()) - { - var previousRecord = db.ExportRecords - .Where(r => r.DrawingNumber == drawingNumber && r.PdfContentHash != null) - .OrderByDescending(r => r.Id) - .FirstOrDefault(); + if (result.WasUnchanged) + LogProgress(context, $"DXF unchanged: {result.FileName}", LogLevel.Info); + else if (result.IsNewFile) + LogProgress(context, $"Exported: {result.FileName}", LogLevel.Info); + else + LogProgress(context, $"DXF updated: {result.FileName}", LogLevel.Info); - if (previousRecord != null && previousRecord.PdfContentHash == newHash) - { - LogProgress(context, $"PDF unchanged: {Path.GetFileName(pdfPath)}", LogLevel.Info); - } - else - { - LogProgress(context, $"Saved PDF: {Path.GetFileName(pdfPath)}", LogLevel.Info); - } - } - - if (exportRecord != null) - exportRecord.PdfContentHash = newHash; + return result; } catch (Exception ex) { - LogProgress(context, $"PDF versioning check failed: {ex.Message}", LogLevel.Warning); - } - } - - #endregion - - #region Database Helpers - - private ExportRecord CreateExportRecord(ExportContext context, string drawingNumber, string outputFolder) - { - try - { - using (var db = _dbContextFactory()) - { - db.Database.Migrate(); - var record = new ExportRecord - { - DrawingNumber = drawingNumber ?? context.ActiveDocument.Title, - SourceFilePath = context.ActiveDocument.FilePath, - OutputFolder = outputFolder, - ExportedAt = DateTime.Now, - ExportedBy = System.Environment.UserName - }; - - db.ExportRecords.Add(record); - db.SaveChanges(); - LogProgress(context, $"Created export record (ID: {record.Id})", LogLevel.Info); - return record; - } - } - catch (Exception ex) - { - LogProgress(context, $"Database error creating export record: {ex.Message}", LogLevel.Error); + LogProgress(context, $"DXF upload failed for {item.FileName}: {ex.Message}", LogLevel.Warning); return null; } } - private string GetNextItemNumber(string drawingNumber) + #endregion + + #region API Helpers + + private async Task CreateExportRecordAsync(ExportContext context, string drawingNumber) + { + try + { + var dto = await _apiClient.CreateExportAsync( + drawingNumber ?? context.ActiveDocument.Title, + context.Equipment ?? "", + context.DrawingNo ?? "", + context.ActiveDocument.FilePath, + "", // Output folder is now managed by the API + context.Title); + + var record = new ExportRecord + { + Id = dto.Id, + DrawingNumber = dto.DrawingNumber, + EquipmentNo = dto.EquipmentNo, + DrawingNo = dto.DrawingNo, + SourceFilePath = dto.SourceFilePath, + OutputFolder = dto.OutputFolder, + ExportedAt = dto.ExportedAt, + ExportedBy = dto.ExportedBy + }; + + LogProgress(context, $"Created export record (ID: {record.Id})", LogLevel.Info); + return record; + } + catch (Exception ex) + { + LogProgress(context, $"API error creating export record: {ex.Message}", LogLevel.Error); + return null; + } + } + + private async Task FindExistingItemNoAsync(int? exportRecordId, string partName, string configurationName) + { + if (!exportRecordId.HasValue) + return null; + + try + { + var existing = await _apiClient.FindExistingBomItemAsync(exportRecordId.Value, partName, configurationName); + return existing?.ItemNo; + } + catch + { + return null; + } + } + + private async Task GetNextItemNumberAsync(string drawingNumber) { if (string.IsNullOrEmpty(drawingNumber)) return "1"; try { - using (var db = _dbContextFactory()) - { - var existingItems = db.ExportRecords - .Where(r => r.DrawingNumber == drawingNumber) - .SelectMany(r => r.BomItems) - .Select(b => b.ItemNo) - .ToList(); - - int maxNum = 0; - foreach (var itemNo in existingItems) - { - if (int.TryParse(itemNo, out var num) && num > maxNum) - maxNum = num; - } - - return (maxNum + 1).ToString(); - } + return await _apiClient.GetNextItemNumberAsync(drawingNumber); } catch { @@ -596,19 +509,40 @@ namespace ExportDXF.Services } } - private void SaveBomItem(BomItem bomItem, ExportContext context) + private async Task SaveBomItemAsync(int exportRecordId, BomItem bomItem, ExportContext context) { try { - using (var db = _dbContextFactory()) + var apiBomItem = new ApiBomItem { - db.BomItems.Add(bomItem); - db.SaveChanges(); + ItemNo = bomItem.ItemNo, + PartNo = bomItem.PartNo, + SortOrder = bomItem.SortOrder, + Qty = bomItem.Qty, + TotalQty = bomItem.TotalQty, + Description = bomItem.Description, + PartName = bomItem.PartName, + ConfigurationName = bomItem.ConfigurationName, + Material = bomItem.Material + }; + + if (bomItem.CutTemplate != null) + { + apiBomItem.CutTemplate = new ApiCutTemplate + { + DxfFilePath = bomItem.CutTemplate.DxfFilePath, + ContentHash = bomItem.CutTemplate.ContentHash, + Thickness = bomItem.CutTemplate.Thickness, + KFactor = bomItem.CutTemplate.KFactor, + DefaultBendRadius = bomItem.CutTemplate.DefaultBendRadius + }; } + + await _apiClient.CreateBomItemAsync(exportRecordId, apiBomItem); } - catch (Exception dbEx) + catch (Exception ex) { - LogProgress(context, $"Database error saving BOM item: {dbEx.Message}", LogLevel.Error); + LogProgress(context, $"API error saving BOM item: {ex.Message}", LogLevel.Error); } } @@ -623,11 +557,28 @@ namespace ExportDXF.Services return path; } + private void CleanupTempDir(string tempDir) + { + try + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + catch + { + // Best-effort cleanup + } + } + private string ParseDrawingNumber(ExportContext context) { // Use explicit Equipment/DrawingNo from the UI when available - if (!string.IsNullOrWhiteSpace(context?.Equipment) && !string.IsNullOrWhiteSpace(context?.DrawingNo)) - return $"{context.Equipment} {context.DrawingNo}"; + if (!string.IsNullOrWhiteSpace(context?.Equipment)) + { + return !string.IsNullOrWhiteSpace(context?.DrawingNo) + ? $"{context.Equipment} {context.DrawingNo}" + : context.Equipment; + } // Fallback: parse from prefix or document title var candidate = context?.FilePrefix; @@ -637,7 +588,7 @@ namespace ExportDXF.Services var title = context?.ActiveDocument?.Title; info = string.IsNullOrWhiteSpace(title) ? null : DrawingInfo.Parse(title); } - return info != null ? ($"{info.EquipmentNo} {info.DrawingNo}") : null; + return info?.ToString(); } private void ValidateContext(ExportContext context) diff --git a/ExportDXF/Services/PartExporter.cs b/ExportDXF/Services/PartExporter.cs index 9fecf6e..f7522ad 100644 --- a/ExportDXF/Services/PartExporter.cs +++ b/ExportDXF/Services/PartExporter.cs @@ -1,4 +1,4 @@ -using ExportDXF.Extensions; +using ExportDXF.Extensions; using ExportDXF.Models; using ExportDXF.Utilities; using SolidWorks.Interop.sldworks; @@ -18,7 +18,7 @@ namespace ExportDXF.Services /// Returns an Item with export metadata (filename, hash, sheet metal properties), or null if export failed. /// /// The part document to export. - /// The directory where the DXF file will be saved. + /// The temp directory where the DXF file will be saved. /// The export context. Item ExportSinglePart(PartDoc part, string saveDirectory, ExportContext context); @@ -26,18 +26,15 @@ namespace ExportDXF.Services /// Exports an item (component from BOM or assembly) to DXF. /// /// The item to export. - /// The directory where the DXF file will be saved. + /// The temp directory where the DXF file will be saved. /// The export context. void ExportItem(Item item, string saveDirectory, ExportContext context); } public class PartExporter : IPartExporter { - private readonly IFileExportService _fileExportService; - - public PartExporter(IFileExportService fileExportService) + public PartExporter() { - _fileExportService = fileExportService ?? throw new ArgumentNullException(nameof(fileExportService)); } public Item ExportSinglePart(PartDoc part, string saveDirectory, ExportContext context) @@ -90,25 +87,17 @@ namespace ExportDXF.Services // Get material item.Material = part.GetMaterialPropertyName2(originalConfigName, out _); - // Stash existing file before overwriting - item.StashedFilePath = _fileExportService.StashFile(savePath); - context.GetOrCreateTemplateDrawing(); if (ExportPartToDxf(part, originalConfigName, savePath, context)) { item.FileName = Path.GetFileNameWithoutExtension(savePath); item.ContentHash = Utilities.ContentHasher.ComputeDxfContentHash(savePath); + item.LocalTempPath = savePath; return item; } else { - // Export failed - restore stashed file - if (item.StashedFilePath != null && File.Exists(item.StashedFilePath)) - { - File.Move(item.StashedFilePath, savePath, overwrite: true); - item.StashedFilePath = null; - } return null; } } @@ -155,22 +144,14 @@ namespace ExportDXF.Services var templateDrawing = context.GetOrCreateTemplateDrawing(); - // Stash existing file before overwriting - item.StashedFilePath = _fileExportService.StashFile(savePath); - if (ExportPartToDxf(part, item.Component.ReferencedConfiguration, savePath, context)) { item.FileName = Path.GetFileNameWithoutExtension(savePath); item.ContentHash = Utilities.ContentHasher.ComputeDxfContentHash(savePath); + item.LocalTempPath = savePath; } else { - // Export failed - restore stashed file if we have one - if (item.StashedFilePath != null && File.Exists(item.StashedFilePath)) - { - File.Move(item.StashedFilePath, savePath, overwrite: true); - item.StashedFilePath = null; - } LogExportFailure(item, context); } } From 5ec66f903954b953fb134adba5901043fcc26f29 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 20:37:16 -0500 Subject: [PATCH 17/34] feat: add web frontend for FabWorks API Add static HTML/CSS/JS frontend with export browser, search, and file download capabilities served via UseStaticFiles. Co-Authored-By: Claude Opus 4.6 --- FabWorks.Api/wwwroot/css/styles.css | 737 ++++++++++++++++++++++++++ FabWorks.Api/wwwroot/index.html | 56 ++ FabWorks.Api/wwwroot/js/components.js | 60 +++ FabWorks.Api/wwwroot/js/helpers.js | 36 ++ FabWorks.Api/wwwroot/js/icons.js | 19 + FabWorks.Api/wwwroot/js/pages.js | 396 ++++++++++++++ FabWorks.Api/wwwroot/js/router.js | 35 ++ 7 files changed, 1339 insertions(+) create mode 100644 FabWorks.Api/wwwroot/css/styles.css create mode 100644 FabWorks.Api/wwwroot/index.html create mode 100644 FabWorks.Api/wwwroot/js/components.js create mode 100644 FabWorks.Api/wwwroot/js/helpers.js create mode 100644 FabWorks.Api/wwwroot/js/icons.js create mode 100644 FabWorks.Api/wwwroot/js/pages.js create mode 100644 FabWorks.Api/wwwroot/js/router.js diff --git a/FabWorks.Api/wwwroot/css/styles.css b/FabWorks.Api/wwwroot/css/styles.css new file mode 100644 index 0000000..9dd9643 --- /dev/null +++ b/FabWorks.Api/wwwroot/css/styles.css @@ -0,0 +1,737 @@ +:root { + --bg-deep: #0a0e14; + --bg: #0d1117; + --surface: #151b23; + --surface-raised: #1c2128; + --border: #2a313a; + --border-subtle: #21262d; + --text: #e6edf3; + --text-secondary: #7d8590; + --text-dim: #484f58; + --cyan: #00d4ff; + --cyan-dim: rgba(0, 212, 255, 0.15); + --cyan-glow: rgba(0, 212, 255, 0.3); + --amber: #f0883e; + --amber-dim: rgba(240, 136, 62, 0.15); + --green: #3fb950; + --green-dim: rgba(63, 185, 80, 0.12); + --red: #f85149; + --sidebar-w: 64px; + --font-display: 'Outfit', sans-serif; + --font-body: 'IBM Plex Sans', sans-serif; + --font-mono: 'IBM Plex Mono', monospace; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font-body); + background: var(--bg); + color: var(--text); + display: flex; + min-height: 100vh; + overflow-x: hidden; +} + +/* Blueprint grid background */ +body::before { + content: ''; + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px); + background-size: 48px 48px; + pointer-events: none; + z-index: 0; +} + +/* ─── Sidebar ─── */ +.sidebar { + width: var(--sidebar-w); + background: var(--bg-deep); + border-right: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + align-items: center; + position: fixed; + top: 0; left: 0; bottom: 0; + z-index: 50; + padding-top: 8px; +} + +.sidebar-brand { + width: 40px; height: 40px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 24px; + position: relative; +} + +.sidebar-brand::after { + content: ''; + position: absolute; + bottom: -12px; + left: 8px; right: 8px; + height: 1px; + background: var(--border); +} + +.sidebar-brand svg { + width: 26px; height: 26px; + color: var(--cyan); + filter: drop-shadow(0 0 6px rgba(0, 212, 255, 0.4)); +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 16px; + width: 100%; +} + +.nav-item { + display: flex; + align-items: center; + justify-content: center; + width: 44px; height: 44px; + margin: 0 auto; + color: var(--text-dim); + text-decoration: none; + cursor: pointer; + border-radius: 8px; + transition: all 0.2s; + position: relative; +} + +.nav-item:hover { + color: var(--text-secondary); + background: var(--surface); +} + +.nav-item.active { + color: var(--cyan); + background: var(--cyan-dim); +} + +.nav-item.active::before { + content: ''; + position: absolute; + left: -10px; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 20px; + background: var(--cyan); + border-radius: 0 2px 2px 0; + box-shadow: 0 0 8px var(--cyan-glow); +} + +.nav-item svg { width: 20px; height: 20px; } + +.nav-tooltip { + position: absolute; + left: calc(100% + 12px); + top: 50%; + transform: translateY(-50%); + background: var(--surface-raised); + border: 1px solid var(--border); + color: var(--text); + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + font-family: var(--font-body); + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; + z-index: 100; +} + +.nav-item:hover .nav-tooltip { opacity: 1; } + +/* ─── Main ─── */ +.main { + margin-left: var(--sidebar-w); + flex: 1; + display: flex; + flex-direction: column; + min-height: 100vh; + position: relative; + z-index: 1; +} + +.topbar { + background: rgba(13, 17, 23, 0.85); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-subtle); + padding: 0 32px; + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 40; +} + +.topbar-left { + display: flex; + align-items: center; + gap: 12px; +} + +.topbar h2 { + font-family: var(--font-display); + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.topbar-tag { + font-family: var(--font-mono); + font-size: 10px; + color: var(--cyan); + background: var(--cyan-dim); + padding: 2px 8px; + border-radius: 3px; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.page-content { + padding: 28px 32px; + flex: 1; +} + +/* ─── Animations ─── */ +@keyframes fadeSlideIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes pulseGlow { + 0%, 100% { box-shadow: 0 0 4px var(--cyan-glow); } + 50% { box-shadow: 0 0 12px var(--cyan-glow); } +} + +.animate-in { + animation: fadeSlideIn 0.3s ease forwards; + opacity: 0; +} + +.animate-in:nth-child(1) { animation-delay: 0.04s; } +.animate-in:nth-child(2) { animation-delay: 0.08s; } +.animate-in:nth-child(3) { animation-delay: 0.12s; } +.animate-in:nth-child(4) { animation-delay: 0.16s; } + +/* ─── Cards ─── */ +.card { + background: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: 6px; + overflow: hidden; +} + +.card-header { + padding: 14px 18px; + border-bottom: 1px solid var(--border-subtle); + font-family: var(--font-display); + font-weight: 600; + font-size: 13px; + letter-spacing: 0.02em; + display: flex; + align-items: center; + justify-content: space-between; + text-transform: uppercase; + color: var(--text-secondary); +} + +.card-body { padding: 18px; } + +/* ─── Stats ─── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 18px 20px; + position: relative; + overflow: hidden; +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; left: 0; + width: 100%; height: 2px; + background: linear-gradient(90deg, var(--cyan), transparent); + opacity: 0.6; +} + +.stat-label { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 1.5px; +} + +.stat-value { + font-family: var(--font-display); + font-size: 32px; + font-weight: 700; + margin-top: 4px; + color: var(--text); + letter-spacing: -0.02em; +} + +.stat-value.stat-sm { + font-size: 14px; + font-weight: 500; + font-family: var(--font-mono); +} + +/* ─── Tables ─── */ +table { width: 100%; border-collapse: collapse; } + +th { + text-align: left; + padding: 10px 16px; + background: var(--bg); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--text-dim); + font-weight: 500; + white-space: nowrap; +} + +td { + padding: 11px 16px; + border-bottom: 1px solid var(--border-subtle); + font-size: 13px; +} + +tbody tr { transition: background 0.1s; } +tbody tr:hover td { background: rgba(0, 212, 255, 0.03); } +tbody tr:last-child td { border-bottom: none; } + +/* ─── Badges ─── */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.badge svg { width: 12px; height: 12px; flex-shrink: 0; } +.badge-cyan { background: var(--cyan-dim); color: var(--cyan); } +.badge-amber { background: var(--amber-dim); color: var(--amber); } +.badge-green { background: var(--green-dim); color: var(--green); } +.badge-count { + background: var(--surface-raised); + color: var(--text-secondary); + border: 1px solid var(--border); +} + +/* ─── Buttons ─── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 4px; + font-family: var(--font-body); + font-size: 12px; + font-weight: 500; + cursor: pointer; + text-decoration: none; + border: 1px solid var(--border); + background: var(--surface-raised); + color: var(--text-secondary); + transition: all 0.15s; + white-space: nowrap; +} + +.btn:hover { + background: var(--surface); + color: var(--text); + border-color: var(--text-dim); +} + +.btn svg { width: 13px; height: 13px; } + +.btn-cyan { + background: var(--cyan-dim); + color: var(--cyan); + border-color: rgba(0, 212, 255, 0.2); +} + +.btn-cyan:hover { + background: rgba(0, 212, 255, 0.2); + border-color: rgba(0, 212, 255, 0.4); + color: var(--cyan); +} + +.btn-sm { padding: 3px 8px; font-size: 11px; } + +/* ─── Search ─── */ +.search-box { + display: flex; + align-items: center; + gap: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0 12px; + height: 34px; + width: 300px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.search-box:focus-within { + border-color: var(--cyan); + box-shadow: 0 0 0 1px var(--cyan-dim); +} + +.search-box svg { + width: 14px; height: 14px; + color: var(--text-dim); + flex-shrink: 0; +} + +.search-box input { + border: none; + outline: none; + font-family: var(--font-body); + font-size: 13px; + width: 100%; + background: transparent; + color: var(--text); +} + +.search-box input::placeholder { color: var(--text-dim); } + +/* ─── Clickable ─── */ +.clickable { cursor: pointer; } + +/* ─── Detail sections ─── */ +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 20px; +} + +.detail-field label { + display: block; + font-family: var(--font-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--text-dim); + margin-bottom: 4px; +} + +.detail-field .value { + font-size: 14px; + font-weight: 500; + word-break: break-all; +} + +.detail-field .value.mono { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); +} + +/* ─── Back link ─── */ +.back-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-dim); + text-decoration: none; + font-size: 12px; + cursor: pointer; + margin-bottom: 20px; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.5px; + transition: color 0.15s; +} + +.back-link:hover { color: var(--cyan); } +.back-link svg { width: 14px; height: 14px; } + +/* ─── BOM Expansion ─── */ +.bom-expand-row td { + padding: 0 !important; + background: var(--bg) !important; +} + +.bom-expand-content { + padding: 16px 16px 16px 48px; + border-left: 2px solid var(--cyan-dim); + margin-left: 16px; +} + +.bom-expand-content .info-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 6px 24px; +} + +.bom-expand-content .info-item { + font-size: 12px; + padding: 2px 0; +} + +.bom-expand-content .info-item .lbl { + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-right: 6px; +} + +.bom-expand-content .info-item .val { + font-family: var(--font-mono); + color: var(--text); +} + +.bom-section-title { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--cyan); + margin: 14px 0 8px; + display: flex; + align-items: center; + gap: 8px; +} + +.bom-section-title svg { width: 14px; height: 14px; flex-shrink: 0; } +.bom-section-title:first-child { margin-top: 0; } + +.bom-section-title::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-subtle); +} + +/* ─── File Browser ─── */ +.breadcrumb { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 12px; + margin-bottom: 16px; + flex-wrap: wrap; + padding: 8px 14px; + background: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: 4px; +} + +.breadcrumb a { + color: var(--cyan); + text-decoration: none; + cursor: pointer; + transition: opacity 0.15s; +} + +.breadcrumb a:hover { opacity: 0.7; } +.breadcrumb .sep { color: var(--text-dim); font-size: 10px; } +.breadcrumb .current { color: var(--text); font-weight: 500; } + +.file-name-cell { + display: flex; + align-items: center; + gap: 10px; +} + +.file-name-cell svg { width: 18px; height: 18px; flex-shrink: 0; } + +.file-name-cell a { + color: var(--text); + text-decoration: none; + cursor: pointer; + transition: color 0.15s; +} + +.file-name-cell a:hover { color: var(--cyan); } + +/* ─── Loading / Empty ─── */ +.loading, .empty { + text-align: center; + padding: 60px 24px; + color: var(--text-dim); + font-size: 13px; + font-family: var(--font-mono); +} + +.loading::before { + content: ''; + display: block; + width: 24px; + height: 24px; + border: 2px solid var(--border); + border-top-color: var(--cyan); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 0 auto 12px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ─── Chevron toggle ─── */ +.chevron-toggle { + display: inline-flex; + width: 18px; height: 18px; + align-items: center; + justify-content: center; + transition: transform 0.2s; + color: var(--text-dim); +} + +.chevron-toggle.open { + transform: rotate(90deg); + color: var(--cyan); +} + +/* ─── Drawing cards grid ─── */ +.drawings-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} + +.drawing-card { + background: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 18px 20px; + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.drawing-card:hover { + border-color: var(--cyan); + background: rgba(0, 212, 255, 0.03); + box-shadow: 0 0 0 1px var(--cyan-dim); +} + +.drawing-card-title { + font-family: var(--font-display); + font-size: 15px; + font-weight: 600; + margin-bottom: 2px; +} + +.drawing-card-sub { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 1px; +} + +/* ─── Equipment Groups ─── */ +.equip-group { + margin-bottom: 16px; +} + +.equip-group:last-child { margin-bottom: 0; } + +.equip-header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: 6px 6px 0 0; + cursor: pointer; + transition: all 0.15s; + user-select: none; +} + +.equip-header:hover { background: rgba(0, 212, 255, 0.03); } + +.equip-header .chevron-toggle { flex-shrink: 0; } + +.equip-header-title { + font-family: var(--font-display); + font-size: 15px; + font-weight: 600; +} + +.equip-header-number { + font-family: var(--font-mono); + font-size: 13px; + color: var(--cyan); + font-weight: 600; +} + +.equip-header-meta { + margin-left: auto; + display: flex; + align-items: center; + gap: 12px; +} + +.equip-header-stat { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-dim); +} + +.equip-header-stat strong { + color: var(--text-secondary); +} + +.equip-body { + border: 1px solid var(--border-subtle); + border-top: none; + border-radius: 0 0 6px 6px; + overflow: hidden; +} + +.equip-body table { margin: 0; } + +.equip-group.collapsed .equip-body { display: none; } +.equip-group.collapsed .equip-header { border-radius: 6px; } + +/* ─── Responsive ─── */ +@media (max-width: 768px) { + .sidebar { display: none; } + .main { margin-left: 0; } + .search-box { width: 100%; } + .topbar { padding: 0 16px; } + .page-content { padding: 16px; } +} diff --git a/FabWorks.Api/wwwroot/index.html b/FabWorks.Api/wwwroot/index.html new file mode 100644 index 0000000..0d6c7e3 --- /dev/null +++ b/FabWorks.Api/wwwroot/index.html @@ -0,0 +1,56 @@ + + + + + + FabWorks + + + + + + + + +
+
+
+

Exports

+ +
+
+
+
+
+ + + + + + + + + diff --git a/FabWorks.Api/wwwroot/js/components.js b/FabWorks.Api/wwwroot/js/components.js new file mode 100644 index 0000000..de03a79 --- /dev/null +++ b/FabWorks.Api/wwwroot/js/components.js @@ -0,0 +1,60 @@ +/* ─── BOM Detail Expansion ─── */ +function renderBomDetails(b) { + let html = '
'; + + if (b.cutTemplate) { + const ct = b.cutTemplate; + const displayName = ct.dxfFilePath?.split(/[/\\]/).pop() || ''; + html += ` +
${icons.laser} Cut Template
+
+
File${esc(displayName)}
+
Thickness${fmtThickness(ct.thickness)}
+
K-Factor${ct.kFactor != null ? ct.kFactor : '\u2014'}
+
Bend Radius${ct.defaultBendRadius != null ? ct.defaultBendRadius.toFixed(4) + '"' : '\u2014'}
+
`; + + if (ct.contentHash) { + html += `
+ ${icons.download} Download DXF + ${esc(displayName)} +
`; + } + } + + if (b.formProgram) { + const fp = b.formProgram; + html += ` +
${icons.bend} Form Program
+
+
Program${esc(fp.programName)}
+
Thickness${fmtThickness(fp.thickness)}
+
Material${esc(fp.materialType)}
+
K-Factor${fp.kFactor != null ? fp.kFactor : '\u2014'}
+
Bends${fp.bendCount}
+
Upper Tools${esc(fp.upperToolNames) || '\u2014'}
+
Lower Tools${esc(fp.lowerToolNames) || '\u2014'}
+
+ ${fp.setupNotes ? `
Setup Notes${esc(fp.setupNotes)}
` : ''}`; + } + + html += '
'; + return html; +} + +function toggleEquipGroup(id) { + const group = document.getElementById(id); + const icon = document.getElementById(id + '-icon'); + if (!group) return; + group.classList.toggle('collapsed'); + if (icon) icon.classList.toggle('open', !group.classList.contains('collapsed')); +} + +function toggleBomRow(id) { + const row = document.getElementById(id); + const icon = document.getElementById(id + '-icon'); + if (!row) return; + const visible = row.style.display !== 'none'; + row.style.display = visible ? 'none' : ''; + if (icon) icon.classList.toggle('open', !visible); +} diff --git a/FabWorks.Api/wwwroot/js/helpers.js b/FabWorks.Api/wwwroot/js/helpers.js new file mode 100644 index 0000000..c0792e4 --- /dev/null +++ b/FabWorks.Api/wwwroot/js/helpers.js @@ -0,0 +1,36 @@ +function fmtSize(b) { + if (!b) return '0 B'; + const k = 1024, s = ['B','KB','MB','GB']; + const i = Math.floor(Math.log(b) / Math.log(k)); + return parseFloat((b / Math.pow(k, i)).toFixed(1)) + ' ' + s[i]; +} + +function fmtDate(d) { + if (!d) return ''; + const dt = new Date(d); + return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + + ' ' + dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function fmtThickness(t) { + if (t == null) return '\u2014'; + return `${t.toFixed(4)}"`; +} + +function esc(s) { + return s ? s.replace(//g,'>').replace(/"/g,'"').replace(/'/g,''') : ''; +} + +function setPage(title, tag = '') { + document.getElementById('page-title').textContent = title; + document.getElementById('page-tag').textContent = tag; + document.getElementById('page-tag').style.display = tag ? '' : 'none'; +} + +const api = { + async get(url) { + const r = await fetch(url); + if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); + return r.json(); + } +}; diff --git a/FabWorks.Api/wwwroot/js/icons.js b/FabWorks.Api/wwwroot/js/icons.js new file mode 100644 index 0000000..97a1bda --- /dev/null +++ b/FabWorks.Api/wwwroot/js/icons.js @@ -0,0 +1,19 @@ +const icons = { + search: ``, + folder: ``, + fileDxf: ``, + filePdf: ``, + fileGeneric: ``, + download: ``, + back: ``, + chevron: ``, + laser: ``, + bend: ``, +}; + +function fileIcon(name) { + const ext = name.split('.').pop().toLowerCase(); + if (ext === 'dxf') return icons.fileDxf; + if (ext === 'pdf') return icons.filePdf; + return icons.fileGeneric; +} diff --git a/FabWorks.Api/wwwroot/js/pages.js b/FabWorks.Api/wwwroot/js/pages.js new file mode 100644 index 0000000..f4c99d6 --- /dev/null +++ b/FabWorks.Api/wwwroot/js/pages.js @@ -0,0 +1,396 @@ +const pages = { + async exports(params) { + const actions = document.getElementById('topbar-actions'); + const content = document.getElementById('page-content'); + setPage('Exports'); + + const searchVal = params.q || ''; + actions.innerHTML = ` + `; + + content.innerHTML = `
Loading exports
`; + + const searchInput = document.getElementById('export-search'); + let debounce; + searchInput.addEventListener('input', () => { + clearTimeout(debounce); + debounce = setTimeout(() => router.go('exports', { q: searchInput.value }), 400); + }); + + try { + const searchQ = searchVal ? `&search=${encodeURIComponent(searchVal)}` : ''; + const data = await api.get(`/api/exports?take=500${searchQ}`); + + if (data.items.length === 0) { + content.innerHTML = `
No exports found.
`; + return; + } + + // Deduplicate: keep only the latest export per drawing number + const seen = new Set(); + const unique = data.items.filter(e => { + const dn = e.drawingNumber || ''; + if (seen.has(dn)) return false; + seen.add(dn); + return true; + }); + + // Group by equipment number (first token of drawing number) + const groups = new Map(); + unique.forEach(e => { + const dn = e.drawingNumber || ''; + const spaceIdx = dn.indexOf(' '); + const equip = spaceIdx > 0 ? dn.substring(0, spaceIdx) : (dn || 'Other'); + if (!groups.has(equip)) groups.set(equip, []); + groups.get(equip).push(e); + }); + + // Sort equipment groups by number descending (most recent equipment first) + const sortedGroups = [...groups.entries()].sort((a, b) => { + const numA = parseInt(a[0]) || 0; + const numB = parseInt(b[0]) || 0; + return numB - numA; + }); + + const uniqueEquip = sortedGroups.length; + const uniqueDrawings = unique.length; + setPage('Exports', `${uniqueDrawings} drawings / ${uniqueEquip} equipment`); + + const groupsHtml = sortedGroups.map(([equip, items], gi) => { + const totalBom = items.reduce((s, e) => s + e.bomItemCount, 0); + + const rows = items.map((e, i) => { + const dn = e.drawingNumber || ''; + const spaceIdx = dn.indexOf(' '); + const drawingPart = spaceIdx > 0 ? dn.substring(spaceIdx + 1) : dn; + + return ` + + ${e.id} + ${esc(drawingPart) || '\u2014'} + ${esc(e.title) || ''} + ${e.bomItemCount} + ${esc(e.exportedBy)} + ${fmtDate(e.exportedAt)} + `; + }).join(''); + + return ` +
+
+ ${icons.chevron} + ${esc(equip)} +
+ ${items.length} exports + ${totalBom} items +
+
+
+ + + + + + + + + + ${rows} +
#DrawingTitleItemsExported ByDate
+
+
`; + }).join(''); + + content.innerHTML = ` +
+
Drawings
${uniqueDrawings}
+
Equipment
${uniqueEquip}
+
+ ${groupsHtml}`; + } catch (err) { + content.innerHTML = `
Error: ${esc(err.message)}
`; + } + }, + + async exportDetail(id) { + const actions = document.getElementById('topbar-actions'); + const content = document.getElementById('page-content'); + setPage('Loading...'); + actions.innerHTML = ''; + content.innerHTML = `
Loading export
`; + + 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 ` + + ${hasDetails ? `${icons.chevron}` : ''} + ${esc(b.itemNo)} + ${esc(b.partName)} + ${esc(b.description)} + ${esc(b.material)} + ${b.qty ?? ''} + ${b.totalQty ?? ''} + + ${b.cutTemplate ? `${icons.laser} DXF` : ''} + ${b.formProgram ? `${icons.bend} Form` : ''} + + + ${hasDetails ? `${renderBomDetails(b)}` : ''}`; + }).join(''); + + content.innerHTML = ` + ${icons.back} Back to exports + +
+
Export Information
+
+
+
${esc(exp.drawingNumber) || '\u2014'}
+ ${exp.title ? `
${esc(exp.title)}
` : ''} +
${esc(exp.exportedBy)}
+
${fmtDate(exp.exportedAt)}
+
${esc(exp.sourceFilePath)}
+
+
+
+ +
+
+ BOM Items + ${exp.bomItems?.length || 0} items + ${dxfCount > 0 ? `${icons.download} Download All DXFs` : ''} +
+ ${exp.bomItems?.length ? ` + + + + + + + + + + + + ${bomRows} +
ItemPart NameDescriptionMaterialQtyTotalData
` : '
No BOM items for this export.
'} +
`; + } catch (err) { + content.innerHTML = `
Error: ${esc(err.message)}
`; + } + }, + + async drawings() { + const actions = document.getElementById('topbar-actions'); + const content = document.getElementById('page-content'); + setPage('Drawings'); + actions.innerHTML = ''; + content.innerHTML = `
Loading drawings
`; + + try { + const numbers = await api.get('/api/exports/drawing-numbers'); + if (numbers.length === 0) { + content.innerHTML = `
No drawings found.
`; + return; + } + + numbers.sort(); + setPage('Drawings', `${numbers.length} drawings`); + + const cards = numbers.map((d, i) => ` +
+
${esc(d)}
+
Drawing
+
`).join(''); + + content.innerHTML = ` +
+
Total Drawings
${numbers.length}
+
+
${cards}
`; + } catch (err) { + content.innerHTML = `
Error: ${esc(err.message)}
`; + } + }, + + async drawingDetail(drawingEncoded) { + const drawingNumber = decodeURIComponent(drawingEncoded); + const actions = document.getElementById('topbar-actions'); + const content = document.getElementById('page-content'); + setPage(drawingNumber, 'drawing'); + actions.innerHTML = ''; + content.innerHTML = `
Loading drawing
`; + + try { + const exports = await api.get(`/api/exports/by-drawing?drawingNumber=${encodeURIComponent(drawingNumber)}`); + + if (exports.length === 0) { + content.innerHTML = ` + ${icons.back} Back to drawings +
No exports found for this drawing.
`; + return; + } + + const allBom = []; + exports.forEach(exp => { + (exp.bomItems || []).forEach(b => { + allBom.push({ ...b, exportId: exp.id, exportedAt: exp.exportedAt }); + }); + }); + + const bomRows = allBom.map((b, i) => { + const hasDetails = b.cutTemplate || b.formProgram; + const toggleId = `dbom-${b.id}`; + return ` + + ${hasDetails ? `${icons.chevron}` : ''} + ${esc(b.itemNo)} + ${esc(b.partName)} + ${esc(b.description)} + ${esc(b.material)} + ${b.qty ?? ''} + ${b.totalQty ?? ''} + + ${b.cutTemplate ? `${icons.laser} DXF` : ''} + ${b.formProgram ? `${icons.bend} Form` : ''} + + + ${hasDetails ? `${renderBomDetails(b)}` : ''}`; + }).join(''); + + content.innerHTML = ` + ${icons.back} Back to drawings + +
+
Exports
${exports.length}
+
BOM Items
${allBom.length}
+
Latest Export
${fmtDate(exports[0].exportedAt)}
+
+ +
+
+ All BOM Items + ${allBom.length} items +
+ ${allBom.length ? ` + + + + + + + + + + + + ${bomRows} +
ItemPart NameDescriptionMaterialQtyTotalData
` : '
No BOM items.
'} +
`; + } catch (err) { + content.innerHTML = `
Error: ${esc(err.message)}
`; + } + }, + + async files(params) { + const actions = document.getElementById('topbar-actions'); + const content = document.getElementById('page-content'); + setPage('Files'); + + const searchVal = params.q || ''; + actions.innerHTML = ` +
+ + +
`; + + content.innerHTML = `
Loading files
`; + + const searchInput = document.getElementById('file-search'); + const typeFilter = document.getElementById('file-type-filter'); + let debounce; + const refresh = () => { + clearTimeout(debounce); + debounce = setTimeout(() => router.go('files', { q: searchInput.value + (typeFilter.value ? '&type=' + typeFilter.value : '') }), 400); + }; + searchInput.addEventListener('input', refresh); + typeFilter.addEventListener('change', refresh); + + // Parse search and type from combined param + let searchQ = searchVal; + let typeQ = ''; + if (searchVal.includes('&type=')) { + const parts = searchVal.split('&type='); + searchQ = parts[0]; + typeQ = parts[1] || ''; + searchInput.value = searchQ; + typeFilter.value = typeQ; + } + + try { + let url = '/api/filebrowser/files?'; + if (searchQ) url += `search=${encodeURIComponent(searchQ)}&`; + if (typeQ) url += `type=${encodeURIComponent(typeQ)}&`; + const data = await api.get(url); + + setPage('Files', `${data.total} files`); + + if (data.files.length === 0) { + content.innerHTML = `
No files found.
`; + return; + } + + const rows = data.files.map((f, i) => { + const ext = f.fileType || f.fileName.split('.').pop().toLowerCase(); + const hashShort = f.contentHash ? f.contentHash.substring(0, 12) : ''; + return ` + +
${ext === 'pdf' ? icons.filePdf : icons.fileDxf}${esc(f.fileName)}
+ ${ext.toUpperCase()} + ${esc(f.drawingNumber)} + ${f.thickness != null ? f.thickness.toFixed(4) + '"' : '\u2014'} + ${fmtDate(f.createdAt)} + ${esc(hashShort)} + + ${icons.download} + + `; + }).join(''); + + content.innerHTML = ` +
+ + + + + + + + + + + ${rows} +
NameTypeDrawingThicknessDateHashActions
+
`; + } catch (err) { + content.innerHTML = `
Error: ${esc(err.message)}
`; + } + } +}; diff --git a/FabWorks.Api/wwwroot/js/router.js b/FabWorks.Api/wwwroot/js/router.js new file mode 100644 index 0000000..5b5a311 --- /dev/null +++ b/FabWorks.Api/wwwroot/js/router.js @@ -0,0 +1,35 @@ +const router = { + go(page, params = {}) { + const hash = page + (params.id ? '/' + params.id : '') + (params.q ? '?q=' + encodeURIComponent(params.q) : ''); + location.hash = hash; + }, + parse() { + const h = location.hash.slice(1) || 'exports'; + const [path, qs] = h.split('?'); + const parts = path.split('/'); + const params = {}; + if (qs) qs.split('&').forEach(p => { const [k,v] = p.split('='); params[k] = decodeURIComponent(v); }); + return { page: parts[0], id: parts[1], params }; + }, + init() { + window.addEventListener('hashchange', () => this.dispatch()); + this.dispatch(); + }, + dispatch() { + const { page, id, params } = this.parse(); + 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(); break; + case 'drawing-detail': pages.drawingDetail(id, params); break; + case 'files': pages.files(params); break; + default: pages.exports(params); + } + } +}; From 2721c33a39b5dc03647d7c0a14c6598030aa5d03 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 20:37:28 -0500 Subject: [PATCH 18/34] fix: parse equipment number from part names without drawing number Add equipmentOnlyRegex fallback so names like "5028 Prox switch bracket" correctly extract equipment number 5028 even without a drawing number. Handle null DrawingNo in ToString and UI dropdown population. Co-Authored-By: Claude Opus 4.6 --- ExportDXF/DrawingInfo.cs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/ExportDXF/DrawingInfo.cs b/ExportDXF/DrawingInfo.cs index 19a7863..c05083f 100644 --- a/ExportDXF/DrawingInfo.cs +++ b/ExportDXF/DrawingInfo.cs @@ -1,10 +1,11 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; namespace ExportDXF { public class DrawingInfo { private static Regex drawingFormatRegex = new Regex(@"(?[345]\d{3}(-\d+\w{1,2})?)\s?(?[ABEP]\d+(-?(\d+[A-Z]?))?)", RegexOptions.IgnoreCase); + private static Regex equipmentOnlyRegex = new Regex(@"^(?[345]\d{3}(-\d+\w{1,2})?)\b", RegexOptions.IgnoreCase); public string EquipmentNo { get; set; } @@ -14,6 +15,8 @@ namespace ExportDXF public override string ToString() { + if (string.IsNullOrEmpty(DrawingNo)) + return EquipmentNo ?? string.Empty; return $"{EquipmentNo} {DrawingNo}"; } @@ -35,7 +38,21 @@ namespace ExportDXF var match = drawingFormatRegex.Match(input); if (match.Success == false) + { + // Try matching just the equipment number (e.g. "5028 Prox switch bracket") + var eqMatch = equipmentOnlyRegex.Match(input); + if (eqMatch.Success) + { + return new DrawingInfo + { + EquipmentNo = eqMatch.Groups["equipmentNo"].Value, + DrawingNo = null, + Source = input + }; + } + return null; + } var dwg = new DrawingInfo(); @@ -46,4 +63,4 @@ namespace ExportDXF return dwg; } } -} \ No newline at end of file +} From d3c154b875b4c18bc99f9578c63e424894ac18bc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 20:37:56 -0500 Subject: [PATCH 19/34] chore: reset FabWorks.Core migrations from scratch Delete old incremental migrations and regenerate a single InitialCreate that creates all tables (ExportRecords, BomItems, CutTemplates, FormPrograms) with current schema. Co-Authored-By: Claude Opus 4.6 --- .../20260218113525_InitialCreate.cs | 61 ------- ... 20260218171742_InitialCreate.Designer.cs} | 14 +- .../20260218171742_InitialCreate.cs | 151 ++++++++++++++++++ .../FabWorksDbContextModelSnapshot.cs | 12 ++ 4 files changed, 176 insertions(+), 62 deletions(-) delete mode 100644 FabWorks.Core/Migrations/20260218113525_InitialCreate.cs rename FabWorks.Core/Migrations/{20260218113525_InitialCreate.Designer.cs => 20260218171742_InitialCreate.Designer.cs} (94%) create mode 100644 FabWorks.Core/Migrations/20260218171742_InitialCreate.cs diff --git a/FabWorks.Core/Migrations/20260218113525_InitialCreate.cs b/FabWorks.Core/Migrations/20260218113525_InitialCreate.cs deleted file mode 100644 index 1a41881..0000000 --- a/FabWorks.Core/Migrations/20260218113525_InitialCreate.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace FabWorks.Core.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - // NOTE: ExportRecords, BomItems, and CutTemplates tables already exist - // in the database (created by ExportDXF's ExportDxfDbContext migrations). - // This migration only adds the new FormPrograms table. - - migrationBuilder.CreateTable( - name: "FormPrograms", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - ProgramFilePath = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), - ContentHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), - ProgramName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), - Thickness = table.Column(type: "float", nullable: true), - MaterialType = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), - KFactor = table.Column(type: "float", nullable: true), - BendCount = table.Column(type: "int", nullable: false), - UpperToolNames = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), - LowerToolNames = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), - SetupNotes = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), - BomItemId = table.Column(type: "int", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_FormPrograms", x => x.Id); - table.ForeignKey( - name: "FK_FormPrograms_BomItems_BomItemId", - column: x => x.BomItemId, - principalTable: "BomItems", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_FormPrograms_BomItemId", - table: "FormPrograms", - column: "BomItemId", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "FormPrograms"); - } - } -} diff --git a/FabWorks.Core/Migrations/20260218113525_InitialCreate.Designer.cs b/FabWorks.Core/Migrations/20260218171742_InitialCreate.Designer.cs similarity index 94% rename from FabWorks.Core/Migrations/20260218113525_InitialCreate.Designer.cs rename to FabWorks.Core/Migrations/20260218171742_InitialCreate.Designer.cs index 57ec80a..2b8a06c 100644 --- a/FabWorks.Core/Migrations/20260218113525_InitialCreate.Designer.cs +++ b/FabWorks.Core/Migrations/20260218171742_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace FabWorks.Core.Migrations { [DbContext(typeof(FabWorksDbContext))] - [Migration("20260218113525_InitialCreate")] + [Migration("20260218171742_InitialCreate")] partial class InitialCreate { /// @@ -124,10 +124,18 @@ namespace FabWorks.Core.Migrations SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("DrawingNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("DrawingNumber") .HasMaxLength(100) .HasColumnType("nvarchar(100)"); + b.Property("EquipmentNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("ExportedAt") .HasColumnType("datetime2"); @@ -147,6 +155,10 @@ namespace FabWorks.Core.Migrations .HasMaxLength(500) .HasColumnType("nvarchar(500)"); + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + b.HasKey("Id"); b.ToTable("ExportRecords"); diff --git a/FabWorks.Core/Migrations/20260218171742_InitialCreate.cs b/FabWorks.Core/Migrations/20260218171742_InitialCreate.cs new file mode 100644 index 0000000..77ae070 --- /dev/null +++ b/FabWorks.Core/Migrations/20260218171742_InitialCreate.cs @@ -0,0 +1,151 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FabWorks.Core.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ExportRecords", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + DrawingNumber = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + Title = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + EquipmentNo = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + DrawingNo = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + SourceFilePath = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + OutputFolder = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + ExportedAt = table.Column(type: "datetime2", nullable: false), + ExportedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + PdfContentHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ExportRecords", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "BomItems", + columns: table => new + { + ID = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ItemNo = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + PartNo = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + SortOrder = table.Column(type: "int", nullable: false), + Qty = table.Column(type: "int", nullable: true), + TotalQty = table.Column(type: "int", nullable: true), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + PartName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + ConfigurationName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + Material = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + ExportRecordId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BomItems", x => x.ID); + table.ForeignKey( + name: "FK_BomItems_ExportRecords_ExportRecordId", + column: x => x.ExportRecordId, + principalTable: "ExportRecords", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CutTemplates", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + DxfFilePath = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + ContentHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + CutTemplateName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + Thickness = table.Column(type: "float", nullable: true), + KFactor = table.Column(type: "float", nullable: true), + DefaultBendRadius = table.Column(type: "float", nullable: true), + BomItemId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CutTemplates", x => x.Id); + table.ForeignKey( + name: "FK_CutTemplates_BomItems_BomItemId", + column: x => x.BomItemId, + principalTable: "BomItems", + principalColumn: "ID", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "FormPrograms", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ProgramFilePath = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + ContentHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + ProgramName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + Thickness = table.Column(type: "float", nullable: true), + MaterialType = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + KFactor = table.Column(type: "float", nullable: true), + BendCount = table.Column(type: "int", nullable: false), + UpperToolNames = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + LowerToolNames = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + SetupNotes = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + BomItemId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FormPrograms", x => x.Id); + table.ForeignKey( + name: "FK_FormPrograms_BomItems_BomItemId", + column: x => x.BomItemId, + principalTable: "BomItems", + principalColumn: "ID", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_BomItems_ExportRecordId", + table: "BomItems", + column: "ExportRecordId"); + + migrationBuilder.CreateIndex( + name: "IX_CutTemplates_BomItemId", + table: "CutTemplates", + column: "BomItemId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_FormPrograms_BomItemId", + table: "FormPrograms", + column: "BomItemId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CutTemplates"); + + migrationBuilder.DropTable( + name: "FormPrograms"); + + migrationBuilder.DropTable( + name: "BomItems"); + + migrationBuilder.DropTable( + name: "ExportRecords"); + } + } +} diff --git a/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs b/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs index 1096f92..72ce3fb 100644 --- a/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs +++ b/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs @@ -121,10 +121,18 @@ namespace FabWorks.Core.Migrations SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("DrawingNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("DrawingNumber") .HasMaxLength(100) .HasColumnType("nvarchar(100)"); + b.Property("EquipmentNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("ExportedAt") .HasColumnType("datetime2"); @@ -144,6 +152,10 @@ namespace FabWorks.Core.Migrations .HasMaxLength(500) .HasColumnType("nvarchar(500)"); + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + b.HasKey("Id"); b.ToTable("ExportRecords"); From c06d834e057a110434ebf41239548d6259267e7c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 22:20:03 -0500 Subject: [PATCH 20/34] feat: add PDF download button to export detail page Co-Authored-By: Claude Opus 4.6 --- FabWorks.Api/wwwroot/js/pages.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/FabWorks.Api/wwwroot/js/pages.js b/FabWorks.Api/wwwroot/js/pages.js index f4c99d6..8cce334 100644 --- a/FabWorks.Api/wwwroot/js/pages.js +++ b/FabWorks.Api/wwwroot/js/pages.js @@ -168,7 +168,10 @@ const pages = {
BOM Items ${exp.bomItems?.length || 0} items - ${dxfCount > 0 ? `${icons.download} Download All DXFs` : ''} + + ${exp.pdfContentHash ? `${icons.download} PDF` : ''} + ${dxfCount > 0 ? `${icons.download} All DXFs` : ''} +
${exp.bomItems?.length ? ` From 463916c75ccb4138b3b95a50e222e5e49f956275 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Feb 2026 22:40:22 -0500 Subject: [PATCH 21/34] fix: resolve drawing dropdown race condition and save PDF hash to export record Detach EquipmentBox event before programmatically setting equipment to prevent async UpdateDrawingDropdownAsync from clearing the drawing selection and duplicating entries. Also update ExportRecord.PdfContentHash in StorePdfAsync so the web frontend can serve PDF downloads. Co-Authored-By: Claude Opus 4.6 --- ExportDXF/Forms/MainForm.cs | 8 ++++++++ FabWorks.Api/Services/FileStorageService.cs | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/ExportDXF/Forms/MainForm.cs b/ExportDXF/Forms/MainForm.cs index 1871506..cf124a3 100644 --- a/ExportDXF/Forms/MainForm.cs +++ b/ExportDXF/Forms/MainForm.cs @@ -477,6 +477,9 @@ namespace ExportDXF.Forms if (drawingInfo != null) { + // Detach event to prevent async race when setting equipment + equipmentBox.SelectedIndexChanged -= EquipmentBox_SelectedIndexChanged; + if (!string.IsNullOrEmpty(drawingInfo.EquipmentNo)) { if (!equipmentBox.Items.Contains(drawingInfo.EquipmentNo)) @@ -484,6 +487,11 @@ namespace ExportDXF.Forms equipmentBox.Text = drawingInfo.EquipmentNo; } + // Load drawings for the selected equipment, then set drawing number + await UpdateDrawingDropdownAsync(); + + equipmentBox.SelectedIndexChanged += EquipmentBox_SelectedIndexChanged; + if (!string.IsNullOrEmpty(drawingInfo.DrawingNo)) { if (!drawingNoBox.Items.Contains(drawingInfo.DrawingNo)) diff --git a/FabWorks.Api/Services/FileStorageService.cs b/FabWorks.Api/Services/FileStorageService.cs index f55231e..52cdb0b 100644 --- a/FabWorks.Api/Services/FileStorageService.cs +++ b/FabWorks.Api/Services/FileStorageService.cs @@ -82,6 +82,17 @@ namespace FabWorks.Api.Services 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, From 0d5742124eab722e1201c23a128f05c117cb7e91 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 08:47:11 -0500 Subject: [PATCH 22/34] feat: add revision tracking to CutTemplate and scope BOM items to export record Each export record now keeps a complete BOM snapshot instead of moving BomItems between records. CutTemplate gains a Revision field that auto-increments when the content hash changes across exports for the same drawing+item, and stays the same when the geometry is unchanged. Co-Authored-By: Claude Opus 4.6 --- ExportDXF/ApiClient/FabWorksApiDtos.cs | 1 + .../Controllers/BomItemsController.cs | 44 ++- FabWorks.Api/Controllers/ExportsController.cs | 2 + FabWorks.Api/DTOs/ExportDetailDto.cs | 1 + ...9134027_AddCutTemplateRevision.Designer.cs | 273 ++++++++++++++++++ .../20260219134027_AddCutTemplateRevision.cs | 29 ++ .../FabWorksDbContextModelSnapshot.cs | 3 + FabWorks.Core/Models/CutTemplate.cs | 2 + 8 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.Designer.cs create mode 100644 FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.cs diff --git a/ExportDXF/ApiClient/FabWorksApiDtos.cs b/ExportDXF/ApiClient/FabWorksApiDtos.cs index 2893306..5e496c0 100644 --- a/ExportDXF/ApiClient/FabWorksApiDtos.cs +++ b/ExportDXF/ApiClient/FabWorksApiDtos.cs @@ -39,6 +39,7 @@ namespace ExportDXF.ApiClient public int Id { get; set; } public string DxfFilePath { get; set; } public string ContentHash { get; set; } + public int Revision { get; set; } public double? Thickness { get; set; } public double? KFactor { get; set; } public double? DefaultBendRadius { get; set; } diff --git a/FabWorks.Api/Controllers/BomItemsController.cs b/FabWorks.Api/Controllers/BomItemsController.cs index be41f59..f6027d1 100644 --- a/FabWorks.Api/Controllers/BomItemsController.cs +++ b/FabWorks.Api/Controllers/BomItemsController.cs @@ -53,12 +53,16 @@ namespace FabWorks.Api.Controllers var export = await _db.ExportRecords.FindAsync(exportId); if (export == null) return NotFound("Export record not found"); - // Look for existing BomItem with same PartName + ConfigurationName under the same drawing + // Look up the latest CutTemplate for this drawing+item across all previous exports + // to determine the revision number + var newContentHash = dto.CutTemplate?.ContentHash; + int revision = await ResolveRevisionAsync(export.DrawingNumber, dto.ItemNo, newContentHash); + + // Look for existing BomItem with same PartName + ConfigurationName within this export record var existing = await _db.BomItems .Include(b => b.CutTemplate) .Include(b => b.FormProgram) - .Include(b => b.ExportRecord) - .Where(b => b.ExportRecord.DrawingNumber == export.DrawingNumber + .Where(b => b.ExportRecordId == exportId && b.PartName == (dto.PartName ?? "") && b.ConfigurationName == (dto.ConfigurationName ?? "")) .OrderByDescending(b => b.ID) @@ -66,8 +70,7 @@ namespace FabWorks.Api.Controllers if (existing != null) { - // Update existing: move to new export record and refresh fields - existing.ExportRecordId = exportId; + // Update existing fields existing.PartNo = dto.PartNo ?? ""; existing.SortOrder = dto.SortOrder; existing.Qty = dto.Qty; @@ -81,6 +84,7 @@ namespace FabWorks.Api.Controllers { existing.CutTemplate.DxfFilePath = dto.CutTemplate.DxfFilePath ?? ""; existing.CutTemplate.ContentHash = dto.CutTemplate.ContentHash; + existing.CutTemplate.Revision = revision; existing.CutTemplate.Thickness = dto.CutTemplate.Thickness; existing.CutTemplate.KFactor = dto.CutTemplate.KFactor; existing.CutTemplate.DefaultBendRadius = dto.CutTemplate.DefaultBendRadius; @@ -91,6 +95,7 @@ namespace FabWorks.Api.Controllers { DxfFilePath = dto.CutTemplate.DxfFilePath ?? "", ContentHash = dto.CutTemplate.ContentHash, + Revision = revision, Thickness = dto.CutTemplate.Thickness, KFactor = dto.CutTemplate.KFactor, DefaultBendRadius = dto.CutTemplate.DefaultBendRadius @@ -156,6 +161,7 @@ namespace FabWorks.Api.Controllers { DxfFilePath = dto.CutTemplate.DxfFilePath ?? "", ContentHash = dto.CutTemplate.ContentHash, + Revision = revision, Thickness = dto.CutTemplate.Thickness, KFactor = dto.CutTemplate.KFactor, DefaultBendRadius = dto.CutTemplate.DefaultBendRadius @@ -185,6 +191,33 @@ namespace FabWorks.Api.Controllers return CreatedAtAction(nameof(GetByExport), new { exportId }, MapToDto(item)); } + /// + /// Determines the revision number for a CutTemplate by looking at the most recent + /// CutTemplate for the same drawing number and item number across all exports. + /// Returns 1 if no previous version exists, the same revision if the hash matches, + /// or previous revision + 1 if the hash changed. + /// + private async Task ResolveRevisionAsync(string drawingNumber, string itemNo, string contentHash) + { + if (string.IsNullOrEmpty(drawingNumber) || string.IsNullOrEmpty(itemNo) || string.IsNullOrEmpty(contentHash)) + return 1; + + var previous = await _db.CutTemplates + .Where(c => c.BomItem.ExportRecord.DrawingNumber == drawingNumber + && c.BomItem.ItemNo == itemNo + && c.ContentHash != null) + .OrderByDescending(c => c.Id) + .Select(c => new { c.ContentHash, c.Revision }) + .FirstOrDefaultAsync(); + + if (previous == null) + return 1; + + return previous.ContentHash == contentHash + ? previous.Revision + : previous.Revision + 1; + } + private static BomItemDto MapToDto(BomItem b) => new() { ID = b.ID, @@ -202,6 +235,7 @@ namespace FabWorks.Api.Controllers Id = b.CutTemplate.Id, DxfFilePath = b.CutTemplate.DxfFilePath, ContentHash = b.CutTemplate.ContentHash, + Revision = b.CutTemplate.Revision, Thickness = b.CutTemplate.Thickness, KFactor = b.CutTemplate.KFactor, DefaultBendRadius = b.CutTemplate.DefaultBendRadius diff --git a/FabWorks.Api/Controllers/ExportsController.cs b/FabWorks.Api/Controllers/ExportsController.cs index 1873aee..e1d9a48 100644 --- a/FabWorks.Api/Controllers/ExportsController.cs +++ b/FabWorks.Api/Controllers/ExportsController.cs @@ -239,6 +239,7 @@ namespace FabWorks.Api.Controllers Id = ct.Id, DxfFilePath = ct.DxfFilePath, ContentHash = ct.ContentHash, + Revision = ct.Revision, Thickness = ct.Thickness, KFactor = ct.KFactor, DefaultBendRadius = ct.DefaultBendRadius @@ -324,6 +325,7 @@ namespace FabWorks.Api.Controllers Id = b.CutTemplate.Id, DxfFilePath = b.CutTemplate.DxfFilePath, ContentHash = b.CutTemplate.ContentHash, + Revision = b.CutTemplate.Revision, Thickness = b.CutTemplate.Thickness, KFactor = b.CutTemplate.KFactor, DefaultBendRadius = b.CutTemplate.DefaultBendRadius diff --git a/FabWorks.Api/DTOs/ExportDetailDto.cs b/FabWorks.Api/DTOs/ExportDetailDto.cs index 870ae52..ed6eb78 100644 --- a/FabWorks.Api/DTOs/ExportDetailDto.cs +++ b/FabWorks.Api/DTOs/ExportDetailDto.cs @@ -39,6 +39,7 @@ namespace FabWorks.Api.DTOs public int Id { get; set; } public string DxfFilePath { get; set; } public string ContentHash { get; set; } + public int Revision { get; set; } public double? Thickness { get; set; } public double? KFactor { get; set; } public double? DefaultBendRadius { get; set; } diff --git a/FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.Designer.cs b/FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.Designer.cs new file mode 100644 index 0000000..59ad995 --- /dev/null +++ b/FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.Designer.cs @@ -0,0 +1,273 @@ +// +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("20260219134027_AddCutTemplateRevision")] + partial class AddCutTemplateRevision + { + /// + 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("ID") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ID")); + + b.Property("ConfigurationName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ExportRecordId") + .HasColumnType("int"); + + b.Property("ItemNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Material") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PartNo") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Qty") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TotalQty") + .HasColumnType("int"); + + b.HasKey("ID"); + + b.HasIndex("ExportRecordId"); + + b.ToTable("BomItems"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BomItemId") + .HasColumnType("int"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CutTemplateName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DefaultBendRadius") + .HasColumnType("float"); + + b.Property("DxfFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("KFactor") + .HasColumnType("float"); + + b.Property("Revision") + .HasColumnType("int"); + + b.Property("Thickness") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("BomItemId") + .IsUnique(); + + b.ToTable("CutTemplates"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DrawingNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DrawingNumber") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EquipmentNo") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ExportedAt") + .HasColumnType("datetime2"); + + b.Property("ExportedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OutputFolder") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PdfContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("ExportRecords"); + }); + + modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BendCount") + .HasColumnType("int"); + + b.Property("BomItemId") + .HasColumnType("int"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("KFactor") + .HasColumnType("float"); + + b.Property("LowerToolNames") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaterialType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProgramFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ProgramName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SetupNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Thickness") + .HasColumnType("float"); + + b.Property("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.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.ExportRecord", b => + { + b.Navigation("BomItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.cs b/FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.cs new file mode 100644 index 0000000..4f8d959 --- /dev/null +++ b/FabWorks.Core/Migrations/20260219134027_AddCutTemplateRevision.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FabWorks.Core.Migrations +{ + /// + public partial class AddCutTemplateRevision : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Revision", + table: "CutTemplates", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Revision", + table: "CutTemplates"); + } + } +} diff --git a/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs b/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs index 72ce3fb..c6ebca9 100644 --- a/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs +++ b/FabWorks.Core/Migrations/FabWorksDbContextModelSnapshot.cs @@ -102,6 +102,9 @@ namespace FabWorks.Core.Migrations b.Property("KFactor") .HasColumnType("float"); + b.Property("Revision") + .HasColumnType("int"); + b.Property("Thickness") .HasColumnType("float"); diff --git a/FabWorks.Core/Models/CutTemplate.cs b/FabWorks.Core/Models/CutTemplate.cs index 9a268fb..db0c9eb 100644 --- a/FabWorks.Core/Models/CutTemplate.cs +++ b/FabWorks.Core/Models/CutTemplate.cs @@ -16,6 +16,8 @@ namespace FabWorks.Core.Models set => _thickness = value.HasValue ? Math.Round(value.Value, 8) : null; } + public int Revision { get; set; } = 1; + public double? KFactor { get; set; } private double? _defaultBendRadius; From 7db44640caf99a20d27afe562a5c7535a5501388 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 09:03:21 -0500 Subject: [PATCH 23/34] feat: switch web UI to light theme with larger font sizes Replace dark blueprint theme with a clean light theme for better readability. Bump all font sizes (10px labels to 12px, 13px body text to 14px) and improve text contrast for users with reading glasses. Icon colors now use CSS variables instead of hardcoded hex. Co-Authored-By: Claude Opus 4.6 --- FabWorks.Api/wwwroot/css/styles.css | 182 +++++++++++++------------- FabWorks.Api/wwwroot/js/components.js | 4 +- FabWorks.Api/wwwroot/js/icons.js | 4 +- FabWorks.Api/wwwroot/js/pages.js | 18 +-- 4 files changed, 102 insertions(+), 106 deletions(-) diff --git a/FabWorks.Api/wwwroot/css/styles.css b/FabWorks.Api/wwwroot/css/styles.css index 9dd9643..8586003 100644 --- a/FabWorks.Api/wwwroot/css/styles.css +++ b/FabWorks.Api/wwwroot/css/styles.css @@ -1,21 +1,21 @@ :root { - --bg-deep: #0a0e14; - --bg: #0d1117; - --surface: #151b23; - --surface-raised: #1c2128; - --border: #2a313a; - --border-subtle: #21262d; - --text: #e6edf3; - --text-secondary: #7d8590; - --text-dim: #484f58; - --cyan: #00d4ff; - --cyan-dim: rgba(0, 212, 255, 0.15); - --cyan-glow: rgba(0, 212, 255, 0.3); - --amber: #f0883e; - --amber-dim: rgba(240, 136, 62, 0.15); - --green: #3fb950; - --green-dim: rgba(63, 185, 80, 0.12); - --red: #f85149; + --bg-deep: #f0f1f3; + --bg: #f8f9fa; + --surface: #ffffff; + --surface-raised: #ffffff; + --border: #d0d5dd; + --border-subtle: #e4e7ec; + --text: #1a1a1a; + --text-secondary: #475467; + --text-dim: #667085; + --cyan: #0975b0; + --cyan-dim: rgba(9, 117, 176, 0.1); + --cyan-glow: rgba(9, 117, 176, 0.2); + --amber: #b54708; + --amber-dim: rgba(181, 71, 8, 0.08); + --green: #067647; + --green-dim: rgba(6, 118, 71, 0.08); + --red: #d92d20; --sidebar-w: 64px; --font-display: 'Outfit', sans-serif; --font-body: 'IBM Plex Sans', sans-serif; @@ -33,24 +33,11 @@ body { overflow-x: hidden; } -/* Blueprint grid background */ -body::before { - content: ''; - position: fixed; - inset: 0; - background-image: - linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px), - linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px); - background-size: 48px 48px; - pointer-events: none; - z-index: 0; -} - /* ─── Sidebar ─── */ .sidebar { width: var(--sidebar-w); background: var(--bg-deep); - border-right: 1px solid var(--border-subtle); + border-right: 1px solid var(--border); display: flex; flex-direction: column; align-items: center; @@ -81,7 +68,6 @@ body::before { .sidebar-brand svg { width: 26px; height: 26px; color: var(--cyan); - filter: drop-shadow(0 0 6px rgba(0, 212, 255, 0.4)); } .sidebar-nav { @@ -126,7 +112,6 @@ body::before { height: 20px; background: var(--cyan); border-radius: 0 2px 2px 0; - box-shadow: 0 0 8px var(--cyan-glow); } .nav-item svg { width: 20px; height: 20px; } @@ -136,12 +121,12 @@ body::before { left: calc(100% + 12px); top: 50%; transform: translateY(-50%); - background: var(--surface-raised); + background: var(--text); border: 1px solid var(--border); - color: var(--text); + color: #fff; padding: 4px 10px; border-radius: 4px; - font-size: 12px; + font-size: 13px; font-family: var(--font-body); white-space: nowrap; opacity: 0; @@ -164,9 +149,9 @@ body::before { } .topbar { - background: rgba(13, 17, 23, 0.85); + background: rgba(255, 255, 255, 0.9); backdrop-filter: blur(12px); - border-bottom: 1px solid var(--border-subtle); + border-bottom: 1px solid var(--border); padding: 0 32px; height: 56px; display: flex; @@ -185,14 +170,14 @@ body::before { .topbar h2 { font-family: var(--font-display); - font-size: 16px; + font-size: 18px; font-weight: 600; letter-spacing: -0.01em; } .topbar-tag { font-family: var(--font-mono); - font-size: 10px; + font-size: 12px; color: var(--cyan); background: var(--cyan-dim); padding: 2px 8px; @@ -212,11 +197,6 @@ body::before { to { opacity: 1; transform: translateY(0); } } -@keyframes pulseGlow { - 0%, 100% { box-shadow: 0 0 4px var(--cyan-glow); } - 50% { box-shadow: 0 0 12px var(--cyan-glow); } -} - .animate-in { animation: fadeSlideIn 0.3s ease forwards; opacity: 0; @@ -233,6 +213,7 @@ body::before { border: 1px solid var(--border-subtle); border-radius: 6px; overflow: hidden; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); } .card-header { @@ -240,7 +221,7 @@ body::before { border-bottom: 1px solid var(--border-subtle); font-family: var(--font-display); font-weight: 600; - font-size: 13px; + font-size: 14px; letter-spacing: 0.02em; display: flex; align-items: center; @@ -266,20 +247,21 @@ body::before { padding: 18px 20px; position: relative; overflow: hidden; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); } .stat-card::before { content: ''; position: absolute; top: 0; left: 0; - width: 100%; height: 2px; + width: 100%; height: 3px; background: linear-gradient(90deg, var(--cyan), transparent); - opacity: 0.6; + opacity: 0.5; } .stat-label { font-family: var(--font-mono); - font-size: 10px; + font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1.5px; @@ -295,7 +277,7 @@ body::before { } .stat-value.stat-sm { - font-size: 14px; + font-size: 15px; font-weight: 500; font-family: var(--font-mono); } @@ -309,22 +291,22 @@ th { background: var(--bg); border-bottom: 1px solid var(--border); font-family: var(--font-mono); - font-size: 10px; + font-size: 12px; text-transform: uppercase; - letter-spacing: 1.2px; + letter-spacing: 1px; color: var(--text-dim); - font-weight: 500; + font-weight: 600; white-space: nowrap; } td { - padding: 11px 16px; + padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); - font-size: 13px; + font-size: 14px; } tbody tr { transition: background 0.1s; } -tbody tr:hover td { background: rgba(0, 212, 255, 0.03); } +tbody tr:hover td { background: var(--cyan-dim); } tbody tr:last-child td { border-bottom: none; } /* ─── Badges ─── */ @@ -332,21 +314,21 @@ tbody tr:last-child td { border-bottom: none; } display: inline-flex; align-items: center; gap: 4px; - padding: 2px 8px; + padding: 3px 10px; border-radius: 3px; font-family: var(--font-mono); - font-size: 10px; - font-weight: 500; + font-size: 12px; + font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; } -.badge svg { width: 12px; height: 12px; flex-shrink: 0; } +.badge svg { width: 14px; height: 14px; flex-shrink: 0; } .badge-cyan { background: var(--cyan-dim); color: var(--cyan); } .badge-amber { background: var(--amber-dim); color: var(--amber); } .badge-green { background: var(--green-dim); color: var(--green); } .badge-count { - background: var(--surface-raised); + background: var(--bg); color: var(--text-secondary); border: 1px solid var(--border); } @@ -356,41 +338,53 @@ tbody tr:last-child td { border-bottom: none; } display: inline-flex; align-items: center; gap: 6px; - padding: 6px 12px; + padding: 7px 14px; border-radius: 4px; font-family: var(--font-body); - font-size: 12px; + font-size: 13px; font-weight: 500; cursor: pointer; text-decoration: none; border: 1px solid var(--border); - background: var(--surface-raised); + background: var(--surface); color: var(--text-secondary); transition: all 0.15s; white-space: nowrap; } .btn:hover { - background: var(--surface); + background: var(--bg); color: var(--text); border-color: var(--text-dim); } -.btn svg { width: 13px; height: 13px; } +.btn svg { width: 14px; height: 14px; } .btn-cyan { background: var(--cyan-dim); color: var(--cyan); - border-color: rgba(0, 212, 255, 0.2); + border-color: rgba(9, 117, 176, 0.25); } .btn-cyan:hover { - background: rgba(0, 212, 255, 0.2); - border-color: rgba(0, 212, 255, 0.4); + background: rgba(9, 117, 176, 0.15); + border-color: rgba(9, 117, 176, 0.4); color: var(--cyan); } -.btn-sm { padding: 3px 8px; font-size: 11px; } +.btn-amber { + background: var(--amber-dim); + color: var(--amber); + border-color: rgba(181, 71, 8, 0.25); +} + +.btn-amber:hover { + background: rgba(181, 71, 8, 0.15); + border-color: rgba(181, 71, 8, 0.4); + color: var(--amber); +} + +.btn-sm { padding: 4px 10px; font-size: 12px; } /* ─── Search ─── */ .search-box { @@ -401,18 +395,18 @@ tbody tr:last-child td { border-bottom: none; } border: 1px solid var(--border); border-radius: 4px; padding: 0 12px; - height: 34px; + height: 36px; width: 300px; transition: border-color 0.2s, box-shadow 0.2s; } .search-box:focus-within { border-color: var(--cyan); - box-shadow: 0 0 0 1px var(--cyan-dim); + box-shadow: 0 0 0 2px var(--cyan-dim); } .search-box svg { - width: 14px; height: 14px; + width: 16px; height: 16px; color: var(--text-dim); flex-shrink: 0; } @@ -421,7 +415,7 @@ tbody tr:last-child td { border-bottom: none; } border: none; outline: none; font-family: var(--font-body); - font-size: 13px; + font-size: 14px; width: 100%; background: transparent; color: var(--text); @@ -442,7 +436,7 @@ tbody tr:last-child td { border-bottom: none; } .detail-field label { display: block; font-family: var(--font-mono); - font-size: 10px; + font-size: 12px; text-transform: uppercase; letter-spacing: 1.2px; color: var(--text-dim); @@ -450,14 +444,14 @@ tbody tr:last-child td { border-bottom: none; } } .detail-field .value { - font-size: 14px; + font-size: 15px; font-weight: 500; word-break: break-all; } .detail-field .value.mono { font-family: var(--font-mono); - font-size: 12px; + font-size: 13px; color: var(--text-secondary); } @@ -468,7 +462,7 @@ tbody tr:last-child td { border-bottom: none; } gap: 6px; color: var(--text-dim); text-decoration: none; - font-size: 12px; + font-size: 13px; cursor: pointer; margin-bottom: 20px; font-family: var(--font-mono); @@ -488,7 +482,7 @@ tbody tr:last-child td { border-bottom: none; } .bom-expand-content { padding: 16px 16px 16px 48px; - border-left: 2px solid var(--cyan-dim); + border-left: 3px solid var(--cyan-dim); margin-left: 16px; } @@ -499,14 +493,14 @@ tbody tr:last-child td { border-bottom: none; } } .bom-expand-content .info-item { - font-size: 12px; + font-size: 13px; padding: 2px 0; } .bom-expand-content .info-item .lbl { color: var(--text-dim); font-family: var(--font-mono); - font-size: 10px; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; margin-right: 6px; @@ -519,7 +513,7 @@ tbody tr:last-child td { border-bottom: none; } .bom-section-title { font-family: var(--font-mono); - font-size: 10px; + font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.5px; @@ -546,7 +540,7 @@ tbody tr:last-child td { border-bottom: none; } align-items: center; gap: 6px; font-family: var(--font-mono); - font-size: 12px; + font-size: 13px; margin-bottom: 16px; flex-wrap: wrap; padding: 8px 14px; @@ -563,7 +557,7 @@ tbody tr:last-child td { border-bottom: none; } } .breadcrumb a:hover { opacity: 0.7; } -.breadcrumb .sep { color: var(--text-dim); font-size: 10px; } +.breadcrumb .sep { color: var(--text-dim); font-size: 11px; } .breadcrumb .current { color: var(--text); font-weight: 500; } .file-name-cell { @@ -588,7 +582,7 @@ tbody tr:last-child td { border-bottom: none; } text-align: center; padding: 60px 24px; color: var(--text-dim); - font-size: 13px; + font-size: 14px; font-family: var(--font-mono); } @@ -638,24 +632,25 @@ tbody tr:last-child td { border-bottom: none; } cursor: pointer; transition: all 0.2s; position: relative; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); } .drawing-card:hover { border-color: var(--cyan); - background: rgba(0, 212, 255, 0.03); - box-shadow: 0 0 0 1px var(--cyan-dim); + background: var(--cyan-dim); + box-shadow: 0 2px 8px rgba(9, 117, 176, 0.1); } .drawing-card-title { font-family: var(--font-display); - font-size: 15px; + font-size: 16px; font-weight: 600; margin-bottom: 2px; } .drawing-card-sub { font-family: var(--font-mono); - font-size: 10px; + font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; @@ -679,21 +674,22 @@ tbody tr:last-child td { border-bottom: none; } cursor: pointer; transition: all 0.15s; user-select: none; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); } -.equip-header:hover { background: rgba(0, 212, 255, 0.03); } +.equip-header:hover { background: var(--cyan-dim); } .equip-header .chevron-toggle { flex-shrink: 0; } .equip-header-title { font-family: var(--font-display); - font-size: 15px; + font-size: 16px; font-weight: 600; } .equip-header-number { font-family: var(--font-mono); - font-size: 13px; + font-size: 15px; color: var(--cyan); font-weight: 600; } @@ -707,7 +703,7 @@ tbody tr:last-child td { border-bottom: none; } .equip-header-stat { font-family: var(--font-mono); - font-size: 11px; + font-size: 13px; color: var(--text-dim); } diff --git a/FabWorks.Api/wwwroot/js/components.js b/FabWorks.Api/wwwroot/js/components.js index de03a79..fac1db5 100644 --- a/FabWorks.Api/wwwroot/js/components.js +++ b/FabWorks.Api/wwwroot/js/components.js @@ -17,7 +17,7 @@ function renderBomDetails(b) { if (ct.contentHash) { html += `
${icons.download} Download DXF - ${esc(displayName)} + ${esc(displayName)}
`; } } @@ -35,7 +35,7 @@ function renderBomDetails(b) {
Upper Tools${esc(fp.upperToolNames) || '\u2014'}
Lower Tools${esc(fp.lowerToolNames) || '\u2014'}
- ${fp.setupNotes ? `
Setup Notes${esc(fp.setupNotes)}
` : ''}`; + ${fp.setupNotes ? `
Setup Notes${esc(fp.setupNotes)}
` : ''}`; } html += ''; diff --git a/FabWorks.Api/wwwroot/js/icons.js b/FabWorks.Api/wwwroot/js/icons.js index 97a1bda..c81c93e 100644 --- a/FabWorks.Api/wwwroot/js/icons.js +++ b/FabWorks.Api/wwwroot/js/icons.js @@ -1,8 +1,8 @@ const icons = { search: ``, - folder: ``, + folder: ``, fileDxf: ``, - filePdf: ``, + filePdf: ``, fileGeneric: ``, download: ``, back: ``, diff --git a/FabWorks.Api/wwwroot/js/pages.js b/FabWorks.Api/wwwroot/js/pages.js index 8cce334..36db5ee 100644 --- a/FabWorks.Api/wwwroot/js/pages.js +++ b/FabWorks.Api/wwwroot/js/pages.js @@ -69,12 +69,12 @@ const pages = { return `
- + - + - + `; }).join(''); @@ -137,7 +137,7 @@ const pages = { - + - + - - - + + + From e072919a59fa004cfddff8f3ecd519c62f34ee7a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 09:12:49 -0500 Subject: [PATCH 24/34] fix: prevent date wrapping on exports page Co-Authored-By: Claude Opus 4.6 --- FabWorks.Api/wwwroot/js/pages.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FabWorks.Api/wwwroot/js/pages.js b/FabWorks.Api/wwwroot/js/pages.js index 36db5ee..c08d863 100644 --- a/FabWorks.Api/wwwroot/js/pages.js +++ b/FabWorks.Api/wwwroot/js/pages.js @@ -74,7 +74,7 @@ const pages = { - + `; }).join(''); From 5de40ebafdf7cb6263652aab25d91934ce215d00 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 09:34:59 -0500 Subject: [PATCH 25/34] feat: add delete button to exports list and detail pages Add DELETE /api/exports/{id} endpoint with cascade delete, trash icon buttons on both the exports list and export detail pages, and disable browser caching for static files to prevent stale JS issues. Co-Authored-By: Claude Opus 4.6 --- FabWorks.Api/Controllers/ExportsController.cs | 16 ++ FabWorks.Api/Program.cs | 6 +- FabWorks.Api/wwwroot/css/styles.css | 12 + FabWorks.Api/wwwroot/index.html | 10 +- FabWorks.Api/wwwroot/js/helpers.js | 14 ++ FabWorks.Api/wwwroot/js/icons.js | 1 + FabWorks.Api/wwwroot/js/pages.js | 211 ++++++++++-------- FabWorks.Api/wwwroot/js/router.js | 2 +- 8 files changed, 174 insertions(+), 98 deletions(-) diff --git a/FabWorks.Api/Controllers/ExportsController.cs b/FabWorks.Api/Controllers/ExportsController.cs index e1d9a48..81cb8ea 100644 --- a/FabWorks.Api/Controllers/ExportsController.cs +++ b/FabWorks.Api/Controllers/ExportsController.cs @@ -246,6 +246,22 @@ namespace FabWorks.Api.Controllers }; } + [HttpDelete("{id}")] + public async Task Delete(int id) + { + var record = await _db.ExportRecords + .Include(r => r.BomItems).ThenInclude(b => b.CutTemplate) + .Include(r => r.BomItems).ThenInclude(b => b.FormProgram) + .FirstOrDefaultAsync(r => r.Id == id); + + if (record == null) return NotFound(); + + _db.ExportRecords.Remove(record); + await _db.SaveChangesAsync(); + + return NoContent(); + } + [HttpGet("{id}/download-dxfs")] public async Task DownloadAllDxfs(int id) { diff --git a/FabWorks.Api/Program.cs b/FabWorks.Api/Program.cs index 8121da5..42e6c76 100644 --- a/FabWorks.Api/Program.cs +++ b/FabWorks.Api/Program.cs @@ -17,6 +17,10 @@ builder.Services.AddScoped(); var app = builder.Build(); app.UseDefaultFiles(); -app.UseStaticFiles(); +app.UseStaticFiles(new StaticFileOptions +{ + OnPrepareResponse = ctx => + ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store") +}); app.MapControllers(); app.Run(); diff --git a/FabWorks.Api/wwwroot/css/styles.css b/FabWorks.Api/wwwroot/css/styles.css index 8586003..eadff8d 100644 --- a/FabWorks.Api/wwwroot/css/styles.css +++ b/FabWorks.Api/wwwroot/css/styles.css @@ -384,6 +384,18 @@ tbody tr:last-child td { border-bottom: none; } color: var(--amber); } +.btn-red { + background: rgba(217, 45, 32, 0.08); + color: var(--red); + border-color: rgba(217, 45, 32, 0.25); +} + +.btn-red:hover { + background: rgba(217, 45, 32, 0.15); + border-color: rgba(217, 45, 32, 0.4); + color: var(--red); +} + .btn-sm { padding: 4px 10px; font-size: 12px; } /* ─── Search ─── */ diff --git a/FabWorks.Api/wwwroot/index.html b/FabWorks.Api/wwwroot/index.html index 0d6c7e3..4b1b4c4 100644 --- a/FabWorks.Api/wwwroot/index.html +++ b/FabWorks.Api/wwwroot/index.html @@ -46,11 +46,11 @@
- - - - - + + + + + diff --git a/FabWorks.Api/wwwroot/js/helpers.js b/FabWorks.Api/wwwroot/js/helpers.js index c0792e4..787225f 100644 --- a/FabWorks.Api/wwwroot/js/helpers.js +++ b/FabWorks.Api/wwwroot/js/helpers.js @@ -32,5 +32,19 @@ const api = { const r = await fetch(url); if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); return r.json(); + }, + async del(url) { + const r = await fetch(url, { method: 'DELETE' }); + if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); } }; + +async function deleteExport(id) { + if (!confirm('Delete this export record? This cannot be undone.')) return; + try { + await api.del(`/api/exports/${id}`); + router.dispatch(); + } catch (err) { + alert('Failed to delete: ' + err.message); + } +} diff --git a/FabWorks.Api/wwwroot/js/icons.js b/FabWorks.Api/wwwroot/js/icons.js index c81c93e..5af4ad1 100644 --- a/FabWorks.Api/wwwroot/js/icons.js +++ b/FabWorks.Api/wwwroot/js/icons.js @@ -9,6 +9,7 @@ const icons = { chevron: ``, laser: ``, bend: ``, + trash: ``, }; function fileIcon(name) { diff --git a/FabWorks.Api/wwwroot/js/pages.js b/FabWorks.Api/wwwroot/js/pages.js index c08d863..c89b136 100644 --- a/FabWorks.Api/wwwroot/js/pages.js +++ b/FabWorks.Api/wwwroot/js/pages.js @@ -29,87 +29,34 @@ const pages = { return; } - // Deduplicate: keep only the latest export per drawing number - const seen = new Set(); - const unique = data.items.filter(e => { - const dn = e.drawingNumber || ''; - if (seen.has(dn)) return false; - seen.add(dn); - return true; - }); + setPage('Exports', `${data.items.length} exports`); - // Group by equipment number (first token of drawing number) - const groups = new Map(); - unique.forEach(e => { - const dn = e.drawingNumber || ''; - const spaceIdx = dn.indexOf(' '); - const equip = spaceIdx > 0 ? dn.substring(0, spaceIdx) : (dn || 'Other'); - if (!groups.has(equip)) groups.set(equip, []); - groups.get(equip).push(e); - }); - - // Sort equipment groups by number descending (most recent equipment first) - const sortedGroups = [...groups.entries()].sort((a, b) => { - const numA = parseInt(a[0]) || 0; - const numB = parseInt(b[0]) || 0; - return numB - numA; - }); - - const uniqueEquip = sortedGroups.length; - const uniqueDrawings = unique.length; - setPage('Exports', `${uniqueDrawings} drawings / ${uniqueEquip} equipment`); - - const groupsHtml = sortedGroups.map(([equip, items], gi) => { - const totalBom = items.reduce((s, e) => s + e.bomItemCount, 0); - - const rows = items.map((e, i) => { - const dn = e.drawingNumber || ''; - const spaceIdx = dn.indexOf(' '); - const drawingPart = spaceIdx > 0 ? dn.substring(spaceIdx + 1) : dn; - - return ` -
- - - - - - - `; - }).join(''); - - return ` -
-
- ${icons.chevron} - ${esc(equip)} -
- ${items.length} exports - ${totalBom} items -
-
-
-
${e.id}${e.id} ${esc(drawingPart) || '\u2014'}${esc(e.title) || ''}${esc(e.title) || ''} ${e.bomItemCount} ${esc(e.exportedBy)}${fmtDate(e.exportedAt)}${fmtDate(e.exportedAt)}
${esc(b.itemNo)} ${esc(b.partName)} ${esc(b.description)}${esc(b.material)}${esc(b.material)} ${b.qty ?? ''} ${b.totalQty ?? ''} @@ -260,7 +260,7 @@ const pages = { ${esc(b.itemNo)} ${esc(b.partName)} ${esc(b.description)}${esc(b.material)}${esc(b.material)} ${b.qty ?? ''} ${b.totalQty ?? ''} @@ -317,7 +317,7 @@ const pages = { ${icons.search} - @@ -368,9 +368,9 @@ const pages = {
${ext === 'pdf' ? icons.filePdf : icons.fileDxf}${esc(f.fileName)}
${ext.toUpperCase()} ${esc(f.drawingNumber)}${f.thickness != null ? f.thickness.toFixed(4) + '"' : '\u2014'}${fmtDate(f.createdAt)}${esc(hashShort)}${f.thickness != null ? f.thickness.toFixed(4) + '"' : '\u2014'}${fmtDate(f.createdAt)}${esc(hashShort)} ${icons.download} ${esc(e.title) || ''} ${e.bomItemCount} ${esc(e.exportedBy)}${fmtDate(e.exportedAt)}${fmtDate(e.exportedAt)}
${e.id}${esc(drawingPart) || '\u2014'}${esc(e.title) || ''}${e.bomItemCount}${esc(e.exportedBy)}${fmtDate(e.exportedAt)}
- - - - - - - - - ${rows} -
#DrawingTitleItemsExported ByDate
- - `; - }).join(''); + const rows = data.items.map((e, i) => ` + + ${e.id} + ${esc(e.drawingNumber) || '\u2014'} + ${esc(e.title) || ''} + ${e.bomItemCount} + ${esc(e.exportedBy)} + ${fmtDate(e.exportedAt)} + + `).join(''); content.innerHTML = ` -
-
Drawings
${uniqueDrawings}
-
Equipment
${uniqueEquip}
-
- ${groupsHtml}`; +
+ + + + + + + + + + + ${rows} +
#DrawingTitleItemsExported ByDate
+
`; } catch (err) { content.innerHTML = `
Error: ${esc(err.message)}
`; } @@ -171,6 +118,7 @@ const pages = { ${exp.pdfContentHash ? `${icons.download} PDF` : ''} ${dxfCount > 0 ? `${icons.download} All DXFs` : ''} + ${exp.bomItems?.length ? ` @@ -193,34 +141,115 @@ const pages = { } }, - async drawings() { + async drawings(params) { const actions = document.getElementById('topbar-actions'); const content = document.getElementById('page-content'); setPage('Drawings'); - actions.innerHTML = ''; + + const searchVal = (params && params.q) || ''; + actions.innerHTML = ` + `; + content.innerHTML = `
Loading drawings
`; + const searchInput = document.getElementById('drawing-search'); + let debounce; + searchInput.addEventListener('input', () => { + clearTimeout(debounce); + debounce = setTimeout(() => router.go('drawings', { q: searchInput.value }), 400); + }); + try { - const numbers = await api.get('/api/exports/drawing-numbers'); - if (numbers.length === 0) { + const searchQ = searchVal ? `&search=${encodeURIComponent(searchVal)}` : ''; + const data = await api.get(`/api/exports?take=500${searchQ}`); + + if (data.items.length === 0) { content.innerHTML = `
No drawings found.
`; return; } - numbers.sort(); - setPage('Drawings', `${numbers.length} drawings`); + // Deduplicate: keep only the latest export per drawing number + const seen = new Set(); + const unique = data.items.filter(e => { + const dn = e.drawingNumber || ''; + if (seen.has(dn)) return false; + seen.add(dn); + return true; + }); - const cards = numbers.map((d, i) => ` -
-
${esc(d)}
-
Drawing
-
`).join(''); + // Group by equipment number (first token of drawing number) + const groups = new Map(); + unique.forEach(e => { + const dn = e.drawingNumber || ''; + const spaceIdx = dn.indexOf(' '); + const equip = spaceIdx > 0 ? dn.substring(0, spaceIdx) : (dn || 'Other'); + if (!groups.has(equip)) groups.set(equip, []); + groups.get(equip).push(e); + }); + + // Sort equipment groups by number descending (most recent equipment first) + const sortedGroups = [...groups.entries()].sort((a, b) => { + const numA = parseInt(a[0]) || 0; + const numB = parseInt(b[0]) || 0; + return numB - numA; + }); + + const uniqueEquip = sortedGroups.length; + const uniqueDrawings = unique.length; + setPage('Drawings', `${uniqueDrawings} drawings / ${uniqueEquip} equipment`); + + const groupsHtml = sortedGroups.map(([equip, items], gi) => { + const totalBom = items.reduce((s, e) => s + e.bomItemCount, 0); + + const rows = items.map((e, i) => { + const dn = e.drawingNumber || ''; + const spaceIdx = dn.indexOf(' '); + const drawingPart = spaceIdx > 0 ? dn.substring(spaceIdx + 1) : dn; + + return ` + + ${esc(drawingPart) || '\u2014'} + ${esc(e.title) || ''} + ${e.bomItemCount} + ${esc(e.exportedBy)} + ${fmtDate(e.exportedAt)} + `; + }).join(''); + + return ` +
+
+ ${icons.chevron} + ${esc(equip)} +
+ ${items.length} drawings + ${totalBom} items +
+
+
+ + + + + + + + + ${rows} +
DrawingTitleItemsExported ByLatest Export
+
+
`; + }).join(''); content.innerHTML = `
-
Total Drawings
${numbers.length}
+
Drawings
${uniqueDrawings}
+
Equipment
${uniqueEquip}
-
${cards}
`; + ${groupsHtml}`; } catch (err) { content.innerHTML = `
Error: ${esc(err.message)}
`; } diff --git a/FabWorks.Api/wwwroot/js/router.js b/FabWorks.Api/wwwroot/js/router.js index 5b5a311..66fdfc1 100644 --- a/FabWorks.Api/wwwroot/js/router.js +++ b/FabWorks.Api/wwwroot/js/router.js @@ -26,7 +26,7 @@ const router = { switch(page) { case 'exports': pages.exports(params); break; case 'export-detail': pages.exportDetail(id); break; - case 'drawings': pages.drawings(); break; + case 'drawings': pages.drawings(params); break; case 'drawing-detail': pages.drawingDetail(id, params); break; case 'files': pages.files(params); break; default: pages.exports(params); From 1266378b519197a6390f8869abe4c04df7b5a8f7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 12:37:29 -0500 Subject: [PATCH 26/34] fix: update EtchBendLines submodule with etch line fix Updates submodule to include the yield break -> return lines fix that was causing etch lines to be silently discarded. Co-Authored-By: Claude Opus 4.6 --- EtchBendLines | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EtchBendLines b/EtchBendLines index 89d987f..b0e4844 160000 --- a/EtchBendLines +++ b/EtchBendLines @@ -1 +1 @@ -Subproject commit 89d987f6c6923b458217017300789ea956114972 +Subproject commit b0e48442ca58dbbece069f8146a48a674a4ee5b6 From dcc508d479f3df61799f12e17a9d0256bbff0df9 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 12:52:48 -0500 Subject: [PATCH 27/34] feat: update EtchBendLines submodule with ACadSharp migration Co-Authored-By: Claude Opus 4.6 --- EtchBendLines | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EtchBendLines b/EtchBendLines index b0e4844..2e8f0e6 160000 --- a/EtchBendLines +++ b/EtchBendLines @@ -1 +1 @@ -Subproject commit b0e48442ca58dbbece069f8146a48a674a4ee5b6 +Subproject commit 2e8f0e60c5ae63381a0b946914592bfe242a4d3d From e59584a5c08fa55331b1162b5c797f242b5e45ad Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 14:13:12 -0500 Subject: [PATCH 28/34] fix: update EtchBendLines submodule with ACAD_GROUP dictionary fix Co-Authored-By: Claude Opus 4.6 --- EtchBendLines | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EtchBendLines b/EtchBendLines index 2e8f0e6..f04c752 160000 --- a/EtchBendLines +++ b/EtchBendLines @@ -1 +1 @@ -Subproject commit 2e8f0e60c5ae63381a0b946914592bfe242a4d3d +Subproject commit f04c75235c3f989860c213fe4b6dabb996ecacde From 26e9233b309b770a2c07f446289d17fa718190dd Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 14:20:57 -0500 Subject: [PATCH 29/34] fix: update EtchBendLines submodule with ACadSharp 3.4.9 upgrade Fixes DXF files failing to open with "GroupTable dictionary was not defined in NamedObject dictionary" error. Co-Authored-By: Claude Opus 4.6 --- EtchBendLines | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EtchBendLines b/EtchBendLines index f04c752..6e131d4 160000 --- a/EtchBendLines +++ b/EtchBendLines @@ -1 +1 @@ -Subproject commit f04c75235c3f989860c213fe4b6dabb996ecacde +Subproject commit 6e131d402e52c59fdef8ac7711c5206d0afe3f20 From 77d015737018869450ffebc6b1fdf63010f66437 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 14:34:05 -0500 Subject: [PATCH 30/34] fix: update EtchBendLines submodule with bend detection fixes Fixes missing etch lines and incorrect bend layer assignment after the ACadSharp migration. Co-Authored-By: Claude Opus 4.6 --- EtchBendLines | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EtchBendLines b/EtchBendLines index 6e131d4..bf36a56 160000 --- a/EtchBendLines +++ b/EtchBendLines @@ -1 +1 @@ -Subproject commit 6e131d402e52c59fdef8ac7711c5206d0afe3f20 +Subproject commit bf36a56387da18f8f802b08c816bc7b078f6b921 From 4a3f33db3356c62e2a806c9f32904ed61eb28034 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 15:51:59 -0500 Subject: [PATCH 31/34] fix: update EtchBendLines submodule with bend line ByLayer color Co-Authored-By: Claude Opus 4.6 --- EtchBendLines | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EtchBendLines b/EtchBendLines index bf36a56..f2f50f9 160000 --- a/EtchBendLines +++ b/EtchBendLines @@ -1 +1 @@ -Subproject commit bf36a56387da18f8f802b08c816bc7b078f6b921 +Subproject commit f2f50f9914e0289871a3ee1b70ef7a2905cd200a From 622cbf117072501c18028c514f6cd20020fa316d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 16:28:45 -0500 Subject: [PATCH 32/34] fix: update EtchBendLines submodule with degree symbol fix Co-Authored-By: Claude Opus 4.6 --- EtchBendLines | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EtchBendLines b/EtchBendLines index f2f50f9..3c1700c 160000 --- a/EtchBendLines +++ b/EtchBendLines @@ -1 +1 @@ -Subproject commit f2f50f9914e0289871a3ee1b70ef7a2905cd200a +Subproject commit 3c1700c480bd8b1ba6d95c52d65ba9af694a6825 From f9e7ace35dfe20a353e42a239e9f79b675f0c3f4 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 16:31:57 -0500 Subject: [PATCH 33/34] fix: repair double-encoded degree symbol in DXF output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACadSharp misreads UTF-8 degree symbol (C2 B0) as two ANSI_1252 characters (°) then writes that back out. Post-process the saved DXF to replace ° with ° so bend notes display correctly. Co-Authored-By: Claude Opus 4.6 --- EtchBendLines | 2 +- ExportDXF/Services/PartExporter.cs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/EtchBendLines b/EtchBendLines index 3c1700c..da4d322 160000 --- a/EtchBendLines +++ b/EtchBendLines @@ -1 +1 @@ -Subproject commit 3c1700c480bd8b1ba6d95c52d65ba9af694a6825 +Subproject commit da4d3228b0eede73a665b0a814f7b8ac818bcc94 diff --git a/ExportDXF/Services/PartExporter.cs b/ExportDXF/Services/PartExporter.cs index f7522ad..eb314f7 100644 --- a/ExportDXF/Services/PartExporter.cs +++ b/ExportDXF/Services/PartExporter.cs @@ -307,6 +307,7 @@ namespace ExportDXF.Services { var etcher = new EtchBendLines.Etcher(); etcher.AddEtchLines(dxfPath); + FixDegreeSymbol(dxfPath); } catch (Exception) { @@ -314,6 +315,20 @@ namespace ExportDXF.Services } } + /// + /// ACadSharp misreads the UTF-8 degree symbol (C2 B0) as two ANSI_1252 + /// characters (°). Fix it in the written DXF so bend notes display correctly. + /// + private static void FixDegreeSymbol(string path) + { + var text = System.IO.File.ReadAllText(path); + if (text.Contains("\u00C2\u00B0")) + { + text = text.Replace("\u00C2\u00B0", "\u00B0"); + System.IO.File.WriteAllText(path, text); + } + } + private string GetSinglePartFileName(ModelDoc2 model, string prefix) { var title = model.GetTitle().Replace(".SLDPRT", ""); From 036ab2a55ada2d2eee1643fde5cd8ed6680ab528 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 16:42:54 -0500 Subject: [PATCH 34/34] docs: add context to FixDegreeSymbol workaround Co-Authored-By: Claude Opus 4.6 --- ExportDXF/Services/PartExporter.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ExportDXF/Services/PartExporter.cs b/ExportDXF/Services/PartExporter.cs index eb314f7..789e9bf 100644 --- a/ExportDXF/Services/PartExporter.cs +++ b/ExportDXF/Services/PartExporter.cs @@ -316,8 +316,11 @@ namespace ExportDXF.Services } /// - /// ACadSharp misreads the UTF-8 degree symbol (C2 B0) as two ANSI_1252 - /// characters (°). Fix it in the written DXF so bend notes display correctly. + /// Workaround for ACadSharp encoding bug (no upstream fix as of v3.4.9). + /// ACadSharp's DxfReader uses $DWGCODEPAGE (ANSI_1252) to decode text, but + /// AC1018+ DXF files use UTF-8. The degree symbol ° (UTF-8: C2 B0) gets + /// misread as two ANSI_1252 characters:  (C2) and ° (B0). + /// See: https://github.com/DomCR/ACadSharp/issues?q=encoding /// private static void FixDegreeSymbol(string path) {