mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-05 18:52:11 -04:00
Enum.TryParse accepted numeric strings ("7" loaded an undefined unit,
"1" loaded millimeters) and comma-joined names, and a JSON number for
units failed the whole file. Units now load only from a defined unit
name; anything else falls back for that field alone, while the other
valid fields still load.
Add Load(path, fallbackUnits, out status) so callers can keep their
existing unit preference when the file is missing, unusable, or lacks a
usable unit (previously a readable file without units silently became
inches). An undefined fallback is ignored in favour of the built-in
units.
The numeric/comma/JSON-number rows failed against the previous commit;
dropping the fallback assignment or its IsDefined guard fails the new
fallback tests.
316 lines
10 KiB
C#
316 lines
10 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 built-in defaults used when no file exists and for every field
|
|
/// that is missing or invalid. Matches the historical
|
|
/// MainForm.CreateDefaultNest 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
|
|
)
|
|
{
|
|
var defaults = Fallback;
|
|
if (Enum.IsDefined(fallbackUnits))
|
|
defaults.Units = fallbackUnits;
|
|
|
|
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
|
{
|
|
status = NestDefaultsStatus.Missing;
|
|
return defaults;
|
|
}
|
|
|
|
NestDefaultsDto? dto;
|
|
try
|
|
{
|
|
var json = File.ReadAllText(path);
|
|
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json, JsonOptions);
|
|
}
|
|
catch (Exception ex) when (IsUnreadableFile(ex))
|
|
{
|
|
// A file that exists but cannot be read (locked, access-denied)
|
|
// or 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);
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/// <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,
|
|
};
|
|
}
|
|
|
|
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 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,
|
|
},
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
|
|
|
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>
|
|
/// 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; }
|
|
}
|
|
|
|
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; }
|
|
}
|
|
}
|