using System.Security.Cryptography; namespace OpenNest.Data; /// A drawing to import into a set: its library details, package and quantity per set. public sealed class DrawingSetImportItem { public DrawingSetImportItem(string label, DrawingLibraryRecord metadata, byte[] package, long quantity) { Label = label ?? ""; Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); Package = package ?? throw new ArgumentNullException(nameof(package)); Quantity = quantity; } /// How the operator knows the drawing, such as "PT01 (items 3, 7)". public string Label { get; } /// The details a new library drawing gets; customer, name, material, grade, thickness, units and cut type are its identity (D4). public DrawingLibraryRecord Metadata { get; } /// The canonical drawing package, compared by geometry with the library's. public byte[] Package { get; } public long Quantity { get; } } /// What an import does with one drawing. public enum DrawingSetImportAction { /// An active library drawing with the same identity holds the same geometry: it is used as it is. Reuse, /// One active library drawing has the same identity but other geometry: it gains the next revision. Revise, /// No active library drawing has this identity: a new one is uploaded. Create, /// The drawing cannot be imported; says why. Skip, } /// One planned drawing: the action and the library drawing it uses or revises. public sealed class DrawingSetImportEntry { internal DrawingSetImportEntry(DrawingSetImportItem item, DrawingSetImportAction action, DrawingLibraryRecord? existing, string reason, Guid newId = default) { Item = item; Action = action; Existing = existing; Reason = reason; NewId = newId; } public DrawingSetImportItem Item { get; } public DrawingSetImportAction Action { get; } /// The library drawing reused or revised, as listed when planned. public DrawingLibraryRecord? Existing { get; } /// Why a skipped drawing is left out; empty otherwise. public string Reason { get; } /// The id a created drawing is uploaded under, allocated when planned. public Guid NewId { get; } /// The operator-facing account of the action, such as "New revision 4 (geometry changed)". public string Describe() => Action switch { DrawingSetImportAction.Reuse => $"In the library (revision {Existing!.Revision})", DrawingSetImportAction.Revise => $"New revision {Existing!.Revision + 1} (geometry changed)", DrawingSetImportAction.Create => "New library drawing", _ => "Not imported: " + Reason, }; } /// A library write failed part-way; lists what was stored before it. public sealed class DrawingSetImportException : IOException { internal DrawingSetImportException(string message, IReadOnlyList written, Exception inner) : base(message, inner) { Written = written; } public IReadOnlyList Written { get; } } /// /// Imports drawings (for example a BOM's) into a new drawing set through the library. /// only reads: it matches each drawing to the active library /// drawings with its identity (D4: normalized customer, name, material and grade, exact /// thickness, units and cut type) and compares geometry. Same identity and /// geometry: reuse the drawing. Same identity, other geometry: the next revision of that /// drawing. No match: a new drawing. Several matches with none holding the geometry are /// skipped, as are invalid items and a second item with the identity of an earlier one. /// /// is the only step that writes, and only the uploads and /// revisions the plan lists; the caller confirms them first. It then adds every /// imported drawing to a , which stores nothing /// until the operator saves the set. A failed write stops the import with the drawings /// already stored listed; planning the same drawings again reuses them. /// /// public sealed class DrawingSetImport { private DrawingSetImport(IReadOnlyList entries) { Entries = entries; } /// Every item, in the order given. public IReadOnlyList Entries { get; } public IEnumerable Imported => Entries.Where(e => e.Action != DrawingSetImportAction.Skip); public int Count(DrawingSetImportAction action) => Entries.Count(e => e.Action == action); /// True when applying stores anything in the library. public bool HasWrites => Entries.Any(e => e.Action is DrawingSetImportAction.Create or DrawingSetImportAction.Revise); /// /// A key equal for packages with the same geometry (OpenNest.IO.DrawingPackage.GeometryKey): /// a file read from another folder records another path, and older packages carry random /// entity ids, so equal hashes are not required. It may /// throw for an unreadable package. /// public static async Task PlanAsync(IDrawingLibrary library, IEnumerable items, Func geometryKey, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(library); ArgumentNullException.ThrowIfNull(items); ArgumentNullException.ThrowIfNull(geometryKey); var entries = new List(); var planned = new List(); foreach (var item in items) { var reason = DrawingLibraryRules.GetMetadataError(item.Metadata) ?? DrawingLibraryRules.GetPackageError(item.Package) ?? (item.Quantity < 1 || item.Quantity > DrawingSetRules.MaxQuantity ? $"quantity {item.Quantity:N0} is outside 1 to {DrawingSetRules.MaxQuantity:N0}" : null); var twin = planned.FirstOrDefault(p => DrawingLibraryDuplicates.SameDuplicateKey(p.Metadata, item.Metadata)); if (reason == null && twin != null) reason = $"same name and details as {twin.Label}; give one of them another material or thickness"; if (reason != null) { entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Skip, null, reason)); continue; } planned.Add(item); var matches = await DrawingLibraryDuplicates.FindMatchesAsync(library, item.Metadata, cancellationToken); var same = await FindSameGeometryAsync(library, item, matches, geometryKey, cancellationToken); if (same != null) entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Reuse, same, "")); else if (matches.Count == 1) entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Revise, matches[0], "")); else if (matches.Count > 1) entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Skip, null, $"{matches.Count} library drawings have these details; add the right one in the set editor")); else entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Create, null, "", Guid.NewGuid())); } return new DrawingSetImport(entries); } // The first match holding the item's geometry: by package hash, otherwise by downloading // the match's current package and comparing geometry keys. private static async Task FindSameGeometryAsync(IDrawingLibrary library, DrawingSetImportItem item, IReadOnlyList matches, Func geometryKey, CancellationToken cancellationToken) { if (matches.Count == 0) return null; var hash = Sha256(item.Package); var same = matches.FirstOrDefault(m => string.Equals(m.PackageSha256, hash, StringComparison.OrdinalIgnoreCase)); if (same != null) return same; var key = geometryKey(item.Package); foreach (var match in matches) { var stored = await library.GetRevisionPackageAsync(match.Id, match.Revision, cancellationToken); if (stored == null) continue; try { if (geometryKey(stored) == key) return match; } catch (InvalidDataException) { // An unreadable stored package is not the same geometry; revising replaces it. } } return null; } /// The library changes makes, one line each, for the operator to confirm. public string DescribeWrites() { var lines = new List(); var created = Entries.Where(e => e.Action == DrawingSetImportAction.Create).ToList(); var revised = Entries.Where(e => e.Action == DrawingSetImportAction.Revise).ToList(); if (created.Count > 0) { lines.Add($"{created.Count} new library drawing{(created.Count == 1 ? "" : "s")}:"); lines.AddRange(created.Select(e => "- " + e.Item.Label)); } if (revised.Count > 0) { if (lines.Count > 0) lines.Add(""); lines.Add($"{revised.Count} new revision{(revised.Count == 1 ? "" : "s")} of library drawings whose geometry changed:"); lines.AddRange(revised.Select(e => $"- {e.Item.Label}: revision {e.Existing!.Revision} to {e.Existing.Revision + 1}")); } return string.Join(Environment.NewLine, lines); } /// /// Stores the planned uploads and revisions, in order, then adds every imported drawing /// to at its quantity. A revision is guarded by the revision it /// was planned against and keeps that drawing's details, notes and settings. When a /// write fails, is left unchanged and a /// lists what was stored before it. /// public async Task> ApplyAsync(IDrawingLibraryStore library, DrawingSetEditSession edit, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(library); ArgumentNullException.ThrowIfNull(edit); var written = new List(); var members = new List<(DrawingLibraryRecord Record, int Quantity)>(); foreach (var entry in Imported) { DrawingLibraryRecord record; try { record = entry.Action switch { DrawingSetImportAction.Create => await library.UploadAsync(Created(entry), entry.Item.Package, cancellationToken), DrawingSetImportAction.Revise => await library.UpdatePackageAsync(entry.Existing!.Id, Revised(entry.Existing, entry.Item.Metadata.SavedBy), entry.Item.Package, entry.Existing.Revision, cancellationToken), _ => entry.Existing!, }; } catch (Exception ex) when (ex is IOException or HttpRequestException or TaskCanceledException) { var stored = written.Count == 0 ? "Nothing was stored in the library." : $"Already stored: {string.Join(", ", written.Select(r => r.Name))}. Importing again reuses them."; throw new DrawingSetImportException( $"Could not store '{entry.Item.Label}' in the drawing library: {ex.Message}{Environment.NewLine}{stored}", written, ex); } var expectedId = entry.Action == DrawingSetImportAction.Create ? entry.NewId : entry.Existing!.Id; if (record.Id != expectedId) throw new DrawingSetImportException( $"The nest server returned a different library drawing than '{entry.Item.Label}'. Import again to check what was stored.", written, new IOException("Unexpected drawing id.")); if (entry.Action != DrawingSetImportAction.Reuse) written.Add(record); members.Add((record, (int)entry.Item.Quantity)); } foreach (var (record, quantity) in members) edit.AddDrawings(new[] { record }, quantity); return written; } private static DrawingLibraryRecord Created(DrawingSetImportEntry entry) { var record = Copy(entry.Item.Metadata); record.Id = entry.NewId; record.Revision = 0; record.Thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness); record.Archived = false; return record; } // The identity already matches; keep the stored spelling, notes and settings. private static DrawingLibraryRecord Revised(DrawingLibraryRecord existing, string savedBy) { var record = Copy(existing); record.Revision = 0; record.SavedBy = savedBy; return record; } private static DrawingLibraryRecord Copy(DrawingLibraryRecord record) => new() { Id = record.Id, Revision = record.Revision, Name = record.Name, Customer = record.Customer, MaterialName = record.MaterialName, MaterialGrade = record.MaterialGrade, Thickness = record.Thickness, Units = record.Units, CutType = record.CutType, Notes = record.Notes, Settings = record.Settings, Archived = record.Archived, SavedBy = record.SavedBy, }; private static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); }