mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 02:41:06 -04:00
Schema v13 adds shared_settings and shared_setting_revisions behind /api/settings: one revision-guarded JSON document per kind and key, with every revision (deletions included) kept with who saved it. Recreating a deleted document continues its revision count. OpenNest.Data gains the client (RemoteSharedSettings), a synchronous store that keeps the last copy read for offline use and refuses writes while the server is unreachable (SharedSettingsStore), a machine provider over it, and JSON parse/serialize entry points for clamp settings and nest defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
384 lines
13 KiB
C#
384 lines
13 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest.Data;
|
|
|
|
/// <summary>Outcome of <see cref="NestDefaults.Load(string, out NestDefaultsStatus)"/>.</summary>
|
|
public enum NestDefaultsStatus
|
|
{
|
|
/// <summary>Defaults were read from the file (invalid fields still fall back individually).</summary>
|
|
Ok,
|
|
|
|
/// <summary>No file exists at the path; built-in fallback values were used.</summary>
|
|
Missing,
|
|
|
|
/// <summary>The file exists but could not be read or parsed; fallback values were used.</summary>
|
|
Invalid,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Plate/nest defaults persisted to a single JSON file
|
|
/// (by default %APPDATA%\OpenNest\defaults.json), replacing the
|
|
/// .nstdot nest-template mechanism. Loading never throws: a missing,
|
|
/// corrupt, or partially valid file degrades field-by-field to
|
|
/// <see cref="Fallback"/> values so creating a new nest is never blocked.
|
|
/// </summary>
|
|
public sealed class NestDefaults
|
|
{
|
|
public const int CurrentVersion = 1;
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true,
|
|
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
|
};
|
|
|
|
public Units Units { get; set; } = Units.Inches;
|
|
|
|
public Size Size { get; set; } = new(100, 100);
|
|
|
|
public int Quadrant { get; set; } = 1;
|
|
|
|
public double PartSpacing { get; set; } = 1;
|
|
|
|
public Spacing EdgeSpacing { get; set; } = new(1, 1, 1, 1);
|
|
|
|
/// <summary>
|
|
/// The machine configuration new nests are assigned, or null to use the first machine
|
|
/// by name. An id with no configuration on file falls back the same way.
|
|
/// </summary>
|
|
public Guid? MachineId { get; set; }
|
|
|
|
/// <summary>
|
|
/// The built-in defaults used when no file exists and for every field
|
|
/// that is missing or invalid. Matches the historical
|
|
/// File > New values (units default to Inches; callers
|
|
/// may override from their own settings).
|
|
/// </summary>
|
|
public static NestDefaults Fallback => new();
|
|
|
|
/// <summary>%APPDATA%\OpenNest\defaults.json.</summary>
|
|
public static string DefaultPath =>
|
|
Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"OpenNest",
|
|
"defaults.json"
|
|
);
|
|
|
|
/// <summary>
|
|
/// Loads defaults from <paramref name="path"/>, falling back field by
|
|
/// field for a missing file, invalid JSON, or invalid values.
|
|
/// </summary>
|
|
public static NestDefaults Load(string path) => Load(path, out _);
|
|
|
|
/// <summary>
|
|
/// Loads defaults and reports whether the file was missing, loaded, or
|
|
/// present but unreadable/invalid, so callers can warn about a corrupt
|
|
/// file while still returning usable values.
|
|
/// </summary>
|
|
public static NestDefaults Load(string path, out NestDefaultsStatus status) =>
|
|
Load(path, Fallback.Units, out status);
|
|
|
|
/// <summary>
|
|
/// Loads defaults like <see cref="Load(string, out NestDefaultsStatus)"/>,
|
|
/// but a missing file, an unusable file, or a missing/undefined unit
|
|
/// field yields <paramref name="fallbackUnits"/> (the caller's existing
|
|
/// unit preference) instead of the built-in units. Other valid fields
|
|
/// still load. An undefined <paramref name="fallbackUnits"/> is ignored.
|
|
/// </summary>
|
|
public static NestDefaults Load(
|
|
string path,
|
|
Units fallbackUnits,
|
|
out NestDefaultsStatus status
|
|
)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
|
{
|
|
status = NestDefaultsStatus.Missing;
|
|
return WithUnits(Fallback, fallbackUnits);
|
|
}
|
|
|
|
string json;
|
|
try
|
|
{
|
|
json = File.ReadAllText(path);
|
|
}
|
|
catch (Exception ex) when (IsUnreadableFile(ex))
|
|
{
|
|
// A file that exists but cannot be read (locked, access-denied)
|
|
// must never block creating a nest.
|
|
status = NestDefaultsStatus.Invalid;
|
|
return WithUnits(Fallback, fallbackUnits);
|
|
}
|
|
return Parse(json, fallbackUnits, out status);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads defaults from <paramref name="json"/> (a file or the shared copy) the way
|
|
/// <see cref="Load(string, Units, out NestDefaultsStatus)"/> reads a file, never throwing.
|
|
/// </summary>
|
|
public static NestDefaults Parse(string json, Units fallbackUnits, out NestDefaultsStatus status)
|
|
{
|
|
var defaults = WithUnits(Fallback, fallbackUnits);
|
|
|
|
NestDefaultsDto? dto;
|
|
try
|
|
{
|
|
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json ?? "", JsonOptions);
|
|
}
|
|
catch (Exception ex) when (IsUnreadableFile(ex))
|
|
{
|
|
// Defaults that cannot be parsed must never block creating a nest.
|
|
status = NestDefaultsStatus.Invalid;
|
|
return defaults;
|
|
}
|
|
|
|
if (dto is null)
|
|
{
|
|
status = NestDefaultsStatus.Invalid;
|
|
return defaults;
|
|
}
|
|
|
|
status = NestDefaultsStatus.Ok;
|
|
|
|
if (TryParseUnits(dto.Units, out var units))
|
|
defaults.Units = units;
|
|
|
|
if (
|
|
dto.Size?.Width is { } width
|
|
&& dto.Size.Length is { } length
|
|
&& IsValidSize(width, length)
|
|
)
|
|
defaults.Size = new Size(width, length);
|
|
|
|
if (dto.Quadrant is { } quadrant && quadrant is >= 1 and <= 4)
|
|
defaults.Quadrant = quadrant;
|
|
|
|
if (dto.PartSpacing is { } partSpacing && IsValidSpacing(partSpacing))
|
|
defaults.PartSpacing = partSpacing;
|
|
|
|
if (
|
|
dto.EdgeSpacing?.Left is { } left
|
|
&& dto.EdgeSpacing.Bottom is { } bottom
|
|
&& dto.EdgeSpacing.Right is { } right
|
|
&& dto.EdgeSpacing.Top is { } top
|
|
&& IsValidSpacing(left)
|
|
&& IsValidSpacing(bottom)
|
|
&& IsValidSpacing(right)
|
|
&& IsValidSpacing(top)
|
|
)
|
|
defaults.EdgeSpacing = new Spacing(left, bottom, right, top);
|
|
|
|
if (dto.MachineId is { ValueKind: JsonValueKind.String } machineId
|
|
&& Guid.TryParse(machineId.GetString(), out var machine) && machine != Guid.Empty)
|
|
defaults.MachineId = machine;
|
|
|
|
return defaults;
|
|
}
|
|
|
|
private static NestDefaults WithUnits(NestDefaults defaults, Units units)
|
|
{
|
|
if (Enum.IsDefined(units))
|
|
defaults.Units = units;
|
|
return defaults;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Captures the current units and plate defaults from a nest.
|
|
/// </summary>
|
|
public static NestDefaults FromNest(Nest nest)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(nest);
|
|
var plate = nest.PlateDefaults;
|
|
return new NestDefaults
|
|
{
|
|
Units = nest.Units,
|
|
Size = plate.Size,
|
|
Quadrant = plate.Quadrant,
|
|
PartSpacing = plate.PartSpacing,
|
|
EdgeSpacing = plate.EdgeSpacing,
|
|
MachineId = nest.Machine?.Id,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Captures defaults from an existing plate (a copy of its size,
|
|
/// quadrant, and spacing), e.g. the active plate in the desktop app.
|
|
/// </summary>
|
|
public static NestDefaults FromPlate(Units units, Plate plate)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(plate);
|
|
return new NestDefaults
|
|
{
|
|
Units = units,
|
|
Size = plate.Size,
|
|
Quadrant = plate.Quadrant,
|
|
PartSpacing = plate.PartSpacing,
|
|
EdgeSpacing = plate.EdgeSpacing,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// These defaults in <paramref name="units"/>: plate size and spacings are converted
|
|
/// (25.4 mm per inch) and rounded to four decimals; the quadrant is kept. Returns a copy
|
|
/// even when the units already match.
|
|
/// </summary>
|
|
public NestDefaults ConvertedTo(Units units)
|
|
{
|
|
var factor = units == Units ? 1.0 : units == Units.Millimeters ? MillimetersPerInch : 1 / MillimetersPerInch;
|
|
double Convert(double value) => System.Math.Round(value * factor, 4, MidpointRounding.AwayFromZero);
|
|
return new NestDefaults
|
|
{
|
|
Units = units,
|
|
Size = new Size(Convert(Size.Width), Convert(Size.Length)),
|
|
Quadrant = Quadrant,
|
|
PartSpacing = Convert(PartSpacing),
|
|
EdgeSpacing = new Spacing(Convert(EdgeSpacing.Left), Convert(EdgeSpacing.Bottom),
|
|
Convert(EdgeSpacing.Right), Convert(EdgeSpacing.Top)),
|
|
MachineId = MachineId,
|
|
};
|
|
}
|
|
|
|
private const double MillimetersPerInch = 25.4;
|
|
|
|
public void ApplyTo(Nest nest)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(nest);
|
|
nest.Units = Units;
|
|
var plate = nest.PlateDefaults;
|
|
plate.Size = Size;
|
|
plate.Quadrant = Quadrant;
|
|
plate.PartSpacing = PartSpacing;
|
|
plate.EdgeSpacing = EdgeSpacing;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the file (creating the parent directory), retrying briefly on
|
|
/// IO collisions the same way <see cref="LocalJsonProvider"/> does.
|
|
/// </summary>
|
|
public void Save(string path, int maxRetries = 3)
|
|
{
|
|
var json = ToJson();
|
|
|
|
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
|
if (!string.IsNullOrEmpty(directory))
|
|
Directory.CreateDirectory(directory);
|
|
|
|
for (var attempt = 0; attempt < maxRetries; attempt++)
|
|
{
|
|
try
|
|
{
|
|
File.WriteAllText(path, json);
|
|
return;
|
|
}
|
|
catch (IOException) when (attempt < maxRetries - 1)
|
|
{
|
|
Thread.Sleep(100);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>The defaults as <see cref="Save"/> writes them, for the shared copy.</summary>
|
|
public string ToJson()
|
|
{
|
|
var dto = new NestDefaultsDto
|
|
{
|
|
Version = CurrentVersion,
|
|
Units = JsonSerializer.SerializeToElement(Units.ToString().ToLowerInvariant()),
|
|
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
|
|
Quadrant = Quadrant,
|
|
PartSpacing = PartSpacing,
|
|
EdgeSpacing = new SpacingDto
|
|
{
|
|
Left = EdgeSpacing.Left,
|
|
Bottom = EdgeSpacing.Bottom,
|
|
Right = EdgeSpacing.Right,
|
|
Top = EdgeSpacing.Top,
|
|
},
|
|
MachineId = MachineId is { } machineId ? JsonSerializer.SerializeToElement(machineId.ToString()) : null,
|
|
};
|
|
|
|
return JsonSerializer.Serialize(dto, JsonOptions);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accepts only a defined unit name (case-insensitive). Enum.TryParse
|
|
/// would also accept numeric strings ("7") and comma-joined names,
|
|
/// producing undefined or unintended values.
|
|
/// </summary>
|
|
private static bool TryParseUnits(JsonElement? element, out Units units)
|
|
{
|
|
units = default;
|
|
if (element is not { ValueKind: JsonValueKind.String } value)
|
|
return false;
|
|
|
|
var text = value.GetString();
|
|
foreach (var candidate in Enum.GetValues<Units>())
|
|
{
|
|
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
units = candidate;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool IsUnreadableFile(Exception ex) =>
|
|
ex
|
|
is JsonException
|
|
or IOException
|
|
or UnauthorizedAccessException
|
|
or NotSupportedException
|
|
or System.Security.SecurityException;
|
|
|
|
private static bool IsValidSize(double width, double length) =>
|
|
!double.IsNaN(width)
|
|
&& !double.IsNaN(length)
|
|
&& !double.IsInfinity(width)
|
|
&& !double.IsInfinity(length)
|
|
&& width > 0
|
|
&& length > 0;
|
|
|
|
private static bool IsValidSpacing(double value) =>
|
|
!double.IsNaN(value) && !double.IsInfinity(value) && value >= 0;
|
|
|
|
/// <summary>
|
|
/// Wire format. Every field is nullable so a partial file merges over
|
|
/// the fallback field by field; unknown fields (including a future
|
|
/// higher <c>version</c>) are ignored rather than rejected.
|
|
/// </summary>
|
|
private sealed record NestDefaultsDto
|
|
{
|
|
public int? Version { get; init; } = CurrentVersion;
|
|
// Read as raw JSON so a wrongly typed unit falls back on its own
|
|
// instead of failing the whole file.
|
|
public JsonElement? Units { get; init; }
|
|
public SizeDto? Size { get; init; }
|
|
public int? Quadrant { get; init; }
|
|
public double? PartSpacing { get; init; }
|
|
public SpacingDto? EdgeSpacing { get; init; }
|
|
// Raw JSON for the same reason as Units.
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
|
public JsonElement? MachineId { get; init; }
|
|
}
|
|
|
|
private sealed record SizeDto
|
|
{
|
|
public double? Width { get; init; }
|
|
public double? Length { get; init; }
|
|
}
|
|
|
|
private sealed record SpacingDto
|
|
{
|
|
public double? Left { get; init; }
|
|
public double? Bottom { get; init; }
|
|
public double? Right { get; init; }
|
|
public double? Top { get; init; }
|
|
}
|
|
}
|