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, the linked revision's cut type and notes when the drawing /// came from this server's library, otherwise , and the /// drawing's own nesting settings. /// 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 ?? "", Settings = DrawingNestingSettings.FromDrawing(Drawing), 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, Settings = record.Settings, 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 && Equals(record.Settings, Metadata.Settings); /// 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 && Equals(metadata.Settings, Metadata.Settings); } }