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 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 13:09:02 -05:00
parent a17d8cac49
commit 719dca1ca5
12 changed files with 725 additions and 85 deletions

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("ExportDXF.Models.BomItem", b =>
{
b.Property<int>("ID")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("ID"));
b.Property<string>("ConfigurationName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("CutTemplateName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<double?>("DefaultBendRadius")
.HasColumnType("float");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("DxfFilePath")
.HasColumnType("nvarchar(max)");
b.Property<int>("ExportRecordId")
.HasColumnType("int");
b.Property<string>("ItemNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<double?>("KFactor")
.HasColumnType("float");
b.Property<string>("Material")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("PartName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("PartNo")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("Qty")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<double?>("Thickness")
.HasColumnType("float");
b.Property<int?>("TotalQty")
.HasColumnType("int");
b.HasKey("ID");
b.HasIndex("ExportRecordId");
b.ToTable("BomItems");
});
modelBuilder.Entity("ExportDXF.Models.ExportRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("DrawingNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("ExportedAt")
.HasColumnType("datetime2");
b.Property<string>("ExportedBy")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("OutputFolder")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("PdfContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("SourceFilePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.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
}
}
}

View File

@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ExportDXF.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExportRecords",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DrawingNumber = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
SourceFilePath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
OutputFolder = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
ExportedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ExportedBy = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
PdfContentHash = table.Column<string>(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<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ItemNo = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
PartNo = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
SortOrder = table.Column<int>(type: "int", nullable: false),
Qty = table.Column<int>(type: "int", nullable: true),
TotalQty = table.Column<int>(type: "int", nullable: true),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
PartName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ConfigurationName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Material = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
CutTemplateName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
DxfFilePath = table.Column<string>(type: "nvarchar(max)", nullable: true),
ContentHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Thickness = table.Column<double>(type: "float", nullable: true),
KFactor = table.Column<double>(type: "float", nullable: true),
DefaultBendRadius = table.Column<double>(type: "float", nullable: true),
ExportRecordId = table.Column<int>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BomItems");
migrationBuilder.DropTable(
name: "ExportRecords");
}
}
}

View File

@@ -32,6 +32,16 @@ namespace ExportDXF.Services
/// </summary>
public string FilePrefix { get; set; }
/// <summary>
/// Equipment number from the UI (e.g., "5028").
/// </summary>
public string Equipment { get; set; }
/// <summary>
/// Drawing number from the UI (e.g., "A02", "Misc").
/// </summary>
public string DrawingNo { get; set; }
/// <summary>
/// Selected Equipment ID for API operations (optional).
/// </summary>

View File

@@ -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<BomItem> BomItems { get; set; } = new List<BomItem>();
}

View File

@@ -61,5 +61,15 @@ namespace ExportDXF.Services
/// The SolidWorks component reference.
/// </summary>
public Component2 Component { get; set; }
/// <summary>
/// SHA256 content hash of the exported DXF (transient, not persisted).
/// </summary>
public string ContentHash { get; set; }
/// <summary>
/// Path to the stashed (backed-up) previous DXF file (transient, not persisted).
/// </summary>
public string StashedFilePath { get; set; }
}
}

View File

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

View File

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

View File

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

View File

@@ -15,11 +15,12 @@ namespace ExportDXF.Services
{
/// <summary>
/// Exports a single part document to DXF.
/// Returns an Item with export metadata (filename, hash, sheet metal properties), or null if export failed.
/// </summary>
/// <param name="part">The part document to export.</param>
/// <param name="saveDirectory">The directory where the DXF file will be saved.</param>
/// <param name="context">The export context.</param>
void ExportSinglePart(PartDoc part, string saveDirectory, ExportContext context);
Item ExportSinglePart(PartDoc part, string saveDirectory, ExportContext context);
/// <summary>
/// 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;

View File

@@ -0,0 +1,118 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace ExportDXF.Utilities
{
public static class ContentHasher
{
/// <summary>
/// Computes a SHA256 hash of DXF file content, skipping the HEADER section
/// which contains timestamps that change on every save.
/// </summary>
public static string ComputeDxfContentHash(string filePath)
{
var text = File.ReadAllText(filePath);
var contentStart = FindEndOfHeader(text);
var content = contentStart >= 0 ? text.Substring(contentStart) : text;
using (var sha = SHA256.Create())
{
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(content));
return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
}
}
/// <summary>
/// Computes a SHA256 hash of the entire file contents (for PDFs and other binary files).
/// </summary>
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();
}
}
/// <summary>
/// Finds the position immediately after the HEADER section's ENDSEC marker.
/// DXF HEADER format:
/// 0\nSECTION\n2\nHEADER\n...variables...\n0\nENDSEC\n
/// Returns -1 if no HEADER section is found.
/// </summary>
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;
}
/// <summary>
/// Finds a DXF group code pair (code line followed by value line) starting from the given position.
/// Returns the position of the value line, or -1 if not found.
/// </summary>
private static int FindGroupCode(string text, int startIndex, string groupCode, string value)
{
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;
}
}
}