diff --git a/OpenNest.Data/DrawingLibrarySaveSession.cs b/OpenNest.Data/DrawingLibrarySaveSession.cs
new file mode 100644
index 00000000..1ac6f6fb
--- /dev/null
+++ b/OpenNest.Data/DrawingLibrarySaveSession.cs
@@ -0,0 +1,405 @@
+using System.Security.Cryptography;
+using OpenNest;
+
+namespace OpenNest.Data;
+
+/// What one save to the drawing library did.
+public enum DrawingLibrarySaveOutcome
+{
+ /// A new library drawing was stored as revision 1.
+ Created,
+
+ /// The chosen library drawing gained the next revision.
+ Revised,
+
+ /// The chosen library drawing already held this exact package and metadata.
+ Unchanged,
+}
+
+/// The saved library revision; the nest drawing's link now points at it.
+public sealed class DrawingLibrarySaveResult
+{
+ internal DrawingLibrarySaveResult(DrawingLibraryRecord saved, DrawingLibrarySaveOutcome outcome, bool recoveredEarlierSave)
+ {
+ Saved = saved;
+ Outcome = outcome;
+ RecoveredEarlierSave = recoveredEarlierSave;
+ }
+
+ public DrawingLibraryRecord Saved { get; }
+
+ public DrawingLibrarySaveOutcome Outcome { get; }
+
+ ///
+ /// True when an earlier save whose reply was lost had in fact been stored, and it
+ /// differed from this request. That earlier save is the result; this request was not
+ /// sent, so the operator must save again to store anything changed since.
+ ///
+ public bool RecoveredEarlierSave { get; }
+}
+
+///
+/// Active library drawings that a save could revise (D4): the ones whose normalized
+/// customer, name, material and grade, and exact thickness, units and cut type equal the
+/// metadata being saved, newest first, plus the drawing's own linked library drawing.
+///
+public sealed class DrawingLibraryDuplicates
+{
+ internal DrawingLibraryDuplicates(DrawingLibraryRecord metadata, IReadOnlyList matches,
+ DrawingLibraryRecord? linked)
+ {
+ Metadata = metadata;
+ Matches = matches;
+ Linked = linked;
+ if (linked != null && matches.Any(m => m.Id == linked.Id))
+ Default = matches.First(m => m.Id == linked.Id);
+ else if (matches.Count == 1)
+ Default = matches[0];
+ }
+
+ /// A copy of the metadata these duplicates were found for.
+ public DrawingLibraryRecord Metadata { get; }
+
+ public IReadOnlyList Matches { get; }
+
+ ///
+ /// The drawing's linked library drawing on this server when it is active, even if its
+ /// details no longer match; revising it is offered but is only the default when it matches.
+ ///
+ public DrawingLibraryRecord? Linked { get; }
+
+ /// Every drawing that may be revised: the matches, then the linked drawing if it is not one.
+ public IReadOnlyList Choices =>
+ Linked == null || Matches.Any(m => m.Id == Linked.Id) ? Matches : Matches.Append(Linked).ToList();
+
+ ///
+ /// The drawing to revise by default: the linked drawing when it matches, otherwise the
+ /// only match. Null means create a separate drawing when there is no match, or that the
+ /// operator must choose between several ().
+ ///
+ public DrawingLibraryRecord? Default { get; }
+
+ /// Several drawings match and none is linked: the operator has to pick one, or a separate drawing.
+ public bool RequiresChoice => Default == null && Matches.Count > 1;
+
+ /// True when these duplicates were found for exactly 's matching details.
+ public bool IsFor(DrawingLibraryRecord metadata) => SameDuplicateKey(Metadata, metadata);
+
+ internal static bool SameDuplicateKey(DrawingLibraryRecord a, DrawingLibraryRecord b) =>
+ SharedListNames.Key(a.Customer) == SharedListNames.Key(b.Customer)
+ && SharedListNames.Key(a.Name) == SharedListNames.Key(b.Name)
+ && SharedListNames.Key(a.MaterialName) == SharedListNames.Key(b.MaterialName)
+ && SharedListNames.Key(a.MaterialGrade) == SharedListNames.Key(b.MaterialGrade)
+ && DrawingLibraryRules.NormalizeThickness(a.Thickness) == DrawingLibraryRules.NormalizeThickness(b.Thickness)
+ && a.Units == b.Units
+ && a.CutType == b.CutType;
+}
+
+///
+/// Saves one nest drawing to the server drawing library, as a new library drawing or as
+/// the next revision of a chosen one (D4), and on success points the drawing's library
+/// link at the saved revision. The nest's copy is otherwise unchanged (D2).
+///
+/// A new drawing's id is allocated before the upload. When a save fails without a
+/// definite answer it stays pending: the next save first checks whether it was stored,
+/// adopts it if so, and otherwise retries a new drawing under the same id. A pending save
+/// is never written onto a different library drawing.
+///
+///
+public sealed class DrawingLibrarySaveSession
+{
+ /// Most library rows the duplicate check reads before giving up.
+ public const int MaxScannedDrawings = 10 * DrawingLibraryQuery.MaxLimit;
+
+ private readonly IDrawingLibraryStore _library;
+ private PendingSave? _pending;
+
+ public DrawingLibrarySaveSession(IDrawingLibraryStore library, string serverUrl, Nest nest, Drawing drawing)
+ {
+ _library = library ?? throw new ArgumentNullException(nameof(library));
+ Nest = nest ?? throw new ArgumentNullException(nameof(nest));
+ Drawing = drawing ?? throw new ArgumentNullException(nameof(drawing));
+ ServerKey = ServerUrl.Key(serverUrl);
+ if (ServerKey.Length == 0)
+ throw new ArgumentException("A library save needs the nest server URL.", nameof(serverUrl));
+ }
+
+ public Nest Nest { get; }
+
+ public Drawing Drawing { get; }
+
+ /// The normalized server URL saved links are bound to.
+ public string ServerKey { get; }
+
+ /// An earlier save failed without a definite answer; the next save checks it first.
+ public bool HasPendingSave => _pending != null;
+
+ ///
+ /// Library details to start from: the drawing's name, its customer (or the nest's), the
+ /// nest's material and grade (or the drawing's when the nest has none), the nest's
+ /// thickness and units, and the linked revision's cut type and notes when the drawing
+ /// came from this server's library, otherwise .
+ ///
+ public DrawingLibraryRecord Prefill(CutType lastCutType, string savedBy)
+ {
+ var link = Drawing.Library is { } l && l.ServerKey == ServerKey ? l : null;
+ var material = !string.IsNullOrWhiteSpace(Nest.Material?.Name) ? Nest.Material : Drawing.Material;
+ return new DrawingLibraryRecord
+ {
+ Name = Drawing.Name ?? "",
+ Customer = !string.IsNullOrWhiteSpace(Drawing.Customer) ? Drawing.Customer : Nest.Customer ?? "",
+ MaterialName = material?.Name ?? "",
+ MaterialGrade = material?.Grade ?? "",
+ Thickness = DrawingLibraryRules.NormalizeThickness(double.IsFinite(Nest.Thickness) && Nest.Thickness > 0 ? Nest.Thickness : 0),
+ Units = Nest.Units,
+ CutType = link?.CutType ?? (Enum.IsDefined(lastCutType) ? lastCutType : CutType.Laser),
+ Notes = link?.Notes ?? "",
+ SavedBy = savedBy ?? "",
+ };
+ }
+
+ ///
+ /// Finds the active library drawings duplicates, reading
+ /// every page the server filters down to, and the drawing's linked library drawing.
+ ///
+ public async Task FindDuplicatesAsync(DrawingLibraryRecord metadata,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(metadata);
+ var snapshot = Copy(metadata);
+ var matches = new List();
+ if (!string.IsNullOrWhiteSpace(snapshot.Name) && DrawingLibraryRules.GetMetadataError(snapshot) == null)
+ {
+ // The server matches customer, material and grade by the same normalized key. A
+ // blank one means "any" there, so every key is checked again here; the name search
+ // narrows by its longest word because LIKE does not collapse whitespace.
+ var offset = 0;
+ while (true)
+ {
+ var page = await _library.QueryAsync(new DrawingLibraryQuery
+ {
+ Search = LongestWord(snapshot.Name),
+ Customer = snapshot.Customer,
+ MaterialName = snapshot.MaterialName,
+ MaterialGrade = snapshot.MaterialGrade,
+ Thickness = snapshot.Thickness,
+ CutType = snapshot.CutType,
+ Sort = DrawingSortField.SavedAt,
+ Descending = true,
+ Offset = offset,
+ Limit = DrawingLibraryQuery.MaxLimit,
+ }, cancellationToken);
+ matches.AddRange(page.Items.Where(r => !r.Archived && DrawingLibraryDuplicates.SameDuplicateKey(r, snapshot)
+ && matches.All(m => m.Id != r.Id)));
+ offset += page.Items.Count;
+ if (page.Items.Count == 0 || offset >= page.Total)
+ break;
+ if (offset >= MaxScannedDrawings)
+ throw new IOException($"More than {MaxScannedDrawings} library drawings share this name's details. Narrow the name or customer before saving.");
+ }
+ }
+
+ DrawingLibraryRecord? linked = null;
+ if (Drawing.Library is { } link && link.ServerKey == ServerKey
+ && await _library.GetAsync(link.Id, cancellationToken) is { Archived: false } current && current.Id == link.Id)
+ linked = matches.FirstOrDefault(m => m.Id == current.Id) ?? current;
+
+ return new DrawingLibraryDuplicates(snapshot, matches, linked);
+ }
+
+ ///
+ /// Saves (the drawing packaged in the nest's units) with
+ /// : as the next revision of at the
+ /// revision it was listed with, or as a new library drawing when it is null. A stale
+ /// revision raises and changes nothing.
+ ///
+ public async Task SaveAsync(DrawingLibraryRecord metadata, byte[] package,
+ DrawingLibraryRecord? revise, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(metadata);
+ ArgumentNullException.ThrowIfNull(package);
+ var error = DrawingLibraryRules.GetMetadataError(metadata) ?? DrawingLibraryRules.GetPackageError(package);
+ if (error != null)
+ throw new ArgumentException(error, nameof(metadata));
+ if (metadata.Units != Nest.Units)
+ throw new ArgumentException("A library drawing is saved in its nest's units.", nameof(metadata));
+ if (revise != null && (revise.Id == Guid.Empty || revise.Revision < 1))
+ throw new ArgumentException("Choose a listed library drawing to revise.", nameof(revise));
+
+ var reuseId = Guid.Empty;
+ if (_pending != null)
+ {
+ var earlier = _pending;
+ var stored = await FindStoredAsync(earlier, cancellationToken);
+ if (stored != null)
+ {
+ _pending = null;
+ var sameRequest = earlier.Matches(metadata, package, revise);
+ return Finish(stored.Value.Record, stored.Value.Outcome, recoveredEarlierSave: !sameRequest);
+ }
+
+ // Not stored: a new drawing may be retried, but only ever under the id it was given.
+ reuseId = earlier.ReviseId == Guid.Empty ? earlier.Id : Guid.Empty;
+ _pending = null;
+ }
+
+ var request = Copy(metadata);
+ var pending = revise == null
+ ? new PendingSave(reuseId != Guid.Empty ? reuseId : Guid.NewGuid(), Guid.Empty, 0, request, package)
+ : new PendingSave(revise.Id, revise.Id, revise.Revision, request, package);
+ request.Id = pending.Id;
+ request.Revision = 0;
+
+ DrawingLibraryRecord saved;
+ _pending = pending;
+ try
+ {
+ saved = revise == null
+ ? await _library.UploadAsync(request, package, cancellationToken)
+ : await _library.UpdatePackageAsync(revise.Id, request, package, revise.Revision, cancellationToken);
+ }
+ catch (Exception ex) when (ex is DrawingLibraryConflictException or ServerTooOldException or ArgumentException)
+ {
+ // A definite refusal: nothing was stored. Any other failure may have been stored
+ // before its reply was lost, so it stays pending for the next save to check.
+ _pending = null;
+ throw;
+ }
+
+ if (saved.Id != pending.Id || saved.Revision < 1 || !pending.SameStored(saved))
+ throw new IOException("The nest server returned a different library drawing than was saved. Save again to check what was stored.");
+ _pending = null;
+ var outcome = revise == null ? DrawingLibrarySaveOutcome.Created
+ : saved.Revision == revise.Revision ? DrawingLibrarySaveOutcome.Unchanged
+ : DrawingLibrarySaveOutcome.Revised;
+ return Finish(saved, outcome, recoveredEarlierSave: false);
+ }
+
+ // The revision a pending save would have produced, when the server holds it with exactly
+ // the pending package and metadata; null when it was not stored.
+ private async Task<(DrawingLibraryRecord Record, DrawingLibrarySaveOutcome Outcome)?> FindStoredAsync(
+ PendingSave pending, CancellationToken cancellationToken)
+ {
+ var current = await _library.GetAsync(pending.Id, cancellationToken);
+ if (pending.ReviseId == Guid.Empty)
+ {
+ if (current == null)
+ return null;
+ var first = await RevisionAsync(current, 1, cancellationToken);
+ if (first == null || !pending.SameStored(first))
+ {
+ _pending = null;
+ throw new IOException("Another library drawing already uses the id of the save that failed. Close this dialog and save again.");
+ }
+ return (first, DrawingLibrarySaveOutcome.Created);
+ }
+
+ if (current == null)
+ {
+ _pending = null;
+ throw new IOException("The library drawing being revised no longer exists on the nest server.");
+ }
+
+ if (current.Revision == pending.ExpectedRevision)
+ return pending.SameStored(current) ? (current, DrawingLibrarySaveOutcome.Unchanged) : null;
+
+ var next = current.Revision > pending.ExpectedRevision
+ ? await RevisionAsync(current, pending.ExpectedRevision + 1, cancellationToken)
+ : null;
+ if (next != null && pending.SameStored(next))
+ return (next, DrawingLibrarySaveOutcome.Revised);
+
+ // Someone else saved the next revision; the pending save can never be stored now.
+ _pending = null;
+ throw new DrawingLibraryConflictException(current);
+ }
+
+ private async Task RevisionAsync(DrawingLibraryRecord current, int revision,
+ CancellationToken cancellationToken)
+ {
+ if (revision < 1 || revision > current.Revision)
+ return null;
+ // Revisions are listed newest first, so revision r of n sits at offset n - r.
+ var page = await _library.GetRevisionsAsync(current.Id, current.Revision - revision, 1, cancellationToken);
+ var record = page?.Items.FirstOrDefault();
+ return record != null && record.Id == current.Id && record.Revision == revision ? record : null;
+ }
+
+ private DrawingLibrarySaveResult Finish(DrawingLibraryRecord saved, DrawingLibrarySaveOutcome outcome,
+ bool recoveredEarlierSave)
+ {
+ Drawing.Library = new DrawingLibraryLink(ServerKey, saved.Id, saved.Revision, saved.CutType,
+ saved.Thickness, saved.MaterialName, saved.MaterialGrade, saved.Units, saved.Notes,
+ saved.PackageSha256.ToLowerInvariant());
+ return new DrawingLibrarySaveResult(saved, outcome, recoveredEarlierSave);
+ }
+
+ private static string LongestWord(string name) =>
+ name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
+ .OrderByDescending(word => word.Length)
+ .FirstOrDefault() ?? "";
+
+ 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,
+ Archived = record.Archived,
+ PackageSha256 = record.PackageSha256,
+ PackageSize = record.PackageSize,
+ SavedAt = record.SavedAt,
+ SavedBy = record.SavedBy,
+ };
+
+ private static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+
+ private sealed class PendingSave
+ {
+ public PendingSave(Guid id, Guid reviseId, int expectedRevision, DrawingLibraryRecord metadata, byte[] package)
+ {
+ Id = id;
+ ReviseId = reviseId;
+ ExpectedRevision = expectedRevision;
+ Metadata = metadata;
+ Package = package.ToArray();
+ PackageSha256 = Sha256(Package);
+ }
+
+ public Guid Id { get; }
+
+ /// The drawing being revised, or empty for a new drawing.
+ public Guid ReviseId { get; }
+
+ public int ExpectedRevision { get; }
+
+ public DrawingLibraryRecord Metadata { get; }
+
+ public byte[] Package { get; }
+
+ public string PackageSha256 { get; }
+
+ /// True when the server's holds exactly this package and these details.
+ public bool SameStored(DrawingLibraryRecord record) =>
+ string.Equals(record.PackageSha256, PackageSha256, StringComparison.OrdinalIgnoreCase)
+ && record.Name == Metadata.Name && record.Customer == Metadata.Customer
+ && record.MaterialName == Metadata.MaterialName && record.MaterialGrade == Metadata.MaterialGrade
+ && record.Thickness == DrawingLibraryRules.NormalizeThickness(Metadata.Thickness)
+ && record.Units == Metadata.Units && record.CutType == Metadata.CutType && record.Notes == Metadata.Notes;
+
+ /// True when a new request asks for exactly this save again.
+ public bool Matches(DrawingLibraryRecord metadata, byte[] package, DrawingLibraryRecord? revise) =>
+ (revise == null ? ReviseId == Guid.Empty : ReviseId == revise.Id && ExpectedRevision == revise.Revision)
+ && Package.AsSpan().SequenceEqual(package)
+ && metadata.Name == Metadata.Name && metadata.Customer == Metadata.Customer
+ && metadata.MaterialName == Metadata.MaterialName && metadata.MaterialGrade == Metadata.MaterialGrade
+ && DrawingLibraryRules.NormalizeThickness(metadata.Thickness) == DrawingLibraryRules.NormalizeThickness(Metadata.Thickness)
+ && metadata.Units == Metadata.Units && metadata.CutType == Metadata.CutType && metadata.Notes == Metadata.Notes;
+ }
+}
diff --git a/OpenNest.Data/IDrawingLibraryStore.cs b/OpenNest.Data/IDrawingLibraryStore.cs
new file mode 100644
index 00000000..0814fac0
--- /dev/null
+++ b/OpenNest.Data/IDrawingLibraryStore.cs
@@ -0,0 +1,23 @@
+namespace OpenNest.Data;
+
+///
+/// The drawing library reads and writes that saving a nest drawing to the library uses.
+/// is the only production implementation.
+///
+public interface IDrawingLibraryStore : IDrawingLibrary
+{
+ /// The current record, including an archived one, or null for an unknown id.
+ Task GetAsync(Guid id, CancellationToken cancellationToken = default);
+
+ /// One page of immutable revisions, newest first, or null for an unknown id.
+ Task GetRevisionsAsync(Guid id, int offset = 0,
+ int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default);
+
+ /// Stores revision 1 under the caller-allocated record.Id.
+ Task UploadAsync(DrawingLibraryRecord record, byte[] package,
+ CancellationToken cancellationToken = default);
+
+ /// Saves the next revision when is current.
+ Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package,
+ int expectedRevision, CancellationToken cancellationToken = default);
+}
diff --git a/OpenNest.Data/RemoteDrawingLibrary.cs b/OpenNest.Data/RemoteDrawingLibrary.cs
index 8b1571e5..7e764de8 100644
--- a/OpenNest.Data/RemoteDrawingLibrary.cs
+++ b/OpenNest.Data/RemoteDrawingLibrary.cs
@@ -16,7 +16,7 @@ namespace OpenNest.Data;
/// the library raises ; this client never falls back
/// to another store. Other failures surface as .
///
-public sealed class RemoteDrawingLibrary : IDrawingLibrary, IDisposable
+public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
diff --git a/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs b/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs
index 3e60bd34..21c4ff2d 100644
--- a/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs
+++ b/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs
@@ -110,6 +110,74 @@ public sealed class DrawingLibraryEndToEndTests
Assert.Equal(new[] { 2, 1 }, history!.Items.Select(r => r.Revision));
}
+ private static Nest SyntheticNest() => new("Synthetic job")
+ {
+ Units = Units.Inches,
+ Thickness = 0.25,
+ Material = new Material("Synthetic Steel", "Grade A"),
+ };
+
+ [Fact]
+ public async Task SaveSession_CreatesThenDefaultsToTheNextRevisionOfTheDuplicate()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ using var library = new RemoteDrawingLibrary(client, client.BaseAddress!.ToString());
+ var nest = SyntheticNest();
+ var drawing = SyntheticDrawing("Synthetic bracket", 2);
+ nest.Drawings.Add(drawing);
+ var first = new DrawingLibrarySaveSession(library, client.BaseAddress!.ToString(), nest, drawing);
+ var metadata = first.Prefill(CutType.Laser, "Synthetic operator");
+
+ var none = await first.FindDuplicatesAsync(metadata);
+ var created = await first.SaveAsync(metadata, DrawingPackage.Write(drawing, nest.Units), none.Default);
+
+ // Another nest's copy, moved hole, same details: D4 offers revision 2 by default.
+ var otherNest = SyntheticNest();
+ var copy = SyntheticDrawing("Synthetic bracket", 5);
+ otherNest.Drawings.Add(copy);
+ var second = new DrawingLibrarySaveSession(library, client.BaseAddress!.ToString(), otherNest, copy);
+ var again = second.Prefill(CutType.Laser, "Synthetic operator");
+ var duplicates = await second.FindDuplicatesAsync(again);
+ var package = DrawingPackage.Write(copy, otherNest.Units);
+ var revised = await second.SaveAsync(again, package, duplicates.Default);
+
+ Assert.Empty(none.Matches);
+ Assert.Equal((DrawingLibrarySaveOutcome.Created, 1), (created.Outcome, created.Saved.Revision));
+ Assert.Equal(created.Saved.Id, duplicates.Default!.Id);
+ Assert.Equal((DrawingLibrarySaveOutcome.Revised, 2), (revised.Outcome, revised.Saved.Revision));
+ Assert.Equal((created.Saved.Id, 2), (copy.Library!.Id, copy.Library.Revision));
+ Assert.Equal((created.Saved.Id, 1), (drawing.Library!.Id, drawing.Library.Revision));
+ Assert.Equal(package, await library.GetPackageAsync(created.Saved.Id));
+ Assert.Equal(revised.Saved.PackageSha256, copy.Library.PackageSha256);
+ }
+
+ [Fact]
+ public async Task SaveSession_StaleChoiceConflictsAndChangesNothing()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ using var library = new RemoteDrawingLibrary(client, client.BaseAddress!.ToString());
+ var nest = SyntheticNest();
+ var drawing = SyntheticDrawing("Synthetic bracket", 2);
+ nest.Drawings.Add(drawing);
+ var session = new DrawingLibrarySaveSession(library, client.BaseAddress!.ToString(), nest, drawing);
+ var metadata = session.Prefill(CutType.Laser, "Synthetic operator");
+ await session.SaveAsync(metadata, DrawingPackage.Write(drawing, nest.Units), null);
+ var duplicates = await session.FindDuplicatesAsync(metadata);
+ var moved = DrawingPackage.Write(SyntheticDrawing("Synthetic bracket", 5), Units.Inches);
+ var current = await library.UpdatePackageAsync(duplicates.Default!.Id, metadata, moved, 1);
+
+ var conflict = await Assert.ThrowsAsync(() =>
+ session.SaveAsync(metadata, DrawingPackage.Write(SyntheticDrawing("Synthetic bracket", 6), Units.Inches), duplicates.Default));
+
+ Assert.Equal(2, conflict.Current.Revision);
+ Assert.False(session.HasPendingSave);
+ Assert.Equal(1, drawing.Library!.Revision);
+ Assert.Equal(current.PackageSha256, (await library.GetAsync(current.Id))!.PackageSha256);
+ Assert.Equal(moved, await library.GetPackageAsync(current.Id));
+ }
+
[Fact]
public async Task ArchiveRestoreAndMetadata_ThroughTheClient()
{
diff --git a/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs b/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs
new file mode 100644
index 00000000..974dc48a
--- /dev/null
+++ b/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs
@@ -0,0 +1,527 @@
+using System.Security.Cryptography;
+using OpenNest.Data;
+using OpenNest.Shapes;
+
+namespace OpenNest.Tests.Data;
+
+public class DrawingLibrarySaveSessionTests
+{
+ private const string Server = "http://library.test:8090";
+ private static readonly byte[] PackageA = { (byte)'P', (byte)'K', 3, 4, 1 };
+ private static readonly byte[] PackageB = { (byte)'P', (byte)'K', 3, 4, 2 };
+
+ private static Nest MatchingNest() => new("Synthetic job")
+ {
+ Units = Units.Inches,
+ Thickness = 0.25,
+ Customer = "Nest customer",
+ Material = new Material("Synthetic Steel", "Grade A"),
+ };
+
+ private static Drawing SyntheticDrawing(string name = "SYN-101")
+ {
+ var drawing = new RectangleShape { Name = name, Length = 4, Width = 2 }.GetDrawing();
+ drawing.Customer = "Synthetic customer";
+ drawing.Material = new Material("Drawing material", "Drawing grade");
+ return drawing;
+ }
+
+ private static DrawingLibraryRecord Metadata(string name = "SYN-101") => new()
+ {
+ Name = name,
+ Customer = "Synthetic customer",
+ MaterialName = "Synthetic Steel",
+ MaterialGrade = "Grade A",
+ Thickness = 0.25,
+ Units = Units.Inches,
+ CutType = CutType.Laser,
+ Notes = "Deburr",
+ SavedBy = "Synthetic operator",
+ };
+
+ private static DrawingLibrarySaveSession Session(FakeStore store, Drawing? drawing = null, Nest? nest = null) =>
+ new(store, Server + "/", nest ?? MatchingNest(), drawing ?? SyntheticDrawing());
+
+ [Fact]
+ public void Prefill_TakesNameAndCustomerFromTheDrawing_AndMaterialThicknessUnitsFromTheNest()
+ {
+ var nest = MatchingNest();
+ nest.Thickness = 0.250004;
+ var session = Session(new FakeStore(), nest: nest);
+
+ var metadata = session.Prefill(CutType.Waterjet, "Synthetic operator");
+
+ Assert.Equal(("SYN-101", "Synthetic customer"), (metadata.Name, metadata.Customer));
+ Assert.Equal(("Synthetic Steel", "Grade A"), (metadata.MaterialName, metadata.MaterialGrade));
+ Assert.Equal((0.25, Units.Inches), (metadata.Thickness, metadata.Units));
+ Assert.Equal((CutType.Waterjet, "", "Synthetic operator"), (metadata.CutType, metadata.Notes, metadata.SavedBy));
+ Assert.Null(DrawingLibraryRules.GetMetadataError(metadata));
+ }
+
+ [Fact]
+ public void Prefill_FallsBackToTheNestCustomerAndDrawingMaterial_AndUsesALinkOnThisServer()
+ {
+ var nest = new Nest("Synthetic job") { Units = Units.Millimeters, Customer = "Nest customer", Material = new Material() };
+ var drawing = SyntheticDrawing();
+ drawing.Customer = " ";
+ drawing.Library = Link(Server, Guid.NewGuid(), 2, CutType.Plasma, "Linked notes");
+ var other = SyntheticDrawing();
+ other.Library = Link("http://other.test:8090", Guid.NewGuid(), 2, CutType.Plasma, "Other notes");
+
+ var linked = Session(new FakeStore(), drawing, nest).Prefill(CutType.Laser, "");
+ var foreign = Session(new FakeStore(), other, nest).Prefill(CutType.Waterjet, "");
+
+ Assert.Equal("Nest customer", linked.Customer);
+ Assert.Equal(("Drawing material", "Drawing grade"), (linked.MaterialName, linked.MaterialGrade));
+ Assert.Equal((0, Units.Millimeters), (linked.Thickness, linked.Units));
+ Assert.Equal((CutType.Plasma, "Linked notes"), (linked.CutType, linked.Notes));
+ Assert.Equal((CutType.Waterjet, ""), (foreign.CutType, foreign.Notes));
+ }
+
+ [Fact]
+ public async Task NoMatch_CreatesANewDrawingUnderAClientIdAndLinksIt()
+ {
+ var store = new FakeStore();
+ store.Seed(Edit(Metadata("SYN-101"), r => { r.CutType = CutType.Plasma; }), PackageA);
+ store.Seed(Edit(Metadata("SYN-101"), r => { r.Thickness = 0.1875; }), PackageA);
+ store.Seed(Metadata("SYN-1010"), PackageA);
+ var drawing = SyntheticDrawing();
+ var session = Session(store, drawing);
+ var metadata = Metadata();
+
+ var duplicates = await session.FindDuplicatesAsync(metadata);
+ var result = await session.SaveAsync(metadata, PackageA, duplicates.Default);
+
+ Assert.Empty(duplicates.Matches);
+ Assert.Null(duplicates.Default);
+ Assert.False(duplicates.RequiresChoice);
+ Assert.Equal(DrawingLibrarySaveOutcome.Created, result.Outcome);
+ Assert.False(result.RecoveredEarlierSave);
+ var upload = Assert.Single(store.Uploads);
+ Assert.NotEqual(Guid.Empty, upload);
+ Assert.Equal((upload, 1), (result.Saved.Id, result.Saved.Revision));
+ Assert.Equal((Server, upload, 1, Sha(PackageA)), (drawing.Library!.ServerKey, drawing.Library.Id, drawing.Library.Revision, drawing.Library.PackageSha256));
+ Assert.Equal(("Synthetic Steel", "Grade A", 0.25, "Deburr"), (drawing.Library.MaterialName, drawing.Library.MaterialGrade, drawing.Library.Thickness, drawing.Library.Notes));
+ Assert.False(session.HasPendingSave);
+ }
+
+ [Fact]
+ public async Task OneMatch_IsTheDefault_AndSavesTheNextRevisionGuardedByItsRevision()
+ {
+ var store = new FakeStore();
+ var match = store.Seed(Edit(Metadata(" syn-101 "), r => { r.Customer = "SYNTHETIC customer"; r.MaterialGrade = "grade a"; }), PackageA);
+ store.Revise(match.Id, PackageA);
+ var drawing = SyntheticDrawing();
+ var session = Session(store, drawing);
+
+ var duplicates = await session.FindDuplicatesAsync(Metadata());
+ var result = await session.SaveAsync(Metadata(), PackageB, duplicates.Default);
+
+ Assert.Equal(match.Id, Assert.Single(duplicates.Matches).Id);
+ Assert.Equal((match.Id, 2), (duplicates.Default!.Id, duplicates.Default.Revision));
+ Assert.Equal(new[] { (match.Id, 2) }, store.Updates);
+ Assert.Empty(store.Uploads);
+ Assert.Equal((DrawingLibrarySaveOutcome.Revised, 3), (result.Outcome, result.Saved.Revision));
+ Assert.Equal((match.Id, 3), (drawing.Library!.Id, drawing.Library.Revision));
+ }
+
+ [Fact]
+ public async Task SeveralMatches_RequireAChoice_AndArchivedOnesAreNotOffered()
+ {
+ var store = new FakeStore();
+ var older = store.Seed(Metadata(), PackageA);
+ var newer = store.Seed(Metadata(), PackageB);
+ var archived = store.Seed(Metadata(), PackageA);
+ store.Archive(archived.Id);
+ var session = Session(store);
+
+ var duplicates = await session.FindDuplicatesAsync(Metadata());
+ var result = await session.SaveAsync(Metadata(), PackageB, duplicates.Matches.Single(m => m.Id == older.Id));
+
+ Assert.Equal(new[] { newer.Id, older.Id }, duplicates.Matches.Select(m => m.Id));
+ Assert.Null(duplicates.Default);
+ Assert.True(duplicates.RequiresChoice);
+ Assert.Equal((older.Id, 2), (result.Saved.Id, result.Saved.Revision));
+ }
+
+ [Fact]
+ public async Task LinkedDrawing_IsTheDefaultWhenItMatches_AndOnlyOfferedWhenItDoesNot()
+ {
+ var store = new FakeStore();
+ var first = store.Seed(Metadata(), PackageA);
+ var linkedMatch = store.Seed(Metadata(), PackageA);
+ var renamed = store.Seed(Metadata("SYN-OLD"), PackageA);
+ var matching = SyntheticDrawing();
+ matching.Library = Link(Server, linkedMatch.Id, 1, CutType.Laser, "");
+ var drifted = SyntheticDrawing();
+ drifted.Library = Link(Server, renamed.Id, 1, CutType.Laser, "");
+
+ var linked = await Session(store, matching).FindDuplicatesAsync(Metadata());
+ var offered = await Session(store, drifted).FindDuplicatesAsync(Metadata());
+
+ Assert.Equal(linkedMatch.Id, linked.Default!.Id);
+ Assert.False(linked.RequiresChoice);
+ Assert.Equal(2, linked.Choices.Count);
+ Assert.Equal(renamed.Id, offered.Linked!.Id);
+ Assert.Null(offered.Default);
+ Assert.True(offered.RequiresChoice);
+ Assert.Equal(new[] { linkedMatch.Id, first.Id, renamed.Id }, offered.Choices.Select(c => c.Id));
+ }
+
+ [Fact]
+ public async Task DuplicatesKnowWhichDetailsTheyWereFoundFor()
+ {
+ var session = Session(new FakeStore());
+ var metadata = Metadata();
+
+ var duplicates = await session.FindDuplicatesAsync(metadata);
+ metadata.Notes = "Notes are not part of the match";
+
+ Assert.True(duplicates.IsFor(metadata));
+ Assert.True(duplicates.IsFor(Edit(metadata, r => { r.Name = " syn-101"; })));
+ Assert.False(duplicates.IsFor(Edit(metadata, r => { r.Thickness = 0.1875; })));
+ Assert.False(duplicates.IsFor(Edit(metadata, r => { r.CutType = CutType.Plasma; })));
+ }
+
+ [Fact]
+ public async Task StaleRevision_Conflicts_ChangesNothing_AndLeavesNothingPending()
+ {
+ var store = new FakeStore();
+ var match = store.Seed(Metadata(), PackageA);
+ var drawing = SyntheticDrawing();
+ var session = Session(store, drawing);
+ var duplicates = await session.FindDuplicatesAsync(Metadata());
+ store.Revise(match.Id, PackageB);
+
+ var conflict = await Assert.ThrowsAsync(() =>
+ session.SaveAsync(Metadata(), PackageA, duplicates.Default));
+
+ Assert.Equal(2, conflict.Current.Revision);
+ Assert.Equal(2, store.Current(match.Id).Revision);
+ Assert.Null(drawing.Library);
+ Assert.False(session.HasPendingSave);
+ }
+
+ [Fact]
+ public async Task IdenticalRevision_IsUnchanged_AndStillLinks()
+ {
+ var store = new FakeStore();
+ var match = store.Seed(Metadata(), PackageA);
+ var drawing = SyntheticDrawing();
+ var session = Session(store, drawing);
+
+ var result = await session.SaveAsync(Metadata(), PackageA, (await session.FindDuplicatesAsync(Metadata())).Default);
+
+ Assert.Equal((DrawingLibrarySaveOutcome.Unchanged, 1), (result.Outcome, result.Saved.Revision));
+ Assert.Equal((match.Id, 1), (drawing.Library!.Id, drawing.Library.Revision));
+ }
+
+ [Fact]
+ public async Task LostCreateReply_IsReconciledOnRetry_WithoutASecondUpload()
+ {
+ var store = new FakeStore { LoseNextReply = true };
+ var drawing = SyntheticDrawing();
+ var session = Session(store, drawing);
+
+ await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageA, null));
+ Assert.True(session.HasPendingSave);
+ Assert.Null(drawing.Library);
+
+ var result = await session.SaveAsync(Metadata(), PackageA, null);
+
+ var upload = Assert.Single(store.Uploads);
+ Assert.Equal((upload, 1, DrawingLibrarySaveOutcome.Created), (result.Saved.Id, result.Saved.Revision, result.Outcome));
+ Assert.False(result.RecoveredEarlierSave);
+ Assert.Equal((upload, 1), (drawing.Library!.Id, drawing.Library.Revision));
+ Assert.False(session.HasPendingSave);
+ }
+
+ [Fact]
+ public async Task LostCreateReply_WithAChangedRetry_ReportsTheEarlierSaveInsteadOfWritingAgain()
+ {
+ var store = new FakeStore { LoseNextReply = true };
+ var session = Session(store);
+ await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageA, null));
+
+ var result = await session.SaveAsync(Edit(Metadata(), r => { r.Notes = "Changed since"; }), PackageA, null);
+
+ Assert.True(result.RecoveredEarlierSave);
+ Assert.Equal("Deburr", result.Saved.Notes);
+ Assert.Single(store.Uploads);
+ }
+
+ [Fact]
+ public async Task CreateThatFailedBeforeStoring_RetriesUnderTheSameId()
+ {
+ var store = new FakeStore { FailNextRequest = true };
+ var session = Session(store);
+
+ await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageA, null));
+ var result = await session.SaveAsync(Metadata(), PackageA, null);
+
+ Assert.Equal(2, store.Uploads.Count);
+ Assert.Equal(store.Uploads[0], store.Uploads[1]);
+ Assert.Equal(store.Uploads[0], result.Saved.Id);
+ Assert.Equal(DrawingLibrarySaveOutcome.Created, result.Outcome);
+ }
+
+ [Fact]
+ public async Task FailedCreateThenRevise_DoesNotCarryTheNewIdOntoAnotherDrawing()
+ {
+ var store = new FakeStore();
+ var match = store.Seed(Metadata(), PackageA);
+ var session = Session(store);
+ store.FailNextRequest = true;
+ await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageB, null));
+
+ var result = await session.SaveAsync(Metadata(), PackageB, store.Current(match.Id));
+
+ Assert.Equal((match.Id, 2), (result.Saved.Id, result.Saved.Revision));
+ Assert.Equal(1, store.Count);
+ }
+
+ [Fact]
+ public async Task LostRevisionReply_IsReconciledOnRetry_WithoutASecondRevision()
+ {
+ var store = new FakeStore();
+ var match = store.Seed(Metadata(), PackageA);
+ var drawing = SyntheticDrawing();
+ var session = Session(store, drawing);
+ store.LoseNextReply = true;
+
+ await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageB, match));
+ store.Revise(match.Id, PackageA);
+ var result = await session.SaveAsync(Metadata(), PackageB, match);
+
+ Assert.Single(store.Updates);
+ Assert.Equal((2, DrawingLibrarySaveOutcome.Revised), (result.Saved.Revision, result.Outcome));
+ Assert.Equal(Sha(PackageB), result.Saved.PackageSha256);
+ Assert.Equal(2, drawing.Library!.Revision);
+ }
+
+ [Fact]
+ public async Task RevisionThatFailedBeforeStoring_RetriesTheSameGuard()
+ {
+ var store = new FakeStore();
+ var match = store.Seed(Metadata(), PackageA);
+ var session = Session(store);
+ store.FailNextRequest = true;
+
+ await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageB, match));
+ var result = await session.SaveAsync(Metadata(), PackageB, match);
+
+ Assert.Equal(new[] { (match.Id, 1), (match.Id, 1) }, store.Updates);
+ Assert.Equal(2, result.Saved.Revision);
+ }
+
+ [Fact]
+ public async Task RevisionThatFailedWhileSomeoneElseSaved_ConflictsAndClearsThePendingSave()
+ {
+ var store = new FakeStore();
+ var match = store.Seed(Metadata(), PackageA);
+ var drawing = SyntheticDrawing();
+ var session = Session(store, drawing);
+ store.FailNextRequest = true;
+ await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageB, match));
+ store.Revise(match.Id, PackageA, notes: "Someone else");
+
+ await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), PackageB, match));
+
+ Assert.False(session.HasPendingSave);
+ Assert.Null(drawing.Library);
+ Assert.Single(store.Updates);
+ }
+
+ [Fact]
+ public async Task InvalidRequests_AreRefusedBeforeSending()
+ {
+ var store = new FakeStore();
+ var session = Session(store);
+
+ await Assert.ThrowsAsync(() => session.SaveAsync(Edit(Metadata(), r => { r.Name = " "; }), PackageA, null));
+ await Assert.ThrowsAsync(() => session.SaveAsync(Edit(Metadata(), r => { r.Units = Units.Millimeters; }), PackageA, null));
+ await Assert.ThrowsAsync(() => session.SaveAsync(Metadata(), new byte[] { 1, 2, 3, 4 }, null));
+
+ Assert.Empty(store.Uploads);
+ Assert.False(session.HasPendingSave);
+ }
+
+ private static DrawingLibraryLink Link(string server, Guid id, int revision, CutType cutType, string notes) =>
+ new(server, id, revision, cutType, 0.25, "Synthetic Steel", "Grade A", Units.Inches, notes, "");
+
+ private static DrawingLibraryRecord Edit(DrawingLibraryRecord source, Action change)
+ {
+ var copy = new DrawingLibraryRecord
+ {
+ Id = source.Id,
+ Revision = source.Revision,
+ Name = source.Name,
+ Customer = source.Customer,
+ MaterialName = source.MaterialName,
+ MaterialGrade = source.MaterialGrade,
+ Thickness = source.Thickness,
+ Units = source.Units,
+ CutType = source.CutType,
+ Notes = source.Notes,
+ Archived = source.Archived,
+ PackageSha256 = source.PackageSha256,
+ PackageSize = source.PackageSize,
+ SavedAt = source.SavedAt,
+ SavedBy = source.SavedBy,
+ };
+ change(copy);
+ return copy;
+ }
+
+ private static string Sha(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+
+ /// An in-memory library that filters like the server and can fail one request either side of storing.
+ private sealed class FakeStore : IDrawingLibraryStore
+ {
+ private readonly Dictionary> _revisions = new();
+ private DateTime _clock = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ /// Throw on the next write without storing anything.
+ public bool FailNextRequest { get; set; }
+
+ /// Store the next write, then throw as though its reply was lost.
+ public bool LoseNextReply { get; set; }
+
+ public List Uploads { get; } = new();
+
+ public List<(Guid Id, int ExpectedRevision)> Updates { get; } = new();
+
+ public int Count => _revisions.Count;
+
+ public DrawingLibraryRecord Current(Guid id) => _revisions[id][^1];
+
+ public DrawingLibraryRecord Seed(DrawingLibraryRecord metadata, byte[] package)
+ {
+ var id = Guid.NewGuid();
+ _revisions[id] = new List { Stored(metadata, id, 1, package, archived: false) };
+ return Current(id);
+ }
+
+ public void Revise(Guid id, byte[] package, string? notes = null)
+ {
+ var current = Current(id);
+ _revisions[id].Add(Stored(Edit(current, r => { r.Notes = notes ?? current.Notes; }), id, current.Revision + 1, package, archived: false));
+ }
+
+ public void Archive(Guid id)
+ {
+ var current = Current(id);
+ _revisions[id].Add(Stored(current, id, current.Revision + 1, null, archived: true, current.PackageSha256));
+ }
+
+ public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default)
+ {
+ Assert.Null(query.GetValidationError());
+ var search = query.NormalizedSearch;
+ var matches = _revisions.Values.Select(r => r[^1])
+ .Where(r => query.IncludeArchived || !r.Archived)
+ .Where(r => search.Length == 0 || new[] { r.Name, r.Customer, r.Notes }
+ .Any(text => text.Contains(search, StringComparison.OrdinalIgnoreCase)))
+ .Where(r => Filter(query.Customer, r.Customer) && Filter(query.MaterialName, r.MaterialName)
+ && Filter(query.MaterialGrade, r.MaterialGrade))
+ .Where(r => query.Thickness is not { } t || r.Thickness == DrawingLibraryRules.NormalizeThickness(t))
+ .Where(r => query.CutType is not { } c || r.CutType == c)
+ .OrderByDescending(r => r.SavedAt)
+ .ToList();
+ return Task.FromResult(new DrawingLibraryPage
+ {
+ Items = matches.Skip(query.Offset).Take(query.Limit).ToList(),
+ Total = matches.Count,
+ Offset = query.Offset,
+ Limit = query.Limit,
+ });
+ }
+
+ public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task GetAsync(Guid id, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_revisions.TryGetValue(id, out var list) ? list[^1] : null);
+
+ public Task GetRevisionsAsync(Guid id, int offset = 0,
+ int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default)
+ {
+ if (!_revisions.TryGetValue(id, out var list))
+ return Task.FromResult(null);
+ var newestFirst = Enumerable.Reverse(list).ToList();
+ return Task.FromResult(new DrawingLibraryPage
+ {
+ Items = newestFirst.Skip(offset).Take(limit).ToList(),
+ Total = list.Count,
+ Offset = offset,
+ Limit = limit,
+ });
+ }
+
+ public Task UploadAsync(DrawingLibraryRecord record, byte[] package,
+ CancellationToken cancellationToken = default)
+ {
+ Uploads.Add(record.Id);
+ FailBeforeStoring();
+ if (_revisions.TryGetValue(record.Id, out var existing))
+ throw new DrawingLibraryConflictException(existing[^1]);
+ _revisions[record.Id] = new List { Stored(record, record.Id, 1, package, archived: false) };
+ return Reply(Current(record.Id));
+ }
+
+ public Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package,
+ int expectedRevision, CancellationToken cancellationToken = default)
+ {
+ Updates.Add((id, expectedRevision));
+ FailBeforeStoring();
+ var current = Current(id);
+ if (current.Revision != expectedRevision || current.Archived)
+ throw new DrawingLibraryConflictException(current);
+ var next = Stored(record, id, current.Revision + 1, package, archived: false);
+ if (SameDetails(current, next) && current.PackageSha256 == next.PackageSha256)
+ return Reply(current);
+ _revisions[id].Add(next);
+ return Reply(next);
+ }
+
+ private void FailBeforeStoring()
+ {
+ if (!FailNextRequest)
+ return;
+ FailNextRequest = false;
+ throw new HttpRequestException("Synthetic connection failure.");
+ }
+
+ private Task Reply(DrawingLibraryRecord record)
+ {
+ if (!LoseNextReply)
+ return Task.FromResult(record);
+ LoseNextReply = false;
+ throw new IOException("Synthetic lost reply.");
+ }
+
+ private DrawingLibraryRecord Stored(DrawingLibraryRecord metadata, Guid id, int revision, byte[]? package,
+ bool archived, string? sha = null)
+ {
+ _clock = _clock.AddMinutes(1);
+ var savedAt = _clock;
+ return Edit(metadata, r =>
+ {
+ r.Id = id;
+ r.Revision = revision;
+ r.Thickness = DrawingLibraryRules.NormalizeThickness(metadata.Thickness);
+ r.Archived = archived;
+ r.PackageSha256 = sha ?? Sha(package!);
+ r.SavedAt = savedAt;
+ });
+ }
+
+ private static bool SameDetails(DrawingLibraryRecord a, DrawingLibraryRecord b) =>
+ a.Name == b.Name && a.Customer == b.Customer && a.MaterialName == b.MaterialName
+ && a.MaterialGrade == b.MaterialGrade && a.Thickness == b.Thickness && a.Units == b.Units
+ && a.CutType == b.CutType && a.Notes == b.Notes;
+
+ private static bool Filter(string filter, string value) =>
+ string.IsNullOrWhiteSpace(filter) || SharedListNames.Key(filter) == SharedListNames.Key(value);
+ }
+}