From 7bf753ce17a1d9e73ac09f2d39623a98a6fca608 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 20 Oct 2026 22:13:46 -0400 Subject: [PATCH] feat(storage): record material grade on server nests and pick it in Nest Info Nest records gain a materialGrade field (server schema v8, blank for existing rows) that is searched, sorted and versioned like the other metadata. The Nest Info dialog gets a grade picker listing the machine configs' grades for the chosen material; density survives only an unchanged name and grade. A blank recorded grade keeps the archive's grade while the name matches, so records saved before v8 do not lose it on open. Saved Nests shows a Grade column. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/NestMaterialSelection.cs | 45 ++++++-- OpenNest.Data/NestQuery.cs | 2 +- OpenNest.Data/NestRecord.cs | 3 + OpenNest.Data/NestRecordFactory.cs | 3 +- OpenNest.Data/NestSaveSession.cs | 4 +- OpenNest.Data/NestSortField.cs | 1 + .../DrawingLibraryDatabaseTests.cs | 6 +- .../DrawingSetDatabaseTests.cs | 4 +- .../NestMaterialGradeTests.cs | 106 ++++++++++++++++++ OpenNest.Server.Tests/NestQueryTests.cs | 2 + OpenNest.Server/NestDatabase.cs | 38 +++++-- OpenNest.Server/NestMetadata.cs | 1 + .../Data/NestMaterialSelectionTests.cs | 75 +++++++++++-- OpenNest.Tests/Data/NestRecordFactoryTests.cs | 5 +- .../Forms/EditNestInfoFormTests.cs | 50 +++++++++ OpenNest/Forms/EditNestForm.cs | 1 + OpenNest/Forms/EditNestInfoForm.Designer.cs | 61 ++++++---- OpenNest/Forms/EditNestInfoForm.cs | 59 +++++++++- OpenNest/Forms/MachineSettings.cs | 5 + OpenNest/Forms/SavedNestsForm.cs | 3 + docs/nest-storage.md | 14 ++- 21 files changed, 427 insertions(+), 61 deletions(-) create mode 100644 OpenNest.Server.Tests/NestMaterialGradeTests.cs diff --git a/OpenNest.Data/NestMaterialSelection.cs b/OpenNest.Data/NestMaterialSelection.cs index c9aefef8..026dc9f2 100644 --- a/OpenNest.Data/NestMaterialSelection.cs +++ b/OpenNest.Data/NestMaterialSelection.cs @@ -1,25 +1,48 @@ namespace OpenNest.Data; /// -/// Chooses the nest material when the Nest Info dialog is accepted. The dialog -/// edits only the material name, so grade and density must come from the -/// material the nest already has. +/// Chooses the nest material from a material name and grade, as edited in the +/// Nest Info dialog or recorded on the nest server. Density is not edited, so it +/// must come from the material the nest already has. /// public static class NestMaterialSelection { /// - /// Returns a new material named . When that - /// name matches the current material's name by , - /// the current grade and density are kept; any other name gets a name-only - /// material. The current material is never modified or returned. + /// Returns a new material named with grade + /// . When both match the current material by + /// , the current density is kept; any other + /// name or grade gets no density. The current material is never modified or returned. /// - public static Material Apply(Material? current, string? selectedName) + public static Material Apply(Material? current, string? selectedName, string? selectedGrade) { var name = selectedName ?? ""; + var grade = selectedGrade ?? ""; - if (current != null && SharedListNames.Key(current.Name) == SharedListNames.Key(name)) - return new Material(name, current.Grade, current.Density); + if ( + current != null + && SharedListNames.Key(current.Name) == SharedListNames.Key(name) + && SharedListNames.Key(current.Grade) == SharedListNames.Key(grade) + ) + return new Material(name, grade, current.Density); - return new Material(name); + return new Material(name, grade); + } + + /// + /// Applies server metadata to an archived material. A blank recorded grade means + /// the record predates grades, so the archive's grade stands while the name is + /// unchanged; every edit rewrites the archive too, so a cleared grade is blank there as well. + /// + public static Material ApplyRecorded(Material? current, string? recordedName, string? recordedGrade) + { + var grade = recordedGrade ?? ""; + if ( + grade.Length == 0 + && current != null + && SharedListNames.Key(current.Name) == SharedListNames.Key(recordedName) + ) + grade = current.Grade ?? ""; + + return Apply(current, recordedName, grade); } } diff --git a/OpenNest.Data/NestQuery.cs b/OpenNest.Data/NestQuery.cs index 8916643c..2d59c6f8 100644 --- a/OpenNest.Data/NestQuery.cs +++ b/OpenNest.Data/NestQuery.cs @@ -11,7 +11,7 @@ public sealed class NestQuery public const int MaxSearchLength = 200; /// - /// Case-insensitive substring matched against name, customer, material, made by, + /// Case-insensitive substring matched against name, customer, material, grade, made by, /// comments and status (stored and display names). Blank means no filter. /// public string Search { get; init; } = ""; diff --git a/OpenNest.Data/NestRecord.cs b/OpenNest.Data/NestRecord.cs index dbb3477a..e77d592f 100644 --- a/OpenNest.Data/NestRecord.cs +++ b/OpenNest.Data/NestRecord.cs @@ -29,6 +29,9 @@ public sealed class NestRecord public string Material { get; set; } = ""; + /// Material grade; blank when none was chosen or the server predates grades. + public string MaterialGrade { get; set; } = ""; + public double Thickness { get; set; } /// quote | toBeCut | hasBeenCut (camelCase on the wire, case-insensitive readers). diff --git a/OpenNest.Data/NestRecordFactory.cs b/OpenNest.Data/NestRecordFactory.cs index edb9c591..fc13cb60 100644 --- a/OpenNest.Data/NestRecordFactory.cs +++ b/OpenNest.Data/NestRecordFactory.cs @@ -21,7 +21,7 @@ public static class NestRecordFactory nest.MadeBy = record.MadeBy; nest.DateLastModified = record.DateModified; nest.DateCreated = record.DateCreated; - nest.Material = NestMaterialSelection.Apply(nest.Material, record.Material); + nest.Material = NestMaterialSelection.ApplyRecorded(nest.Material, record.Material, record.MaterialGrade); nest.Thickness = record.Thickness; } @@ -44,6 +44,7 @@ public static class NestRecordFactory DateCreated = nest.DateCreated, DateModified = nest.DateLastModified, Material = nest.Material?.Name ?? "", + MaterialGrade = nest.Material?.Grade ?? "", Thickness = nest.Thickness, Status = nest.Status, // The trailing empty plate is the editor's new-plate slot, not a plate of the nest. diff --git a/OpenNest.Data/NestSaveSession.cs b/OpenNest.Data/NestSaveSession.cs index 59d6fe40..2613c3b8 100644 --- a/OpenNest.Data/NestSaveSession.cs +++ b/OpenNest.Data/NestSaveSession.cs @@ -127,7 +127,7 @@ public sealed class NestSaveSession private static bool SameEditableMetadata(NestRecord a, NestRecord b) => a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated - && a.DateModified == b.DateModified && a.Material == b.Material && a.Thickness == b.Thickness - && a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount + && a.DateModified == b.DateModified && a.Material == b.Material && a.MaterialGrade == b.MaterialGrade + && a.Thickness == b.Thickness && a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount && a.Comments == b.Comments && a.MadeBy == b.MadeBy; } diff --git a/OpenNest.Data/NestSortField.cs b/OpenNest.Data/NestSortField.cs index c0fdd946..7f0ed22e 100644 --- a/OpenNest.Data/NestSortField.cs +++ b/OpenNest.Data/NestSortField.cs @@ -16,4 +16,5 @@ public enum NestSortField MadeBy, Comments, FileSize, + MaterialGrade, } diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index 23f2daef..86da82b6 100644 --- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -21,7 +21,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items); Assert.Equal(before, NestDigest(factory.DatabasePath)); - Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -42,7 +42,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) { - Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(before, NestDigest(factory.DatabasePath)); // Two distinct blobs across four revisions: the bracket's notes edit and the // plate both reuse the bracket's first package. @@ -79,7 +79,7 @@ public sealed class DrawingLibraryDatabaseTests { using var factory = new ServerFactory(); using (new NestDatabase(factory.DatabasePath, pooling: false)) { } - Execute(factory.DatabasePath, "PRAGMA user_version=8"); + Execute(factory.DatabasePath, "PRAGMA user_version=9"); Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); } diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs index f2edfd8a..79a4f3e9 100644 --- a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs @@ -22,7 +22,7 @@ public sealed class DrawingSetDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items); Assert.Equal(before, Digest(factory.DatabasePath)); - Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -47,7 +47,7 @@ public sealed class DrawingSetDatabaseTests Assert.Equal(before, Digest(factory.DatabasePath)); Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing"); using var database = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } [Fact] diff --git a/OpenNest.Server.Tests/NestMaterialGradeTests.cs b/OpenNest.Server.Tests/NestMaterialGradeTests.cs new file mode 100644 index 00000000..f8aa6ce6 --- /dev/null +++ b/OpenNest.Server.Tests/NestMaterialGradeTests.cs @@ -0,0 +1,106 @@ +using Microsoft.Data.Sqlite; +using OpenNest.Data; + +namespace OpenNest.Server.Tests; + +public sealed class NestMaterialGradeTests +{ + [Fact] + public void Grade_RoundTripsThroughInsertUpdateAndHistory() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var id = Guid.NewGuid(); + + var created = database.Insert(id, Record("Job", "Stainless", "304"), [1]); + Assert.Equal("304", created.MaterialGrade); + + var updated = database.UpdateMetadata(id, Record("Job", "Stainless", "316"), created.Revision)!; + Assert.Equal("316", updated.MaterialGrade); + Assert.Equal(created.Revision + 1, updated.Revision); + + var history = database.History(id, 0, 10)!; + Assert.Equal(["316", "304"], history.Items.Select(r => r.MaterialGrade)); + } + + [Fact] + public void GradeOnlyChange_IsNotANoOp() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var id = Guid.NewGuid(); + var created = database.Insert(id, Record("Job", "Stainless", "304"), [1]); + + var unchanged = database.UpdateMetadata(id, Record("Job", "Stainless", "304"), created.Revision)!; + var changed = database.UpdateMetadata(id, Record("Job", "Stainless", "316"), created.Revision)!; + + Assert.Equal(created.Revision, unchanged.Revision); + Assert.Equal(created.Revision + 1, changed.Revision); + } + + [Fact] + public void Query_SearchesAndSortsByGrade() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + database.Insert(Guid.NewGuid(), Record("A", "Aluminum", "6061"), [1]); + database.Insert(Guid.NewGuid(), Record("B", "Aluminum", "5052"), [1]); + database.Insert(Guid.NewGuid(), Record("C", "Stainless", "304"), [1]); + + var found = database.Query(new NestQuery { Search = "5052" }); + Assert.Equal("B", Assert.Single(found.Items).Name); + + var sorted = database.Query(new NestQuery { Sort = NestSortField.MaterialGrade, Descending = false }); + Assert.Equal(["C", "B", "A"], sorted.Items.Select(r => r.Name)); + } + + [Fact] + public void Version7Database_GainsBlankGradeAndKeepsRecords() + { + using var factory = new ServerFactory(); + var id = Guid.NewGuid(); + using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) + database.Insert(id, Record("Legacy", "Stainless", "304"), [1]); + Execute(factory.DatabasePath, """ + ALTER TABLE nests DROP COLUMN materialGrade; + ALTER TABLE nest_revisions DROP COLUMN materialGrade; + PRAGMA user_version=7; + """); + + using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); + + var record = upgraded.Get(id)!; + Assert.Equal("Stainless", record.Material); + Assert.Equal("", record.MaterialGrade); + Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade); + Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + } + + private static NestRecord Record(string name, string material, string grade) => new() + { + Name = name, + Material = material, + MaterialGrade = grade, + Thickness = 0.25, + DateCreated = new DateTime(2026, 1, 1), + DateModified = new DateTime(2026, 1, 2), + }; + + private static void Execute(string path, string sql) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + private static object? Scalar(string path, string sql) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } +} diff --git a/OpenNest.Server.Tests/NestQueryTests.cs b/OpenNest.Server.Tests/NestQueryTests.cs index 4c424eb2..f90d2602 100644 --- a/OpenNest.Server.Tests/NestQueryTests.cs +++ b/OpenNest.Server.Tests/NestQueryTests.cs @@ -321,6 +321,7 @@ public sealed class NestQueryTests : IDisposable DateCreated = new DateTime(2026, 1, 1 + index % 28, 8, 0, 0, DateTimeKind.Unspecified), DateModified = new DateTime(2026, 1, 2 + index % 27, 9, 0, 0, DateTimeKind.Unspecified), Material = index % 3 == 0 ? "Synthetic steel" : "Synthetic alloy", + MaterialGrade = index % 4 == 0 ? "" : $"Grade {index % 5}", Thickness = 0.25 + index % 3 * 0.125, Status = statuses[index % statuses.Length], PlateCount = 1 + index % 4, @@ -362,6 +363,7 @@ public sealed class NestQueryTests : IDisposable NestSortField.MadeBy => Text(r => r.MadeBy), NestSortField.Comments => Text(r => r.Comments), NestSortField.FileSize => (a, b) => a.FileSize.CompareTo(b.FileSize), + NestSortField.MaterialGrade => Text(r => r.MaterialGrade), _ => throw new ArgumentOutOfRangeException(nameof(field)), }; var sorted = records.ToList(); diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index f67423c3..c8af6537 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -149,11 +149,11 @@ public sealed partial class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = """ INSERT INTO nests (id, name, customer, dateCreated, dateModified, material, - thickness, status, plateCount, partCount, comments, madeBy, fileSize, - savedAt, file) + materialGrade, thickness, status, plateCount, partCount, comments, madeBy, + fileSize, savedAt, file) VALUES ($id, $name, $customer, $dateCreated, $dateModified, $material, - $thickness, $status, $plateCount, $partCount, $comments, $madeBy, - $fileSize, $savedAt, $file) + $materialGrade, $thickness, $status, $plateCount, $partCount, $comments, + $madeBy, $fileSize, $savedAt, $file) """; AddRecordParameters(command, id, record, file, updateFile: true); command.ExecuteNonQuery(); @@ -199,7 +199,7 @@ public sealed partial class NestDatabase : IDisposable UPDATE nests SET name = $name, customer = $customer, dateCreated = $dateCreated, dateModified = $dateModified, material = $material, - thickness = $thickness, status = $status, plateCount = $plateCount, + materialGrade = $materialGrade, thickness = $thickness, status = $status, plateCount = $plateCount, partCount = $partCount, comments = $comments, madeBy = $madeBy, savedAt = $savedAt, revision = $revision, restoredFromRevision=$source{fileClause} WHERE id = $id @@ -297,8 +297,8 @@ public sealed partial class NestDatabase : IDisposable private static bool SameMetadata(NestRecord a, NestRecord b) => a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated && - a.DateModified == b.DateModified && a.Material == b.Material && a.Thickness == b.Thickness && - a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount && + a.DateModified == b.DateModified && a.Material == b.Material && a.MaterialGrade == b.MaterialGrade && + a.Thickness == b.Thickness && a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount && a.Comments == b.Comments && a.MadeBy == b.MadeBy; private void Snapshot(Guid id, SqliteTransaction transaction) @@ -319,7 +319,7 @@ public sealed partial class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = "PRAGMA user_version"; var version = (long)command.ExecuteScalar()!; - if (version > 7) throw new InvalidOperationException("Database schema is newer than this server."); + if (version > 8) throw new InvalidOperationException("Database schema is newer than this server."); if (version == 0) { command.CommandText = """ @@ -371,6 +371,21 @@ public sealed partial class NestDatabase : IDisposable command.CommandText = DrawingSetSchema; command.ExecuteNonQuery(); } + if (version < 8) + { + // Existing records stay blank until saved again; clients keep the archive's grade meanwhile. + foreach (var table in new[] { "nests", "nest_revisions" }) + { + command.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name='materialGrade'"; + if ((long)command.ExecuteScalar()! == 0) + { + command.CommandText = $"ALTER TABLE {table} ADD COLUMN materialGrade TEXT NOT NULL DEFAULT ''"; + command.ExecuteNonQuery(); + } + } + command.CommandText = "PRAGMA user_version=8"; + command.ExecuteNonQuery(); + } transaction.Commit(); } } @@ -408,7 +423,8 @@ public sealed partial class NestDatabase : IDisposable private const string RecordColumns = """ id, name, customer, dateCreated, dateModified, material, thickness, - status, plateCount, partCount, comments, madeBy, fileSize, savedAt, revision, deleted, restoredFromRevision + status, plateCount, partCount, comments, madeBy, fileSize, savedAt, revision, deleted, restoredFromRevision, + materialGrade """; // Case-insensitive (ASCII) substring over the text columns and the status's stored @@ -417,6 +433,7 @@ public sealed partial class NestDatabase : IDisposable name LIKE $pattern ESCAPE '\' OR customer LIKE $pattern ESCAPE '\' OR material LIKE $pattern ESCAPE '\' + OR materialGrade LIKE $pattern ESCAPE '\' OR madeBy LIKE $pattern ESCAPE '\' OR comments LIKE $pattern ESCAPE '\' OR status LIKE $pattern ESCAPE '\' @@ -440,6 +457,7 @@ public sealed partial class NestDatabase : IDisposable NestSortField.MadeBy => "madeBy COLLATE NOCASE", NestSortField.Comments => "comments COLLATE NOCASE", NestSortField.FileSize => "fileSize", + NestSortField.MaterialGrade => "materialGrade COLLATE NOCASE", _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."), }; @@ -461,6 +479,7 @@ public sealed partial class NestDatabase : IDisposable command.Parameters.AddWithValue("$dateCreated", record.DateCreated.ToString("o")); command.Parameters.AddWithValue("$dateModified", record.DateModified.ToString("o")); command.Parameters.AddWithValue("$material", record.Material); + command.Parameters.AddWithValue("$materialGrade", record.MaterialGrade); command.Parameters.AddWithValue("$thickness", record.Thickness); command.Parameters.AddWithValue("$status", record.Status.ToString()); command.Parameters.AddWithValue("$plateCount", record.PlateCount); @@ -498,6 +517,7 @@ public sealed partial class NestDatabase : IDisposable Revision = reader.GetInt32(first + 14), Deleted = reader.GetInt32(first + 15) != 0, RestoredFromRevision = reader.IsDBNull(first + 16) ? null : reader.GetInt32(first + 16), + MaterialGrade = reader.GetString(first + 17), }; } } diff --git a/OpenNest.Server/NestMetadata.cs b/OpenNest.Server/NestMetadata.cs index 751a7ff3..212e6532 100644 --- a/OpenNest.Server/NestMetadata.cs +++ b/OpenNest.Server/NestMetadata.cs @@ -51,6 +51,7 @@ internal static class NestMetadata if (record.Name is null) return "name must be a string."; if (record.Customer is null) return "customer must be a string."; if (record.Material is null) return "material must be a string."; + if (record.MaterialGrade is null) return "materialGrade must be a string."; if (record.Comments is null) return "comments must be a string."; if (record.MadeBy is null) return "madeBy must be a string."; if (!Enum.IsDefined(record.Status)) return "status must be draft, quote, toBeCut or hasBeenCut."; diff --git a/OpenNest.Tests/Data/NestMaterialSelectionTests.cs b/OpenNest.Tests/Data/NestMaterialSelectionTests.cs index 243855ae..45a4502e 100644 --- a/OpenNest.Tests/Data/NestMaterialSelectionTests.cs +++ b/OpenNest.Tests/Data/NestMaterialSelectionTests.cs @@ -5,11 +5,11 @@ namespace OpenNest.Tests.Data; public class NestMaterialSelectionTests { [Fact] - public void Apply_UnchangedName_KeepsGradeAndDensity() + public void Apply_UnchangedNameAndGrade_KeepsDensity() { var current = new Material("Stainless", "304", 0.289); - var result = NestMaterialSelection.Apply(current, "Stainless"); + var result = NestMaterialSelection.Apply(current, "Stainless", "304"); Assert.Equal("Stainless", result.Name); Assert.Equal("304", result.Grade); @@ -21,7 +21,7 @@ public class NestMaterialSelectionTests { var current = new Material("Cold Rolled Steel", "1008", 0.284); - var result = NestMaterialSelection.Apply(current, " cold rolled\tsteel "); + var result = NestMaterialSelection.Apply(current, " cold rolled\tsteel ", "1008"); Assert.Equal(" cold rolled\tsteel ", result.Name); Assert.Equal("1008", result.Grade); @@ -33,7 +33,7 @@ public class NestMaterialSelectionTests { var current = new Material("Stainless", "304", 0.289); - var result = NestMaterialSelection.Apply(current, "Aluminum"); + var result = NestMaterialSelection.Apply(current, "Aluminum", ""); Assert.Equal("Aluminum", result.Name); Assert.True(string.IsNullOrEmpty(result.Grade)); @@ -45,7 +45,7 @@ public class NestMaterialSelectionTests { var current = new Material("Stainless", "304", 0.289); - var result = NestMaterialSelection.Apply(current, ""); + var result = NestMaterialSelection.Apply(current, "", ""); Assert.Equal("", result.Name); Assert.True(string.IsNullOrEmpty(result.Grade)); @@ -55,7 +55,7 @@ public class NestMaterialSelectionTests [Fact] public void Apply_NoCurrentMaterial_GetsNameOnly() { - var result = NestMaterialSelection.Apply(null, "Stainless"); + var result = NestMaterialSelection.Apply(null, "Stainless", null); Assert.Equal("Stainless", result.Name); Assert.True(string.IsNullOrEmpty(result.Grade)); @@ -67,7 +67,7 @@ public class NestMaterialSelectionTests { var current = new Material("Stainless", "304", 0.289); - var result = NestMaterialSelection.Apply(current, "stainless"); + var result = NestMaterialSelection.Apply(current, "stainless", "304"); result.Grade = "316"; result.Density = 1; @@ -77,6 +77,67 @@ public class NestMaterialSelectionTests Assert.Equal(0.289, current.Density); } + [Fact] + public void Apply_ChangedGrade_TakesGradeWithoutDensity() + { + var current = new Material("Stainless", "304", 0.289); + + var result = NestMaterialSelection.Apply(current, "Stainless", "316"); + + Assert.Equal("Stainless", result.Name); + Assert.Equal("316", result.Grade); + Assert.Equal(0, result.Density); + } + + [Fact] + public void Apply_ChangedNameWithGrade_TakesNameAndGrade() + { + var current = new Material("Stainless", "304", 0.289); + + var result = NestMaterialSelection.Apply(current, "Aluminum", "5052"); + + Assert.Equal("Aluminum", result.Name); + Assert.Equal("5052", result.Grade); + Assert.Equal(0, result.Density); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void ApplyRecorded_BlankGradeWithSameName_KeepsArchivedGradeAndDensity(string? recordedGrade) + { + var current = new Material("Stainless", "304", 0.289); + + var result = NestMaterialSelection.ApplyRecorded(current, "stainless", recordedGrade); + + Assert.Equal("stainless", result.Name); + Assert.Equal("304", result.Grade); + Assert.Equal(0.289, result.Density); + } + + [Fact] + public void ApplyRecorded_BlankGradeWithChangedName_GetsNameOnly() + { + var current = new Material("Stainless", "304", 0.289); + + var result = NestMaterialSelection.ApplyRecorded(current, "Aluminum", ""); + + Assert.Equal("Aluminum", result.Name); + Assert.Equal("", result.Grade); + Assert.Equal(0, result.Density); + } + + [Fact] + public void ApplyRecorded_RecordedGrade_Wins() + { + var current = new Material("Stainless", "304", 0.289); + + var result = NestMaterialSelection.ApplyRecorded(current, "Stainless", "316"); + + Assert.Equal("316", result.Grade); + Assert.Equal(0, result.Density); + } + [Theory] [InlineData("Carbon Steel", "CARBON STEEL")] [InlineData(" carbon \t steel\n", "CARBON STEEL")] diff --git a/OpenNest.Tests/Data/NestRecordFactoryTests.cs b/OpenNest.Tests/Data/NestRecordFactoryTests.cs index 46d974dc..de9e37a1 100644 --- a/OpenNest.Tests/Data/NestRecordFactoryTests.cs +++ b/OpenNest.Tests/Data/NestRecordFactoryTests.cs @@ -34,6 +34,7 @@ public class NestRecordFactoryTests NestRecordFactory.ApplyMetadata(nest, current); var roundTrip = NestRecordFactory.FromNest(nest, Guid.Empty, 0); Assert.Equal(current.Material, roundTrip.Material); + Assert.Equal(sameMaterial ? "Test grade" : "", roundTrip.MaterialGrade); Assert.Equal(current.Thickness, roundTrip.Thickness); Assert.Equal(current.DateCreated, roundTrip.DateCreated); Assert.Equal(current.DateModified, roundTrip.DateModified); @@ -58,7 +59,7 @@ public class NestRecordFactoryTests var nest = new Nest("Job 42") { Customer = "Cozy Cat", - Material = new Material("Aluminum"), + Material = new Material("Aluminum", "5052"), Thickness = 0.125, Status = NestStatus.ToBeCut, MadeBy = "AJ", @@ -89,6 +90,7 @@ public class NestRecordFactoryTests Assert.Equal("Job 42", record.Name); Assert.Equal("Cozy Cat", record.Customer); Assert.Equal("Aluminum", record.Material); + Assert.Equal("5052", record.MaterialGrade); Assert.Equal(0.125, record.Thickness); Assert.Equal(NestStatus.ToBeCut, record.Status); Assert.Equal("AJ", record.MadeBy); @@ -111,6 +113,7 @@ public class NestRecordFactoryTests Assert.Equal(0, record.PlateCount); Assert.Equal(0, record.PartCount); Assert.Equal("", record.Material); + Assert.Equal("", record.MaterialGrade); Assert.Equal("", record.Comments); Assert.Equal("", record.MadeBy); } diff --git a/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs b/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs index be3235e2..fc704ef6 100644 --- a/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs @@ -216,6 +216,56 @@ public class EditNestInfoFormTests }); } + [Fact] + public void GradeList_FollowsMaterial_AndSavesChosenGrade() + { + RunSta(() => + { + var nest = new Nest { Material = new Material("Stainless", "304", 0.289) }; + using var form = new EditNestInfoForm(); + form.SetMaterialCatalog(new[] + { + new Data.MaterialConfig { Name = "Stainless", Grade = "316" }, + new Data.MaterialConfig { Name = "Stainless", Grade = "304" }, + new Data.MaterialConfig { Name = "Aluminum", Grade = "5052" }, + }); + form.LoadNestInfo(nest); + var gradeBox = Assert.IsType(form.Controls.Find("gradeBox", true).Single()); + + Assert.Equal(new[] { "304", "316" }, gradeBox.Items.Cast()); + Assert.Equal("304", form.MaterialGrade); + + form.MaterialName = "aluminum"; + Assert.Equal(new[] { "5052" }, gradeBox.Items.Cast()); + Assert.Equal("", form.MaterialGrade); + + form.MaterialGrade = "5052"; + form.SaveNestInfo(nest); + + Assert.Equal("Aluminum", nest.Material.Name); + Assert.Equal("5052", nest.Material.Grade); + Assert.Equal(0, nest.Material.Density); + }); + } + + [Fact] + public void SaveWithChangedGrade_KeepsNameAndDropsDensity() + { + RunSta(() => + { + var nest = new Nest { Material = new Material("Stainless", "304", 0.289) }; + using var form = new EditNestInfoForm(); + form.LoadNestInfo(nest); + form.MaterialGrade = "316"; + + form.SaveNestInfo(nest); + + Assert.Equal("Stainless", nest.Material.Name); + Assert.Equal("316", nest.Material.Grade); + Assert.Equal(0, nest.Material.Density); + }); + } + private static void RunSta(Action action) => StaTestThread.Run(action, TimeSpan.FromSeconds(15), "The STA test did not complete."); } diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 223e13f9..1c36fa40 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -758,6 +758,7 @@ namespace OpenNest.Forms public async Task ShowNestInfoEditorAsync() { var form = new EditNestInfoForm { AllowRename = NestStorage.Settings.IsDatabaseMode }; + form.SetMaterialCatalog(MachineSettings.LoadMaterials()); form.LoadNestInfo(Nest); _ = ShowReleaseOriginAsync(form); diff --git a/OpenNest/Forms/EditNestInfoForm.Designer.cs b/OpenNest/Forms/EditNestInfoForm.Designer.cs index 4d855ee9..7f0aac23 100644 --- a/OpenNest/Forms/EditNestInfoForm.Designer.cs +++ b/OpenNest/Forms/EditNestInfoForm.Designer.cs @@ -64,6 +64,8 @@ this.label5 = new System.Windows.Forms.Label(); this.labelMaterial = new System.Windows.Forms.Label(); this.materialBox = new System.Windows.Forms.ComboBox(); + this.labelGrade = new System.Windows.Forms.Label(); + this.gradeBox = new System.Windows.Forms.ComboBox(); this.labelStatus = new System.Windows.Forms.Label(); this.statusBox = new System.Windows.Forms.ComboBox(); this.labelMadeBy = new System.Windows.Forms.Label(); @@ -408,37 +410,40 @@ this.tableLayoutPanel3.ColumnCount = 2; this.tableLayoutPanel3.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); this.tableLayoutPanel3.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); - this.tableLayoutPanel3.Controls.Add(this.tableLayoutPanel4, 1, 6); + this.tableLayoutPanel3.Controls.Add(this.tableLayoutPanel4, 1, 7); this.tableLayoutPanel3.Controls.Add(this.label1, 0, 0); this.tableLayoutPanel3.Controls.Add(this.nameBox, 1, 0); - this.tableLayoutPanel3.Controls.Add(this.label2, 0, 5); + this.tableLayoutPanel3.Controls.Add(this.label2, 0, 6); this.tableLayoutPanel3.Controls.Add(this.labelThk, 0, 3); this.tableLayoutPanel3.Controls.Add(this.thicknessBox, 1, 3); this.tableLayoutPanel3.Controls.Add(this.labelMaterial, 0, 4); this.tableLayoutPanel3.Controls.Add(this.materialBox, 1, 4); + this.tableLayoutPanel3.Controls.Add(this.labelGrade, 0, 5); + this.tableLayoutPanel3.Controls.Add(this.gradeBox, 1, 5); this.tableLayoutPanel3.Controls.Add(this.label3, 0, 1); this.tableLayoutPanel3.Controls.Add(this.label4, 0, 2); - this.tableLayoutPanel3.Controls.Add(this.customerBox, 1, 5); + this.tableLayoutPanel3.Controls.Add(this.customerBox, 1, 6); this.tableLayoutPanel3.Controls.Add(this.textBox1, 1, 1); this.tableLayoutPanel3.Controls.Add(this.textBox2, 1, 2); - this.tableLayoutPanel3.Controls.Add(this.label5, 0, 6); - this.tableLayoutPanel3.Controls.Add(this.labelStatus, 0, 7); - this.tableLayoutPanel3.Controls.Add(this.statusBox, 1, 7); - this.tableLayoutPanel3.Controls.Add(this.labelMadeBy, 0, 8); - this.tableLayoutPanel3.Controls.Add(this.madeByBox, 1, 8); + this.tableLayoutPanel3.Controls.Add(this.label5, 0, 7); + this.tableLayoutPanel3.Controls.Add(this.labelStatus, 0, 8); + this.tableLayoutPanel3.Controls.Add(this.statusBox, 1, 8); + this.tableLayoutPanel3.Controls.Add(this.labelMadeBy, 0, 9); + this.tableLayoutPanel3.Controls.Add(this.madeByBox, 1, 9); this.tableLayoutPanel3.Location = new System.Drawing.Point(6, 6); this.tableLayoutPanel3.Name = "tableLayoutPanel3"; - this.tableLayoutPanel3.RowCount = 9; - this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F)); - this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F)); - this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F)); - this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F)); - this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F)); - this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F)); - this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F)); - this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F)); - this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F)); - this.tableLayoutPanel3.Size = new System.Drawing.Size(362, 280); + this.tableLayoutPanel3.RowCount = 10; + this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 10F)); + this.tableLayoutPanel3.Size = new System.Drawing.Size(362, 311); this.tableLayoutPanel3.TabIndex = 0; // // tableLayoutPanel4 @@ -532,6 +537,22 @@ this.materialBox.Name = "materialBox"; this.materialBox.Size = new System.Drawing.Size(224, 24); this.materialBox.TabIndex = 11; + // + // labelGrade + // + this.labelGrade.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right))); + this.labelGrade.AutoSize = true; + this.labelGrade.Name = "labelGrade"; + this.labelGrade.TabIndex = 10; + this.labelGrade.Text = "Grade :"; + // + // gradeBox + // + this.gradeBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right))); + this.gradeBox.FormattingEnabled = true; + this.gradeBox.Name = "gradeBox"; + this.gradeBox.Size = new System.Drawing.Size(224, 24); + this.gradeBox.TabIndex = 11; // // labelStatus // @@ -786,6 +807,8 @@ private System.Windows.Forms.Label label5; private System.Windows.Forms.Label labelMaterial; private System.Windows.Forms.ComboBox materialBox; + private System.Windows.Forms.Label labelGrade; + private System.Windows.Forms.ComboBox gradeBox; private System.Windows.Forms.Label labelStatus; private System.Windows.Forms.ComboBox statusBox; private System.Windows.Forms.Label labelMadeBy; diff --git a/OpenNest/Forms/EditNestInfoForm.cs b/OpenNest/Forms/EditNestInfoForm.cs index 2edc94ae..afe0ff3d 100644 --- a/OpenNest/Forms/EditNestInfoForm.cs +++ b/OpenNest/Forms/EditNestInfoForm.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Windows.Forms; using OpenNest.Data; using OpenNest.Geometry; @@ -11,6 +13,8 @@ namespace OpenNest.Forms private readonly Timer timer; private DateTime dateCreated; private DateTime dateLastModified; + private List materialCatalog = new(); + private string gradeMaterialKey = ""; public EditNestInfoForm() { @@ -34,6 +38,7 @@ namespace OpenNest.Forms }; Disposed += (sender, e) => timer.Dispose(); nameBox.TextChanged += (sender, e) => EnableCheck(); + materialBox.TextChanged += (sender, e) => RefreshGrades(); EnableCheck(); } @@ -142,6 +147,57 @@ namespace OpenNest.Forms set { materialBox.Text = value; } } + public string MaterialGrade + { + get { return gradeBox.Text; } + set { gradeBox.Text = value; } + } + + /// + /// Offers the machine configurations' materials: their names join the material + /// list, and the grade list shows the grades on file for the chosen material. + /// + public void SetMaterialCatalog(IEnumerable materials) + { + materialCatalog = materials.ToList(); + + var text = materialBox.Text; + var names = materialBox.Items.Cast() + .Concat(materialCatalog.Select(m => m.Name)) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .GroupBy(SharedListNames.Key) + .Select(group => group.First()) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + materialBox.Items.Clear(); + materialBox.Items.AddRange(names); + materialBox.Text = text; + RefreshGrades(); + } + + /// + /// Lists the grades on file for the typed material. A grade belongs to its + /// material, so changing the material clears a grade the new one does not have. + /// + private void RefreshGrades() + { + var text = gradeBox.Text; + var key = SharedListNames.Key(materialBox.Text); + var grades = materialCatalog + .Where(m => SharedListNames.Key(m.Name) == key && !string.IsNullOrWhiteSpace(m.Grade)) + .Select(m => m.Grade) + .GroupBy(SharedListNames.Key) + .Select(group => group.First()) + .OrderBy(grade => grade, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (key != gradeMaterialKey && !grades.Any(grade => SharedListNames.Key(grade) == SharedListNames.Key(text))) + text = ""; + gradeMaterialKey = key; + gradeBox.Items.Clear(); + gradeBox.Items.AddRange(grades); + gradeBox.Text = text; + } + public NestStatus Status { get { return statusBox.SelectedItem is NestStatus status ? status : NestStatus.Quote; } @@ -245,6 +301,7 @@ namespace OpenNest.Forms DateLastModified = nest.DateLastModified; Thickness = nest.Thickness; MaterialName = nest.Material?.Name ?? ""; + MaterialGrade = nest.Material?.Grade ?? ""; Status = nest.Status; MadeBy = nest.MadeBy ?? ""; SizeString = nest.PlateDefaults.Size.ToString(); @@ -267,7 +324,7 @@ namespace OpenNest.Forms nest.DateCreated = DateCreated; nest.DateLastModified = DateLastModified; nest.Thickness = Thickness; - nest.Material = NestMaterialSelection.Apply(nest.Material, MaterialName); + nest.Material = NestMaterialSelection.Apply(nest.Material, MaterialName, MaterialGrade); nest.Status = Status; nest.MadeBy = MadeBy; nest.PlateDefaults.Size = OpenNest.Geometry.Size.Parse(SizeString); diff --git a/OpenNest/Forms/MachineSettings.cs b/OpenNest/Forms/MachineSettings.cs index 5fc378c6..4eaadf17 100644 --- a/OpenNest/Forms/MachineSettings.cs +++ b/OpenNest/Forms/MachineSettings.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Windows.Forms; using OpenNest.Data; @@ -42,6 +43,10 @@ namespace OpenNest.Forms catch (UnauthorizedAccessException) { } return machines; } + + /// Every material and grade on file across the readable machine configurations. + public static IEnumerable LoadMaterials() => + LoadMachines().SelectMany(machine => machine.Materials); private static bool defaultsWarned; /// diff --git a/OpenNest/Forms/SavedNestsForm.cs b/OpenNest/Forms/SavedNestsForm.cs index 2fdd886c..9ac38f36 100644 --- a/OpenNest/Forms/SavedNestsForm.cs +++ b/OpenNest/Forms/SavedNestsForm.cs @@ -111,6 +111,7 @@ public sealed class SavedNestsForm : Form AddNestColumn("Customer", NestSortField.Customer, 160); AddNestColumn("Made by", NestSortField.MadeBy, 90); AddNestColumn("Material", NestSortField.Material, 150); + AddNestColumn("Grade", NestSortField.MaterialGrade, 80); AddNestColumn("Thickness", NestSortField.Thickness, 80, alignRight: true); AddNestColumn("Created", NestSortField.DateCreated, 140); AddNestColumn("Saved", NestSortField.SavedAt, 140); @@ -515,6 +516,7 @@ public sealed class SavedNestsForm : Form record.Customer, record.MadeBy, record.Material, + record.MaterialGrade, record.Thickness.ToString("0.####", CultureInfo.CurrentCulture), FormatDate(record.DateCreated), FormatDate(record.SavedAt), @@ -816,6 +818,7 @@ public sealed class SavedNestsForm : Form }); if (IsDisposed) return; using var dialog = new EditNestInfoForm { AllowRename = true }; + dialog.SetMaterialCatalog(MachineSettings.LoadMaterials()); dialog.LoadNestInfo(nest); while (true) { diff --git a/docs/nest-storage.md b/docs/nest-storage.md index d5239095..98fbeee5 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -5,7 +5,7 @@ The desktop app can save nests two ways: - **File mode** (default, unchanged behavior): Save/Save As write a `.nest` ZIP archive to disk via the normal file dialog. See [nest-file-format.md](nest-file-format.md). - **Database mode**: Open browses server records 100 at a time. The Find box - searches name, customer, material, made by, comments and status on the server + searches name, customer, material, grade, made by, comments and status on the server after a short typing pause (Enter in the box searches at once); clicking a column header sorts every match on the server (click again to reverse); the arrow buttons move between pages and the title and find bar show the range and @@ -77,7 +77,8 @@ so upgrade the server before desktops that can set it. | `name` | string | | | `customer` | string | | | `dateCreated` / `dateModified` | datetime | Set by the client from the nest's own metadata. | -| `material` | string | | +| `material` | string | Material name (type), e.g. `Stainless`. | +| `materialGrade` | string | Optional; default blank. Older servers drop it. A blank grade on a record saved before schema v8 is treated as unrecorded: opening keeps the archive's grade while the material name matches. | | `thickness` | number | | | `status` | `"draft"` \| `"quote"` \| `"toBeCut"` \| `"hasBeenCut"` | Default `quote`. | | `plateCount` / `partCount` | integer | Computed client-side at save time (non-cutoff parts only). | @@ -163,13 +164,13 @@ only the requested page of metadata (never archive bytes): - `search` (optional): trimmed, at most 200 characters, no NUL characters; blank means no filter. Case-insensitive substring of the whole text in `name`, `customer`, `material`, - `madeBy`, `comments` or the status (`ToBeCut` or the display name `To Be Cut`). + `materialGrade`, `madeBy`, `comments` or the status (`ToBeCut` or the display name `To Be Cut`). `%`, `_` and `\` are literal. Dates and numbers are not matched as text. Case folding is ASCII-only (SQLite `LIKE`). - `offset` (default 0, at least 0) and `limit` (default 100, 1 to 500). - `sort` (default `savedAt`): one of `savedAt`, `name`, `customer`, `status`, `material`, `dateCreated`, `dateModified`, `thickness`, `plateCount`, - `partCount`, `madeBy`, `comments`, `fileSize` (names, case-insensitive; no + `partCount`, `madeBy`, `comments`, `fileSize`, `materialGrade` (names, case-insensitive; no numbers). Text columns sort case-insensitively (ASCII); dates sort as their stored ISO text. `order` is `asc` or `desc` (default `desc`). - Ties are broken by `id` in the same direction, so pages partition the matches @@ -535,6 +536,11 @@ v7 refuses to open a v7 database, so rolling back needs the pre-upgrade backup. The stopped-volume `tar` backup includes the set and release tables; `nest_manifest.py` and `restore_check` do not verify them yet. +Schema v8 adds a `materialGrade` text column (default blank) to `nests` and +`nest_revisions`; existing records keep a blank grade until they are next saved. +A server older than v8 refuses to open a v8 database, so rolling back needs the +pre-upgrade backup. + ## Storage `OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with