From 719dca1ca55d823f58c9634eff5232e77a2a0a56 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 17 Feb 2026 13:09:02 -0500 Subject: [PATCH] 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; + } + } +}