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