using System.Text.Json;
namespace OpenNest.Data;
///
/// One settings document every workstation shares through the nest server
/// (/api/settings): a machine configuration, a post's settings, the clamp rail or the
/// nest defaults. is the document exactly as its owner serializes it;
/// guards every change.
///
public sealed class SharedSettingRecord
{
public string Kind { get; set; } = "";
public string Key { get; set; } = "";
public int Revision { get; set; }
public string Json { get; set; } = "";
public DateTime SavedAt { get; set; }
public string SavedBy { get; set; } = "";
}
/// The body of a shared settings create or update.
public sealed class SharedSettingWrite
{
public string Json { get; set; } = "";
public string SavedBy { get; set; } = "";
}
/// The kinds of shared settings and the keys of the single-document kinds.
public static class SharedSettingKinds
{
/// A , keyed by its id.
public const string Machine = "machine";
/// A post processor's settings, keyed by the post's type name.
public const string Post = "post";
///
/// The legacy new nests started from before each machine held its
/// clamps, under ; read only to copy them onto a machine once.
///
public const string Clamps = "clamps";
/// The new nests start from, under .
public const string NestDefaults = "nest-defaults";
/// The key of the clamp settings and nest defaults documents.
public const string DefaultKey = "default";
public static IReadOnlyList All { get; } = [Machine, Post, Clamps, NestDefaults];
public static bool IsKnown(string? kind) => kind is not null && All.Contains(kind);
}
/// Validation shared by the nest server and its clients.
public static class SharedSettingRules
{
public const int MaxKeyLength = 200;
public const int MaxSavedByLength = 200;
///
/// A settings document's limit in characters: far above the largest post configuration, and
/// small enough that the escaped document fits the server's request limit.
///
public const int MaxJsonLength = 512 * 1024;
///
/// The key as stored: a machine id in lower-case "D" form, any other key as given. Null when
/// the key is not valid for the kind.
///
public static string? NormalizeKey(string? kind, string? key)
{
if (!SharedSettingKinds.IsKnown(kind) || string.IsNullOrEmpty(key) || key.Length > MaxKeyLength)
return null;
if (kind == SharedSettingKinds.Machine)
return Guid.TryParse(key, out var id) && id != Guid.Empty ? id.ToString("D") : null;
if (kind is SharedSettingKinds.Clamps or SharedSettingKinds.NestDefaults)
return key == SharedSettingKinds.DefaultKey ? key : null;
return key.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '-' or '+') ? key : null;
}
/// Why the document cannot be stored under the kind and key, or null when it can.
public static string? GetError(string? kind, string? key, SharedSettingWrite? write)
{
if (!SharedSettingKinds.IsKnown(kind))
return $"Unknown settings kind '{kind}'.";
var normalized = NormalizeKey(kind, key);
if (normalized is null)
return $"Invalid {kind} settings key.";
if (write is null || string.IsNullOrEmpty(write.Json))
return "The settings document is required.";
if (write.Json.Length > MaxJsonLength)
return "The settings document is too large.";
if ((write.SavedBy ?? "").Length > MaxSavedByLength)
return "savedBy is too long.";
try
{
using var document = JsonDocument.Parse(write.Json);
if (document.RootElement.ValueKind != JsonValueKind.Object)
return "The settings document must be a JSON object.";
}
catch (JsonException)
{
return "The settings document is not valid JSON.";
}
if (kind == SharedSettingKinds.Machine)
{
MachineConfig? machine;
try { machine = JsonSerializer.Deserialize(write.Json, LocalJsonProvider.JsonOptions); }
catch (JsonException) { return "The machine configuration is not valid."; }
if (machine is null || machine.Id.ToString("D") != normalized)
return "The machine configuration's id does not match its key.";
}
return null;
}
///
/// Why may not replace the stored , or
/// null when it may. A machine saved by an OpenNest too old to know a setting would drop it:
/// a field the stored machine, one of its materials (matched by name and grade) or one of
/// their thicknesses (matched by value) holds a value for, and that the new document leaves
/// out entirely, refuses the save. Current builds keep fields they do not know, and write
/// cleared settings as null, so they never trip this.
///
public static string? GetReplaceError(string? kind, string storedJson, string json)
{
if (kind != SharedSettingKinds.Machine)
return null;
try
{
using var stored = JsonDocument.Parse(storedJson);
using var incoming = JsonDocument.Parse(json);
var dropped = DroppedField(stored.RootElement, incoming.RootElement, "");
return dropped is null
? null
: $"This machine has settings ({dropped}) that this version of OpenNest does not know, "
+ "so saving here would erase them. Update OpenNest to change this machine.";
}
catch (JsonException)
{
return null;
}
}
// The first field with a value in stored that incoming leaves out, as a path; materials and
// thicknesses are matched by identity, so a reorder or a removed entry is not a drop.
private static string? DroppedField(JsonElement stored, JsonElement incoming, string path)
{
if (stored.ValueKind != JsonValueKind.Object || incoming.ValueKind != JsonValueKind.Object)
return null;
foreach (var field in stored.EnumerateObject())
{
if (field.Value.ValueKind == JsonValueKind.Null)
continue;
var name = path.Length == 0 ? field.Name : $"{path}.{field.Name}";
if (!TryGetProperty(incoming, field.Name, out var match))
return name;
var nested = field.Name.ToLowerInvariant() switch
{
"materials" => DroppedInList(field.Value, match, name, Material),
"thicknesses" => DroppedInList(field.Value, match, name, Thickness),
_ => path.Length > 0 && path.EndsWith("]", StringComparison.Ordinal)
? DroppedField(field.Value, match, name)
: null,
};
if (nested != null)
return nested;
}
return null;
}
private static string? DroppedInList(JsonElement stored, JsonElement incoming, string path, Func identity)
{
if (stored.ValueKind != JsonValueKind.Array || incoming.ValueKind != JsonValueKind.Array)
return null;
foreach (var item in stored.EnumerateArray())
{
var id = identity(item);
if (id == null)
continue;
foreach (var candidate in incoming.EnumerateArray())
{
if (identity(candidate) != id)
continue;
if (DroppedField(item, candidate, $"{path}[{id}]") is { } dropped)
return dropped;
break;
}
}
return null;
}
private static string? Material(JsonElement material) =>
material.ValueKind == JsonValueKind.Object
? $"{Text(material, "name")}/{Text(material, "grade")}"
: null;
private static string? Thickness(JsonElement thickness) =>
thickness.ValueKind == JsonValueKind.Object && TryGetProperty(thickness, "value", out var value)
&& value.ValueKind == JsonValueKind.Number
? value.GetDouble().ToString("R", System.Globalization.CultureInfo.InvariantCulture)
: null;
private static string Text(JsonElement element, string name) =>
TryGetProperty(element, name, out var value) && value.ValueKind == JsonValueKind.String
? SharedListNames.Key(value.GetString())
: "";
private static bool TryGetProperty(JsonElement element, string name, out JsonElement value)
{
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
value = default;
return false;
}
}