diff --git a/OpenNest.Desktop.Tests/FakeSettingsServer.cs b/OpenNest.Desktop.Tests/FakeSettingsServer.cs
new file mode 100644
index 00000000..bd194d0f
--- /dev/null
+++ b/OpenNest.Desktop.Tests/FakeSettingsServer.cs
@@ -0,0 +1,93 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using OpenNest.Data;
+
+namespace OpenNest.Desktop.Tests;
+
+///
+/// A nest server's /api/settings in memory: lists, creates and replaces documents, or refuses
+/// every request as an unreachable server while is false.
+///
+internal sealed class FakeSettingsServer : HttpMessageHandler
+{
+ public const string Url = "http://settings.test/";
+
+ // The server's JSON shape: camel-case names and enum names.
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
+ };
+
+ public List Records { get; } = [];
+
+ public bool Online { get; set; } = true;
+
+ public int Reads { get; private set; }
+
+ public void Hold(string kind, string key, string json) =>
+ Records.Add(new SharedSettingRecord { Kind = kind, Key = key, Json = json, Revision = 1, SavedAt = DateTime.Now });
+
+ /// A source in Database mode on this server, keeping its last copy in .
+ public SharedSettingsSource Source(string cachePath) =>
+ new(() => new NestStorageSettings { Mode = NestStorageMode.Database, ServerUrl = Url },
+ url => new SharedSettingsStore(new RemoteSharedSettings(new HttpClient(this, disposeHandler: false), url), cachePath, "tester"));
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (!Online)
+ throw new HttpRequestException("No route to the nest server.");
+
+ var path = request.RequestUri!.AbsolutePath.Trim('/').Split('/').Select(Uri.UnescapeDataString).ToArray();
+ if (request.Method == HttpMethod.Get && path is ["api", "settings"])
+ {
+ Reads++;
+ return JsonResponse(HttpStatusCode.OK, Records);
+ }
+ if (path is ["api", "settings", var kind, var key] && (request.Method == HttpMethod.Post || request.Method == HttpMethod.Put))
+ {
+ var write = await request.Content!.ReadFromJsonAsync(JsonOptions, cancellationToken);
+ var current = Records.FirstOrDefault(r => r.Kind == kind && r.Key == key);
+ var saved = new SharedSettingRecord
+ {
+ Kind = kind,
+ Key = key,
+ Json = write!.Json,
+ Revision = (current?.Revision ?? 0) + 1,
+ SavedAt = DateTime.Now,
+ SavedBy = write.SavedBy,
+ };
+ if (current != null)
+ Records.Remove(current);
+ Records.Add(saved);
+ return JsonResponse(current == null ? HttpStatusCode.Created : HttpStatusCode.OK, saved);
+ }
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }
+
+ private static HttpResponseMessage JsonResponse(HttpStatusCode status, T body) =>
+ new(status) { Content = JsonContent.Create(body, options: JsonOptions) };
+}
+
+/// The two machine user settings in memory.
+internal sealed class MemoryUserSettings : IMachineUserSettings
+{
+ public Units DefaultUnit { get; set; } = Units.Inches;
+
+ public string CuttingParametersJson { get; set; } = "";
+
+ public int Saves { get; private set; }
+
+ string IMachineUserSettings.CuttingParametersJson
+ {
+ get => CuttingParametersJson;
+ set
+ {
+ CuttingParametersJson = value;
+ Saves++;
+ }
+ }
+}
diff --git a/OpenNest.Desktop.Tests/MachineSettingsSourceTests.cs b/OpenNest.Desktop.Tests/MachineSettingsSourceTests.cs
new file mode 100644
index 00000000..31ae285d
--- /dev/null
+++ b/OpenNest.Desktop.Tests/MachineSettingsSourceTests.cs
@@ -0,0 +1,332 @@
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Data;
+
+namespace OpenNest.Desktop.Tests;
+
+///
+/// The shared machine entry points: the legacy clamps moving onto a machine once, cutting
+/// settings saved onto the nest's machine thickness or this PC, and the nest defaults.
+///
+public sealed class MachineSettingsSourceTests : IDisposable
+{
+ private readonly string directory = Path.Combine(Path.GetTempPath(), "OpenNestDesktopTests", Guid.NewGuid().ToString("N"));
+ private readonly FakeSettingsServer server = new();
+ private readonly MemoryUserSettings user = new();
+
+ public MachineSettingsSourceTests() => Directory.CreateDirectory(directory);
+
+ public void Dispose()
+ {
+ try { Directory.Delete(directory, recursive: true); } catch (IOException) { }
+ }
+
+ private string MachinesDirectory => Path.Combine(directory, "Machines");
+
+ private string DefaultsPath => Path.Combine(directory, "defaults.json");
+
+ private string ClampsPath => Path.Combine(directory, "clamps.json");
+
+ private static SharedSettingsSource FileMode() =>
+ new(() => new NestStorageSettings(), _ => throw new InvalidOperationException("File mode never reaches the server."));
+
+ private MachineSettingsSource Machines(SharedSettingsSource? shared = null) =>
+ new(shared ?? FileMode(), user, MachinesDirectory, DefaultsPath, ClampsPath, Path.Combine(directory, "grid.json"));
+
+ private MachineConfig SaveLocalMachine(string name)
+ {
+ var machine = new MachineConfig { Name = name };
+ new LocalJsonProvider(MachinesDirectory).SaveMachine(machine);
+ return machine;
+ }
+
+ private MachineConfig Reload(MachineConfig machine) => new LocalJsonProvider(MachinesDirectory).GetMachine(machine.Id)!;
+
+ private static ClampSettings Legacy() => new()
+ {
+ Enabled = true,
+ Units = Units.Inches,
+ Width = 2,
+ Depth = 0.5,
+ RemovalMinutes = 4,
+ Centers = [12, 24],
+ };
+
+ [Fact]
+ public void LegacyClamps_GoOntoTheOnlyMachineOnce()
+ {
+ var only = SaveLocalMachine("Only");
+ Legacy().Save(ClampsPath);
+ var machines = Machines();
+
+ Assert.Null(machines.MigrateLegacyClamps(_ => throw new InvalidOperationException("Nothing to ask.")));
+
+ Assert.Equal([12.0, 24.0], Reload(only).Clamps!.Centers);
+ // Once a machine has clamps the legacy file is never read again.
+ File.Delete(ClampsPath);
+ new ClampSettings { Enabled = true, Units = Units.Inches, Width = 1, Depth = 1, Centers = [99] }.Save(ClampsPath);
+ Assert.Null(machines.MigrateLegacyClamps(null!));
+ Assert.Equal([12.0, 24.0], Reload(only).Clamps!.Centers);
+ }
+
+ [Fact]
+ public void LegacyClamps_GoOntoTheNestDefaultsMachine()
+ {
+ var a = SaveLocalMachine("A");
+ var b = SaveLocalMachine("B");
+ new NestDefaults { MachineId = b.Id }.Save(DefaultsPath);
+ Legacy().Save(ClampsPath);
+
+ Assert.Null(Machines().MigrateLegacyClamps(_ => throw new InvalidOperationException("Nothing to ask.")));
+
+ Assert.Null(Reload(a).Clamps);
+ Assert.NotNull(Reload(b).Clamps);
+ }
+
+ [Fact]
+ public void LegacyClamps_WithSeveralMachinesAndNoDefault_AskOncePerSession()
+ {
+ var a = SaveLocalMachine("A");
+ var b = SaveLocalMachine("B");
+ Legacy().Save(ClampsPath);
+ var machines = Machines();
+ var asked = new List>();
+
+ machines.MigrateLegacyClamps(choices => { asked.Add(choices.Select(m => m.Name).ToList()); return null; });
+ machines.MigrateLegacyClamps(choices => { asked.Add(choices.Select(m => m.Name).ToList()); return choices[1]; });
+
+ Assert.Equal([["A", "B"]], asked);
+ Assert.Null(Reload(a).Clamps);
+ Assert.Null(Reload(b).Clamps);
+
+ // The next session asks again, and the machine picked gets the clamps.
+ Assert.Null(Machines().MigrateLegacyClamps(choices => choices[1]));
+ Assert.Null(Reload(a).Clamps);
+ Assert.NotNull(Reload(b).Clamps);
+ }
+
+ [Fact]
+ public void LegacyClamps_ComeFromTheServersCopyInDatabaseMode()
+ {
+ var only = new MachineConfig { Name = "Shared Laser" };
+ server.Hold(SharedSettingKinds.Machine, only.Id.ToString("D"), SharedMachineProvider.Serialize(only));
+ server.Hold(SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey, Legacy().ToJson());
+ var machines = Machines(server.Source(Path.Combine(directory, "shared.json")));
+
+ Assert.Null(machines.MigrateLegacyClamps(null!));
+
+ var stored = server.Records.Single(r => r.Kind == SharedSettingKinds.Machine);
+ Assert.Equal(2, stored.Revision);
+ Assert.NotNull(machines.LoadMachines().Single().Clamps);
+ }
+
+ [Fact]
+ public void LegacyClamps_ThatCannotBeSavedAreReported()
+ {
+ var only = new MachineConfig { Name = "Shared Laser" };
+ server.Hold(SharedSettingKinds.Machine, only.Id.ToString("D"), SharedMachineProvider.Serialize(only));
+ Legacy().Save(ClampsPath);
+ var shared = server.Source(Path.Combine(directory, "shared.json"));
+ var machines = Machines(shared);
+ shared.Current(refresh: true);
+ server.Online = false;
+
+ var warning = machines.MigrateLegacyClamps(null!);
+
+ Assert.Contains("could not be copied to a machine", warning);
+ Assert.Equal(1, server.Records.Single().Revision);
+ }
+
+ private static MachineConfig CuttingMachine() => new()
+ {
+ Name = "Fiber 1",
+ Materials =
+ {
+ new MaterialConfig
+ {
+ Name = "Mild Steel",
+ Grade = "A36",
+ Thicknesses = { new ThicknessConfig { Value = 0.25, LeadIn = new LeadConfig { Type = "Line", Length = 0.1, Angle = 60 } } },
+ },
+ },
+ };
+
+ private static Nest NestFor(MachineConfig machine, double thickness) => new()
+ {
+ Thickness = thickness,
+ Material = new Material("Mild Steel", "A36", 0.2836),
+ Machine = new NestMachine(machine.Id, machine.Name),
+ };
+
+ private static CuttingParameters Edited() => new()
+ {
+ ExternalLeadIn = new LineLeadIn { Length = 0.2, ApproachAngle = 45 },
+ SupportTabs = true,
+ TabBySize = true,
+ TabSizeMaxWidth = 2,
+ TabConfig = new NormalTab { Size = 0.03 },
+ };
+
+ [Fact]
+ public void CuttingSettings_SaveOntoTheMachinesThicknessAndDropThisPcsCopyForThatJob()
+ {
+ var machine = CuttingMachine();
+ var provider = new LocalJsonProvider(MachinesDirectory);
+ provider.SaveMachine(machine);
+ var machines = Machines();
+ var nest = NestFor(machine, 0.25);
+ var stale = new CuttingParameters();
+ MachineCuttingDefaults.ApplyTo(stale, machine, nest);
+ machines.SaveLastCuttingParameters(stale);
+ var warnings = new List();
+
+ Assert.True(machines.SaveCuttingParameters(nest, Edited(), warnings.Add));
+
+ Assert.Empty(warnings);
+ var thickness = Reload(machine).Materials[0].Thicknesses[0];
+ Assert.Equal(0.2, thickness.LeadIn.Length);
+ Assert.True(thickness.Cutting!.TabBySize);
+ Assert.Equal(2, thickness.Cutting.TabSizeMaxWidth);
+ Assert.False(MachineCuttingDefaults.IsRecordedFor(machines.LastCuttingParameters(), machine, nest));
+ Assert.True(machines.HasMachineThickness(nest));
+ Assert.Equal("Saved for Fiber 1 · Mild Steel A36 0.25 in", machines.DescribeCuttingSource(nest));
+ // The machine's thickness is what the next plate starts from.
+ Assert.Equal(0.2, ((LineLeadIn)machines.DefaultCuttingParameters(nest).ExternalLeadIn).Length);
+ }
+
+ [Fact]
+ public void CuttingSettings_SaveOnThisPcWhenTheMachineHasNoSuchThickness()
+ {
+ var machine = CuttingMachine();
+ new LocalJsonProvider(MachinesDirectory).SaveMachine(machine);
+ var machines = Machines();
+ var nest = NestFor(machine, 0.5);
+
+ Assert.False(machines.SaveCuttingParameters(nest, Edited(), _ => Assert.Fail("No warning expected.")));
+
+ Assert.Null(Reload(machine).Materials[0].Thicknesses[0].Cutting);
+ Assert.True(machines.LastCuttingParameters().TabBySize);
+ Assert.Null(machines.LastCuttingParameters().MachineName);
+ Assert.Equal("This PC's settings: Fiber 1 has no Mild Steel A36 0.5 in.", machines.DescribeCuttingSource(nest));
+ }
+
+ [Fact]
+ public void CuttingSettings_SaveOnThisPcWhenTheNestHasNoMachine()
+ {
+ var machines = Machines();
+ var nest = new Nest { Thickness = 0.25, Material = new Material("Mild Steel", "A36", 0.2836) };
+
+ Assert.False(machines.SaveCuttingParameters(nest, Edited(), _ => Assert.Fail("No warning expected.")));
+
+ Assert.True(machines.LastCuttingParameters().TabBySize);
+ Assert.Equal("This PC's settings: the nest has no machine.", machines.DescribeCuttingSource(nest));
+ }
+
+ [Fact]
+ public void CuttingSettings_KeptOnThisPcForThatJobWhenTheServerIsOffline()
+ {
+ var machine = CuttingMachine();
+ server.Hold(SharedSettingKinds.Machine, machine.Id.ToString("D"), SharedMachineProvider.Serialize(machine));
+ var shared = server.Source(Path.Combine(directory, "shared.json"));
+ var machines = Machines(shared);
+ var nest = NestFor(machine, 0.25);
+ shared.Current(refresh: true);
+ server.Online = false;
+ var warnings = new List();
+
+ Assert.False(machines.SaveCuttingParameters(nest, Edited(), warnings.Add));
+
+ Assert.Contains("could not be saved to Fiber 1", Assert.Single(warnings));
+ Assert.Equal(1, server.Records.Single().Revision);
+ var last = machines.LastCuttingParameters();
+ Assert.True(MachineCuttingDefaults.IsRecordedFor(last, machine, nest));
+ Assert.True(last.TabBySize);
+ // Recorded for the job, so the machine's settings do not replace the edit here.
+ Assert.Equal(0.2, ((LineLeadIn)machines.DefaultCuttingParameters(nest).ExternalLeadIn).Length);
+ }
+
+ [Fact]
+ public void CuttingParametersFor_UsesThePlatesOwnOnlyWhenNoneAreSaved()
+ {
+ var machines = Machines();
+ var plate = new Plate { CuttingParameters = new CuttingParameters { TabBySize = true } };
+ var nest = new Nest();
+
+ Assert.Same(plate.CuttingParameters, machines.CuttingParametersFor(plate, nest));
+
+ machines.SaveLastCuttingParameters(new CuttingParameters());
+ Assert.NotSame(plate.CuttingParameters, machines.CuttingParametersFor(plate, nest));
+ }
+
+ [Fact]
+ public void NestDefaults_FallBackToTheDefaultUnitAndAnUnreadableFileWarnsOnce()
+ {
+ user.DefaultUnit = Units.Millimeters;
+ var machines = Machines();
+
+ Assert.Equal(Units.Millimeters, machines.LoadNestDefaults(out var none).Units);
+ Assert.Null(none);
+
+ File.WriteAllText(DefaultsPath, "{ not json");
+ machines.LoadNestDefaults(out var first);
+ machines.LoadNestDefaults(out var second);
+
+ Assert.Contains(DefaultsPath, first);
+ Assert.Null(second);
+ }
+
+ [Fact]
+ public void DefaultMachine_IsTheDefaultsMachineElseTheFirstByName()
+ {
+ var b = new MachineConfig { Name = "b" };
+ var a = new MachineConfig { Name = "A" };
+
+ Assert.Same(a, MachineSettingsSource.DefaultMachine(new NestDefaults(), [b, a]));
+ Assert.Same(b, MachineSettingsSource.DefaultMachine(new NestDefaults { MachineId = b.Id }, [b, a]));
+ Assert.Null(MachineSettingsSource.DefaultMachine(null!, []));
+ }
+
+ [Fact]
+ public void LocalMachines_StartWithTheBuiltInMachine()
+ {
+ var loaded = Machines().LoadMachines();
+
+ Assert.NotEmpty(loaded);
+ Assert.True(Directory.GetFiles(MachinesDirectory, "*.json").Length > 0);
+ }
+
+ [Fact]
+ public void UserSettingsFile_KeepsBothSettingsUnderTheWinFormsNames()
+ {
+ var path = Path.Combine(directory, "user-settings.json");
+ UserSettingsFile.Write(path, new Dictionary { ["DefaultUnit"] = "Millimeters", ["Other"] = "kept" });
+ var settings = new MachineUserSettingsFile(path);
+
+ Assert.Equal(Units.Millimeters, settings.DefaultUnit);
+ Assert.Equal("", settings.CuttingParametersJson);
+
+ settings.CuttingParametersJson = "{\"a\":1}";
+
+ var values = UserSettingsFile.Read(path);
+ Assert.Equal("{\"a\":1}", values["CuttingParametersJson"]);
+ Assert.Equal("kept", values["Other"]);
+ Assert.Equal("{\"a\":1}", settings.CuttingParametersJson);
+
+ UserSettingsFile.Write(path, new Dictionary { ["DefaultUnit"] = "Furlongs" });
+ Assert.Equal(Units.Inches, settings.DefaultUnit);
+ UserSettingsFile.Write(path, new Dictionary { ["DefaultUnit"] = "7" });
+ Assert.Equal(Units.Inches, settings.DefaultUnit);
+ }
+
+ [Fact]
+ public void CutOffDefaults_ComeFromTheNestsMachineThickness()
+ {
+ var machine = CuttingMachine();
+ machine.Materials[0].Thicknesses[0].CutOff = new CutOffConfig { PartClearance = 0.75 };
+ new LocalJsonProvider(MachinesDirectory).SaveMachine(machine);
+ var settings = new CutOffSettings { PartClearance = 0.1 };
+
+ Machines().ApplyCutOffDefaults(settings, NestFor(machine, 0.25));
+
+ Assert.Equal(0.75, settings.PartClearance);
+ }
+}
diff --git a/OpenNest.Desktop.Tests/SharedSettingsSourceTests.cs b/OpenNest.Desktop.Tests/SharedSettingsSourceTests.cs
new file mode 100644
index 00000000..050da500
--- /dev/null
+++ b/OpenNest.Desktop.Tests/SharedSettingsSourceTests.cs
@@ -0,0 +1,299 @@
+using OpenNest.Data;
+
+namespace OpenNest.Desktop.Tests;
+
+///
+/// Which copy of the machines, nest defaults and post settings the desktop uses: the nest
+/// server's once it holds one in Database mode, otherwise this PC's; the last copy read,
+/// read-only, while the server cannot be reached.
+///
+public sealed class SharedSettingsSourceTests : IDisposable
+{
+ private readonly string directory = Path.Combine(Path.GetTempPath(), "OpenNestDesktopTests", Guid.NewGuid().ToString("N"));
+ private readonly FakeSettingsServer server = new();
+ private readonly MemoryUserSettings user = new();
+
+ public SharedSettingsSourceTests() => Directory.CreateDirectory(directory);
+
+ public void Dispose()
+ {
+ try { Directory.Delete(directory, recursive: true); } catch (IOException) { }
+ }
+
+ private string CachePath => Path.Combine(directory, "shared.json");
+
+ private string MachinesDirectory => Path.Combine(directory, "Machines");
+
+ private string DefaultsPath => Path.Combine(directory, "defaults.json");
+
+ private MachineSettingsSource Machines(SharedSettingsSource shared) =>
+ new(shared, user, MachinesDirectory, DefaultsPath, Path.Combine(directory, "clamps.json"), Path.Combine(directory, "grid.json"));
+
+ private static SharedSettingsSource FileMode() =>
+ new(() => new NestStorageSettings { Mode = NestStorageMode.File, ServerUrl = FakeSettingsServer.Url },
+ _ => throw new InvalidOperationException("File mode never reaches the server."));
+
+ private MachineConfig SaveLocalMachine(string name)
+ {
+ var machine = new MachineConfig { Name = name };
+ new LocalJsonProvider(MachinesDirectory).SaveMachine(machine);
+ return machine;
+ }
+
+ private MachineConfig HoldServerMachine(string name)
+ {
+ var machine = new MachineConfig { Name = name };
+ server.Hold(SharedSettingKinds.Machine, machine.Id.ToString("D"), SharedMachineProvider.Serialize(machine));
+ return machine;
+ }
+
+ private void HoldServerDefaults(double partSpacing) =>
+ server.Hold(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey,
+ new NestDefaults { PartSpacing = partSpacing }.ToJson());
+
+ [Fact]
+ public void FileMode_UsesThisPcsMachinesAndDefaults()
+ {
+ SaveLocalMachine("Local Laser");
+ new NestDefaults { PartSpacing = 2 }.Save(DefaultsPath);
+ var shared = FileMode();
+ var machines = Machines(shared);
+
+ Assert.Null(shared.Current(refresh: true));
+ Assert.Equal(["Local Laser"], machines.LoadMachines().Select(m => m.Name));
+ Assert.Equal(2, machines.LoadSavedNestDefaults(out _).PartSpacing);
+ Assert.Equal("", shared.MachineSource(machines.CreateMachineProvider()));
+ Assert.Equal(("Saved on this PC.", false), shared.NestDefaultsSource());
+ }
+
+ [Fact]
+ public void DatabaseMode_TheServersMachinesReplaceThisPcsOnceItHoldsOne()
+ {
+ SaveLocalMachine("Local Laser");
+ HoldServerMachine("Shared Laser");
+ var shared = server.Source(CachePath);
+ var machines = Machines(shared);
+
+ Assert.Equal(["Shared Laser"], machines.LoadMachines().Select(m => m.Name));
+ Assert.Equal(["Local Laser"], machines.LoadLocalMachines().Select(m => m.Name));
+ Assert.IsType(machines.CreateMachineProvider());
+ Assert.Equal("shared on the nest server", shared.MachineSource(machines.CreateMachineProvider()));
+ }
+
+ [Fact]
+ public void DatabaseMode_UsesThisPcsMachinesUntilTheServerHoldsOne()
+ {
+ SaveLocalMachine("Local Laser");
+ HoldServerDefaults(3);
+ var shared = server.Source(CachePath);
+ var machines = Machines(shared);
+
+ Assert.Equal(["Local Laser"], machines.LoadMachines().Select(m => m.Name));
+ Assert.IsType(machines.CreateMachineProvider());
+ Assert.Equal("this PC only: not shared on the server yet", shared.MachineSource(machines.CreateMachineProvider()));
+ }
+
+ [Fact]
+ public void DatabaseMode_TheServersNestDefaultsWinAndSavesGoBackToIt()
+ {
+ new NestDefaults { PartSpacing = 2 }.Save(DefaultsPath);
+ HoldServerDefaults(3);
+ var shared = server.Source(CachePath);
+ var machines = Machines(shared);
+
+ Assert.Equal(3, machines.LoadSavedNestDefaults(out _).PartSpacing);
+ Assert.Equal(2, machines.LoadLocalNestDefaults().PartSpacing);
+ Assert.Equal(("Shared with every workstation on the nest server.", false), shared.NestDefaultsSource());
+
+ machines.SaveNestDefaults(new NestDefaults { PartSpacing = 4 });
+
+ var stored = server.Records.Single(r => r.Kind == SharedSettingKinds.NestDefaults);
+ Assert.Equal(4, NestDefaults.Parse(stored.Json, Units.Inches, out _).PartSpacing);
+ Assert.Equal(2, NestDefaults.Load(DefaultsPath).PartSpacing);
+ }
+
+ [Fact]
+ public void DatabaseMode_NestDefaultsTheServerDoesNotHoldSaveOnThisPc()
+ {
+ var shared = server.Source(CachePath);
+ var machines = Machines(shared);
+
+ Assert.Equal(("Saved on this PC only: not shared on the server yet.", false), shared.NestDefaultsSource());
+ machines.SaveNestDefaults(new NestDefaults { PartSpacing = 5 });
+
+ Assert.Equal(5, NestDefaults.Load(DefaultsPath).PartSpacing);
+ Assert.DoesNotContain(server.Records, r => r.Kind == SharedSettingKinds.NestDefaults);
+ }
+
+ [Fact]
+ public void Offline_UsesTheLastCopyReadReadOnlyAndSaysSoOnce()
+ {
+ SaveLocalMachine("Local Laser");
+ HoldServerMachine("Shared Laser");
+ HoldServerDefaults(3);
+ // An earlier session read the server, leaving its copy on this PC.
+ Assert.NotNull(server.Source(CachePath).Current(refresh: true));
+ server.Online = false;
+
+ var shared = server.Source(CachePath);
+ var machines = Machines(shared);
+
+ Assert.Equal(["Shared Laser"], machines.LoadMachines().Select(m => m.Name));
+ Assert.Equal(3, machines.LoadSavedNestDefaults(out _).PartSpacing);
+ Assert.True(shared.IsOffline);
+ Assert.Equal("shared, read-only: the server cannot be reached", shared.MachineSource(machines.CreateMachineProvider()));
+ Assert.Equal(("Shared, read-only: the server cannot be reached.", true), shared.NestDefaultsSource());
+ Assert.Throws(() => machines.SaveNestDefaults(new NestDefaults { PartSpacing = 4 }));
+ Assert.False(File.Exists(DefaultsPath));
+
+ var warning = shared.TakeOfflineWarning();
+ Assert.Contains("the copy read", warning);
+ Assert.Contains("cannot be changed", warning);
+ Assert.Null(shared.TakeOfflineWarning());
+ }
+
+ [Fact]
+ public void Offline_WithNoCopyReadYet_UsesThisPcsOwn()
+ {
+ SaveLocalMachine("Local Laser");
+ server.Online = false;
+ var shared = server.Source(CachePath);
+ var machines = Machines(shared);
+
+ Assert.Equal(["Local Laser"], machines.LoadMachines().Select(m => m.Name));
+ Assert.Contains("this PC's own settings", shared.TakeOfflineWarning());
+ }
+
+ [Fact]
+ public void TheServersCopyIsReReadWhenOlderThanTwoMinutesAndOfflineRetriedOnceAMinute()
+ {
+ // The store stamps each read with the real clock; the source sees this much time since it.
+ var later = TimeSpan.Zero;
+ var shared = new SharedSettingsSource(
+ () => new NestStorageSettings { Mode = NestStorageMode.Database, ServerUrl = FakeSettingsServer.Url },
+ url => new SharedSettingsStore(new RemoteSharedSettings(new HttpClient(server, false), url), CachePath, "tester"),
+ () => DateTime.Now + later);
+
+ shared.Current();
+ later = TimeSpan.FromSeconds(110);
+ shared.Current();
+ Assert.Equal(1, server.Reads);
+
+ later = TimeSpan.FromSeconds(130);
+ shared.Current();
+ Assert.Equal(2, server.Reads);
+
+ later = TimeSpan.Zero;
+ shared.Current(refresh: true);
+ Assert.Equal(3, server.Reads);
+
+ // Offline: retried after a minute rather than two.
+ server.Online = false;
+ shared.Current(refresh: true);
+ server.Online = true;
+ later = TimeSpan.FromSeconds(50);
+ shared.Current();
+ Assert.Equal(3, server.Reads);
+ later = TimeSpan.FromSeconds(70);
+ shared.Current();
+ Assert.Equal(4, server.Reads);
+ }
+
+ [Fact]
+ public void PendingUploads_ListWhatTheServerLacksAndNeverReplaceWhatItHolds()
+ {
+ SaveLocalMachine("Local Laser");
+ new NestDefaults { PartSpacing = 2 }.Save(DefaultsPath);
+ var post = new JsonPost();
+ var shared = server.Source(CachePath);
+ var machines = Machines(shared);
+
+ var uploads = machines.PendingUploads(shared.Current(refresh: true), [post]);
+
+ Assert.Equal(["Machine: Local Laser", "Post settings: Json Post", "Nest defaults"], uploads.Select(u => u.Label));
+ Assert.Contains("• Machine: Local Laser", SharedSettingsSource.DescribeUploads(uploads));
+ Assert.Null(SharedSettingsSource.Upload(shared.Current(), uploads));
+ Assert.Equal(3, server.Records.Count);
+ Assert.IsType(machines.CreateMachineProvider());
+ Assert.Empty(machines.PendingUploads(shared.Current(refresh: true), [post]));
+ }
+
+ [Fact]
+ public void PendingUploads_SkipMachinesOnceTheServerHoldsAnyAndDefaultsThisPcHasNot()
+ {
+ SaveLocalMachine("Local Laser");
+ HoldServerMachine("Shared Laser");
+ var shared = server.Source(CachePath);
+ var machines = Machines(shared);
+
+ Assert.Empty(machines.PendingUploads(shared.Current(refresh: true), []));
+ }
+
+ [Fact]
+ public void PostSettings_TheServersCopyIsLoadedAndAnUnreadableOneStopsThePost()
+ {
+ var post = new JsonPost();
+ server.Hold(SharedSettingKinds.Post, SharedSettingsSource.PostKey(post), "{\"feed\":7}");
+ var shared = server.Source(CachePath);
+
+ Assert.Null(shared.LoadPostSettings(post));
+ Assert.Equal("{\"feed\":7}", post.Loaded);
+
+ post.Refuse = true;
+ Assert.Contains("could not be read, so nothing was posted", shared.LoadPostSettings(post));
+ }
+
+ [Fact]
+ public void PostSettings_SaveWhereTheyAreReadAndNotWhenUnchanged()
+ {
+ var post = new JsonPost { Json = "{\"feed\":1}" };
+ var shared = server.Source(CachePath);
+
+ shared.SavePostSettings(post, "{\"feed\":1}");
+ Assert.Equal(0, post.FileSaves);
+
+ shared.SavePostSettings(post, "{\"feed\":0}");
+ Assert.Equal(1, post.FileSaves);
+ Assert.Empty(server.Records);
+
+ server.Hold(SharedSettingKinds.Post, SharedSettingsSource.PostKey(post), "{\"feed\":0}");
+ shared.Current(refresh: true);
+ shared.SavePostSettings(post, "{\"feed\":0}");
+ Assert.Equal(1, post.FileSaves);
+ Assert.Equal("{\"feed\":1}", server.Records.Single().Json);
+ }
+
+ internal sealed class JsonPost : IJsonConfigPostProcessor
+ {
+ public string Json { get; set; } = "{}";
+
+ public string? Loaded { get; private set; }
+
+ public bool Refuse { get; set; }
+
+ public int FileSaves { get; private set; }
+
+ public string Name => "Json Post";
+
+ public string Author => "Tests";
+
+ public string Description => "A post sharing its settings as JSON.";
+
+ public object Config => Json;
+
+ public void Post(Nest nest, Stream outputStream) { }
+
+ public void Post(Nest nest, string outputFile) { }
+
+ public void SaveConfig() => FileSaves++;
+
+ public string SerializeConfig() => Json;
+
+ public void LoadConfig(string json)
+ {
+ if (Refuse)
+ throw new System.Text.Json.JsonException("Not this post's settings.");
+ Loaded = json;
+ }
+ }
+}
diff --git a/OpenNest.Desktop/MachineSettingsSource.cs b/OpenNest.Desktop/MachineSettingsSource.cs
new file mode 100644
index 00000000..4e1b5859
--- /dev/null
+++ b/OpenNest.Desktop/MachineSettingsSource.cs
@@ -0,0 +1,455 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Data;
+using OpenNest.IO;
+
+namespace OpenNest.Desktop;
+
+///
+/// The two per-user settings the machine entry points read: the legacy default unit, used
+/// when the nest defaults give none, and this PC's last cutting settings (saving one persists it).
+///
+public interface IMachineUserSettings
+{
+ /// The DefaultUnit setting.
+ Units DefaultUnit { get; }
+
+ /// The CuttingParametersJson setting; setting it saves it.
+ string CuttingParametersJson { get; set; }
+}
+
+///
+/// The desktop entry points for the persisted machines (with their clamps) and nest defaults.
+/// In Database mode each comes from the nest server once it holds a shared copy
+/// (), otherwise from this PC's files. Nothing here shows UI:
+/// the warning texts it returns are shown by the front end, at most once per session where
+/// noted.
+///
+public sealed class MachineSettingsSource
+{
+ private readonly IMachineUserSettings user;
+ private readonly string machinesDirectory;
+ private readonly string nestDefaultsPath;
+ private readonly string legacyClampsPath;
+ private readonly string legacySupportGridPath;
+ private bool defaultsWarned;
+ private bool clampMigrationAsked;
+
+ /// Where shared copies come from.
+ /// The per-user settings this reads and saves.
+ /// This PC's machines; defaults to %APPDATA%\OpenNest\Machines.
+ /// This PC's nest defaults; defaults to .
+ /// The pre-machine clamps.json; defaults to .
+ /// The pre-machine support-grid.json; defaults to .
+ public MachineSettingsSource(
+ SharedSettingsSource shared,
+ IMachineUserSettings user,
+ string machinesDirectory = null,
+ string nestDefaultsPath = null,
+ string legacyClampsPath = null,
+ string legacySupportGridPath = null)
+ {
+ Shared = shared ?? throw new ArgumentNullException(nameof(shared));
+ this.user = user ?? throw new ArgumentNullException(nameof(user));
+ this.machinesDirectory = machinesDirectory ?? DefaultMachinesDirectory;
+ this.nestDefaultsPath = nestDefaultsPath ?? NestDefaults.DefaultPath;
+ this.legacyClampsPath = legacyClampsPath ?? ClampSettings.DefaultPath;
+ this.legacySupportGridPath = legacySupportGridPath ?? SupportGridSettings.DefaultPath;
+ }
+
+ /// %APPDATA%\OpenNest\Machines.
+ public static string DefaultMachinesDirectory => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenNest",
+ "Machines");
+
+ /// Where shared copies come from.
+ public SharedSettingsSource Shared { get; }
+
+ /// This PC's nest defaults file.
+ public string NestDefaultsPath => nestDefaultsPath;
+
+ ///
+ /// The machine configurations edited in Tools > Machine Configuration: the server's when
+ /// it holds any, otherwise this PC's. re-reads the server first.
+ ///
+ public IDataProvider CreateMachineProvider(bool refresh = false) =>
+ Shared.Holding(SharedSettingKinds.Machine, refresh) is { } shared
+ ? new SharedMachineProvider(shared)
+ : CreateLocalMachineProvider();
+
+ /// This PC's machine configurations, with the built-in ones written on first use.
+ public LocalJsonProvider CreateLocalMachineProvider()
+ {
+ var provider = new LocalJsonProvider(machinesDirectory);
+ provider.EnsureDefaults();
+ return provider;
+ }
+
+ /// Every readable machine configuration, for choosing acceleration limits.
+ public List LoadMachines() => ReadMachines(() => CreateMachineProvider());
+
+ /// Every readable machine configuration on this PC, shared or not, for uploading.
+ public List LoadLocalMachines() => ReadMachines(CreateLocalMachineProvider);
+
+ private static List ReadMachines(Func createProvider)
+ {
+ var machines = new List();
+ try
+ {
+ var provider = createProvider();
+ foreach (var summary in provider.GetMachines())
+ {
+ if (provider.GetMachine(summary.Id) is { } machine)
+ machines.Add(machine);
+ }
+ }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ return machines;
+ }
+
+ /// Every material and grade on file across the readable machine configurations.
+ public IEnumerable LoadMaterials() =>
+ LoadMachines().SelectMany(machine => machine.Materials);
+
+ ///
+ /// The clamps new nests assigned receive (New, BOM import, Load
+ /// Machine Defaults); off when there is no machine or it has no clamps.
+ ///
+ public static ClampSettings LoadClampSettings(MachineConfig machine) =>
+ machine?.GetClampSettings() ?? ClampSettings.Fallback;
+
+ /// Saves as the clamps of the machine with .
+ public void SaveClampSettings(Guid machineId, ClampSettings settings)
+ {
+ var provider = CreateMachineProvider();
+ var machine = provider.GetMachine(machineId)
+ ?? throw new IOException("The machine configuration is no longer on file.");
+ machine.Clamps = MachineClamps.FromSettings(settings, machine.Units);
+ provider.SaveMachine(machine);
+ }
+
+ ///
+ /// Copies the legacy clamps.json (or the server's shared copy) onto a machine once, while
+ /// no machine has clamps of its own: the nest defaults' machine, or the only machine. With
+ /// several machines and no default picks one (null for not
+ /// now), asked once per session. Returns what to tell the user when the copy failed, or null.
+ ///
+ public string MigrateLegacyClamps(Func, MachineConfig> askMachine)
+ {
+ try
+ {
+ var legacy = LoadLegacyClampSettings();
+ var provider = CreateMachineProvider();
+ var machines = ReadMachines(() => provider)
+ .OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
+ .ToList();
+ if (!LegacyClampMigration.IsNeeded(legacy, machines))
+ return null;
+
+ var target = LegacyClampMigration.Target(machines, LoadSavedNestDefaults(out _).MachineId);
+ if (target == null)
+ {
+ if (clampMigrationAsked)
+ return null;
+ clampMigrationAsked = true;
+ target = askMachine?.Invoke(machines);
+ if (target == null)
+ return null;
+ }
+
+ LegacyClampMigration.Apply(legacy, target);
+ provider.SaveMachine(target);
+ return null;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return $"The clamp settings could not be copied to a machine:\n\n{ex.Message}\n\n"
+ + "New nests get no clamps until a machine has them (Tools > Machine Configuration).";
+ }
+ }
+
+ // The pre-machine clamp settings: the server's shared copy once it holds one, otherwise
+ // this PC's clamps.json; null when neither exists.
+ private ClampSettings LoadLegacyClampSettings()
+ {
+ if (Shared.Find(SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey) is { } shared)
+ return ClampSettings.Parse(shared.Json, out _);
+ return File.Exists(legacyClampsPath) ? ClampSettings.Load(legacyClampsPath) : null;
+ }
+
+ ///
+ /// The machine new nests are assigned: the nest defaults' machine when it is on file,
+ /// otherwise the first by name, or null when there are none.
+ ///
+ public static MachineConfig DefaultMachine(NestDefaults defaults, IEnumerable machines)
+ {
+ var sorted = machines.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
+ return sorted.FirstOrDefault(m => m.Id == defaults?.MachineId) ?? sorted.FirstOrDefault();
+ }
+
+ /// The configuration of , or null when none is assigned or it is not on file.
+ public MachineConfig FindMachine(NestMachine machine)
+ {
+ if (machine == null)
+ return null;
+ try
+ {
+ return CreateMachineProvider().GetMachine(machine.Id);
+ }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ return null;
+ }
+
+ ///
+ /// Assigns to (null for none) and gives
+ /// every sheet that machine's support grid, or none when the machine gives nests no grid.
+ ///
+ public void AssignMachine(Nest nest, MachineConfig machine)
+ {
+ nest.Machine = machine == null ? null : new NestMachine(machine.Id, machine.Name);
+ var grid = LoadSupportGridSettings(machine);
+ nest.SetSupportBed(grid.Enabled ? grid.BedFor(nest.Units) : null);
+ }
+
+ ///
+ /// The cutting parameters a plate of without its own starts from:
+ /// the last ones applied (or the built-in defaults), with the lead-ins, lead-outs and kerf
+ /// of the nest's machine for its material and thickness when the machine has them. Last
+ /// parameters already taken from that machine, material and thickness keep the user's
+ /// edits to them.
+ ///
+ public CuttingParameters DefaultCuttingParameters(Nest nest)
+ {
+ var parameters = LastCuttingParameters();
+ var machine = FindMachine(nest.Machine);
+ if (!MachineCuttingDefaults.IsRecordedFor(parameters, machine, nest))
+ MachineCuttingDefaults.ApplyTo(parameters, machine, nest);
+ return parameters;
+ }
+
+ ///
+ /// The cutting parameters to plan or place lead-ins on with: the
+ /// user's (see ), so an edit reaches nests already
+ /// planned. The plate's own are used only when no user settings are saved or readable.
+ ///
+ public CuttingParameters CuttingParametersFor(Plate plate, Nest nest) =>
+ plate?.CuttingParameters != null && ReadLastCuttingParameters() == null
+ ? plate.CuttingParameters
+ : DefaultCuttingParameters(nest);
+
+ /// Whether the cutting settings of are kept on its machine's thickness.
+ public bool HasMachineThickness(Nest nest) =>
+ nest != null && MachineCuttingDefaults.Find(FindMachine(nest.Machine), nest) != null;
+
+ ///
+ /// Where the cutting settings of are kept, for the settings
+ /// dialogs: the machine's thickness, or why this PC's settings are used instead.
+ ///
+ public string DescribeCuttingSource(Nest nest)
+ {
+ var machine = FindMachine(nest?.Machine);
+ if (nest == null || machine == null)
+ return "This PC's settings: the nest has no machine.";
+ var material = nest.Material?.Name;
+ if (string.IsNullOrWhiteSpace(material) || !double.IsFinite(nest.Thickness) || nest.Thickness <= 0)
+ return "This PC's settings: the nest has no material and thickness.";
+ var job = $"{material}{(string.IsNullOrWhiteSpace(nest.Material.Grade) ? "" : " " + nest.Material.Grade)} "
+ + $"{nest.Thickness:0.####} {(nest.Units == Units.Millimeters ? "mm" : "in")}";
+ return MachineCuttingDefaults.Find(machine, nest) == null
+ ? $"This PC's settings: {machine.Name} has no {job}."
+ : $"Saved for {machine.Name} · {job}";
+ }
+
+ ///
+ /// Saves cutting settings edited for : on the machine's thickness
+ /// when it has one, otherwise as this PC's settings. When the machine cannot be saved
+ /// (the nest server is offline, or someone else changed the machine), the settings are
+ /// kept on this PC for this job and is told why. Returns whether
+ /// they reached the machine.
+ ///
+ public bool SaveCuttingParameters(Nest nest, CuttingParameters parameters, Action warn) =>
+ SaveCuttingParameters(nest, parameters, CreateMachineProvider(), warn);
+
+ ///
+ /// with the
+ /// machines of .
+ ///
+ public bool SaveCuttingParameters(Nest nest, CuttingParameters parameters, IDataProvider provider, Action warn)
+ {
+ MachineConfig machine = null;
+ string error;
+ try
+ {
+ machine = nest?.Machine == null ? null : provider.GetMachine(nest.Machine.Id);
+ if (machine == null || !MachineCuttingDefaults.WriteTo(machine, nest, parameters))
+ {
+ SaveLastCuttingParameters(parameters);
+ return false;
+ }
+ provider.SaveMachine(machine);
+ // A copy kept on this PC for this job would hide the machine's from now on.
+ var last = ReadLastCuttingParameters();
+ if (last != null && MachineCuttingDefaults.IsRecordedFor(last, machine, nest))
+ {
+ last.MachineName = null;
+ SaveLastCuttingParameters(last);
+ }
+ return true;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
+ {
+ error = ex.Message;
+ }
+
+ // Stamped for this job, so the edit sticks on this PC until the machine takes it.
+ var kept = CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(parameters));
+ kept.MachineName = machine?.Name ?? nest.Machine.Name;
+ kept.MaterialName = nest.Material?.Name;
+ kept.Grade = nest.Material?.Grade;
+ kept.Thickness = nest.Thickness;
+ SaveLastCuttingParameters(kept);
+ warn?.Invoke($"The cutting settings could not be saved to {kept.MachineName}:\n\n{error}\n\n"
+ + "They are kept on this PC for this material and thickness.");
+ return false;
+ }
+
+ /// Saves as this PC's last cutting settings.
+ public void SaveLastCuttingParameters(CuttingParameters parameters) =>
+ user.CuttingParametersJson = CuttingParametersSerializer.Serialize(parameters);
+
+ /// The cutting parameters last applied, or the built-in defaults when none are saved or readable.
+ public CuttingParameters LastCuttingParameters() =>
+ ReadLastCuttingParameters() ?? new CuttingParameters();
+
+ private CuttingParameters ReadLastCuttingParameters()
+ {
+ var json = user.CuttingParametersJson;
+ if (!string.IsNullOrEmpty(json))
+ {
+ try
+ {
+ // Settings saved with the retired "Tab every part" read as tabs by size over
+ // every width. A plate's recorded parameters are never migrated.
+ var parameters = CuttingParametersSerializer.Deserialize(json);
+ Support.SupportTabSettings.MigrateTabEveryPart(parameters);
+ return parameters;
+ }
+ catch
+ { /* fall through */
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Gives the cut-off clearance, overtravel, minimum segment and
+ /// direction of the nest's machine for its material and thickness, when the machine has
+ /// them; otherwise they are left as they are.
+ ///
+ public void ApplyCutOffDefaults(CutOffSettings settings, Nest nest) =>
+ MachineCuttingDefaults.ApplyTo(settings, FindMachine(nest.Machine), nest);
+
+ ///
+ /// The support grid of ; a machine without one saved yet, or no
+ /// machine, uses the legacy shared support-grid.json. Never throws, and loads as off when unreadable.
+ ///
+ public SupportGridSettings LoadSupportGridSettings(MachineConfig machine) =>
+ machine?.SupportGrid ?? SupportGridSettings.Load(legacySupportGridPath);
+
+ /// Saves as the support grid of the machine with .
+ public void SaveSupportGridSettings(Guid machineId, SupportGridSettings settings)
+ {
+ var provider = CreateMachineProvider();
+ var machine = provider.GetMachine(machineId)
+ ?? throw new IOException("The machine configuration is no longer on file.");
+ machine.SupportGrid = settings;
+ provider.SaveMachine(machine);
+ }
+
+ ///
+ /// Loads the persisted nest defaults (see ).
+ /// says, the first time in a session, that they could not be
+ /// read and the built-in defaults are used; it is null otherwise.
+ ///
+ public NestDefaults LoadNestDefaults(out string warning)
+ {
+ var defaults = LoadSavedNestDefaults(out var status);
+ warning = null;
+ if (status == NestDefaultsStatus.Invalid && !defaultsWarned)
+ {
+ defaultsWarned = true;
+ var source = SharedNestDefaults() != null ? "on the nest server" : nestDefaultsPath;
+ warning = $"The nest defaults could not be read:\n{source}\n\nBuilt-in defaults will be used. Re-save your defaults from Tools > Nest Defaults.";
+ }
+ return defaults;
+ }
+
+ ///
+ /// The single desktop entry point for persisted nest defaults. Units
+ /// fall back to the legacy DefaultUnit setting whenever the file is
+ /// missing or unusable or has no valid unit, so New, BOM import and
+ /// the defaults dialog always agree.
+ ///
+ public NestDefaults LoadSavedNestDefaults(out NestDefaultsStatus status) =>
+ SharedNestDefaults() is { } shared
+ ? NestDefaults.Parse(shared.Json, user.DefaultUnit, out status)
+ : NestDefaults.Load(nestDefaultsPath, user.DefaultUnit, out status);
+
+ /// This PC's own nest defaults, shared or not, for uploading.
+ public NestDefaults LoadLocalNestDefaults() =>
+ NestDefaults.Load(nestDefaultsPath, user.DefaultUnit, out _);
+
+ ///
+ /// Saves the nest defaults where they are read: the server once it holds them, otherwise
+ /// this PC's file. Throws when the server cannot take them.
+ ///
+ public void SaveNestDefaults(NestDefaults defaults)
+ {
+ if (Shared.Current() is { } current
+ && current.Get(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey) != null)
+ current.Save(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey, defaults.ToJson());
+ else
+ defaults.Save(nestDefaultsPath);
+ }
+
+ ///
+ /// This PC's machines, ' settings and nest defaults that
+ /// does not hold yet (see ).
+ ///
+ public IReadOnlyList PendingUploads(SharedSettingsStore current, IEnumerable postProcessors) =>
+ SharedSettingsSource.PendingUploads(current, LoadLocalMachines, postProcessors,
+ () => File.Exists(nestDefaultsPath) ? LoadLocalNestDefaults().ToJson() : null);
+
+ private SharedSettingRecord SharedNestDefaults() =>
+ Shared.Find(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey);
+}
+
+///
+/// kept in the desktop user-settings file
+/// () under the WinForms names and text: DefaultUnit as the unit's
+/// name ("Inches" when missing or unreadable) and CuttingParametersJson as JSON text.
+///
+public sealed class MachineUserSettingsFile(string path) : IMachineUserSettings
+{
+ public const string DefaultUnitName = "DefaultUnit";
+ public const string CuttingParametersName = "CuttingParametersJson";
+
+ public Units DefaultUnit =>
+ UserSettingsFile.Read(path).TryGetValue(DefaultUnitName, out var text)
+ && Enum.TryParse(text?.Trim(), ignoreCase: false, out var units)
+ && Enum.IsDefined(units)
+ ? units
+ : Units.Inches;
+
+ public string CuttingParametersJson
+ {
+ get => UserSettingText.ReadString(UserSettingsFile.Read(path), CuttingParametersName, "");
+ set => UserSettingsFile.Update(path, values => values[CuttingParametersName] = value ?? "");
+ }
+}
diff --git a/OpenNest.Desktop/SharedSettingsSource.cs b/OpenNest.Desktop/SharedSettingsSource.cs
new file mode 100644
index 00000000..f608765c
--- /dev/null
+++ b/OpenNest.Desktop/SharedSettingsSource.cs
@@ -0,0 +1,263 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using OpenNest.Data;
+
+namespace OpenNest.Desktop;
+
+/// A document this PC could upload to a nest server that does not hold it yet.
+public sealed record SharedSettingUpload(string Kind, string Key, string Json, string Label);
+
+///
+/// The settings every workstation shares through the nest server in Database mode: machines,
+/// post settings, clamp settings and nest defaults (see docs/shared-settings.md). Each kind
+/// is read from the server once it holds a copy and from this PC's own files until then.
+/// The server's copy is re-read when it is older than a few minutes, and always before
+/// posting or editing machines; while the server is unreachable the last copy read is used
+/// and nothing shared can be changed. Front ends show the texts it returns.
+///
+public sealed class SharedSettingsSource
+{
+ private static readonly TimeSpan MaxAge = TimeSpan.FromMinutes(2);
+ private static readonly TimeSpan OfflineRetry = TimeSpan.FromMinutes(1);
+ private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
+
+ private readonly object sync = new();
+ private readonly Func storageSettings;
+ private readonly Func createStore;
+ private readonly Func now;
+ private SharedSettingsStore store;
+ private string storeUrl;
+ private bool offlineWarned;
+
+ /// The current storage mode and server, read on every use.
+ /// Makes the store for a server URL; defaults to .
+ /// The clock deciding when the copy is stale; defaults to .
+ public SharedSettingsSource(
+ Func storageSettings,
+ Func createStore = null,
+ Func now = null)
+ {
+ this.storageSettings = storageSettings ?? throw new ArgumentNullException(nameof(storageSettings));
+ this.createStore = createStore ?? CreateStore;
+ this.now = now ?? (() => DateTime.Now);
+ }
+
+ ///
+ /// A source reading the storage mode once from %APPDATA%\OpenNest\storage.json, for a front
+ /// end that does not change it while running.
+ ///
+ public static SharedSettingsSource FromStorageFile(string path = null)
+ {
+ var settings = NestStorageSettings.Load(path ?? NestStorageSettings.DefaultPath);
+ return new SharedSettingsSource(() => settings);
+ }
+
+ ///
+ /// The store for : requests give up after five seconds and the
+ /// last copy read is kept under %APPDATA%\OpenNest\SharedSettings. Throws
+ /// for an invalid URL.
+ ///
+ public static SharedSettingsStore CreateStore(string serverUrl) =>
+ new(new RemoteSharedSettings(serverUrl, Timeout), CachePath(serverUrl), Environment.UserName);
+
+ /// Where this PC keeps its last copy of a server's shared settings.
+ public static string CachePath(string serverUrl)
+ {
+ var key = (serverUrl ?? "").Trim().TrimEnd('/').ToLowerInvariant();
+ var name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key)))[..16];
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenNest",
+ "SharedSettings",
+ name + ".json");
+ }
+
+ ///
+ /// The shared settings for the configured server, re-read from it when
+ /// is set or the copy is stale; null in File mode or when the
+ /// server predates shared settings.
+ ///
+ public SharedSettingsStore Current(bool refresh = false)
+ {
+ var settings = storageSettings();
+ if (settings == null || !settings.IsDatabaseMode)
+ return null;
+
+ lock (sync)
+ {
+ if (store == null || storeUrl != settings.ServerUrl)
+ {
+ store?.Dispose();
+ store = null;
+ try
+ {
+ store = createStore(settings.ServerUrl);
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+ storeUrl = settings.ServerUrl;
+ }
+
+ var age = now() - (store.RefreshedAt ?? DateTime.MinValue);
+ if (refresh || age > (store.IsOffline ? OfflineRetry : MaxAge))
+ store.Refresh();
+ return store.IsUnsupported ? null : store;
+ }
+ }
+
+ /// The shared document of under , or null when this PC's own is used.
+ public SharedSettingRecord Find(string kind, string key, bool refresh = false) =>
+ Current(refresh)?.Get(kind, key);
+
+ /// The store when the server holds documents of , so they replace this PC's own.
+ public SharedSettingsStore Holding(string kind, bool refresh = false) =>
+ Current(refresh) is { } current && current.List(kind).Count > 0 ? current : null;
+
+ /// Whether the last read of the server failed, so the last copy read is in use.
+ public bool IsOffline => store?.IsOffline == true;
+
+ ///
+ /// What to tell the user, once per session, when the server could not be reached and the
+ /// last copy read is in use; null when there is nothing (more) to say.
+ ///
+ public string TakeOfflineWarning()
+ {
+ var current = store;
+ if (current is not { IsOffline: true } || offlineWarned)
+ return null;
+ offlineWarned = true;
+ var copy = current.ReadAt is { } readAt
+ ? $"the copy read {readAt:g}"
+ : "this PC's own settings (no copy of the shared ones has been read yet)";
+ return $"The nest server could not be reached, so machines, post settings, clamps and nest defaults come from {copy}. "
+ + "They cannot be changed until the server is back.\n\n" + current.LastError?.Message;
+ }
+
+ /// Where Tools > Machine Configuration reads and saves, for its title.
+ public string MachineSource(IDataProvider provider)
+ {
+ if (provider is not SharedMachineProvider)
+ return Current() == null ? "" : "this PC only: not shared on the server yet";
+ return store?.IsOffline == true ? "shared, read-only: the server cannot be reached" : "shared on the nest server";
+ }
+
+ ///
+ /// Where Tools > Nest Defaults saves (the server once it holds them) and whether it can save there now.
+ ///
+ public (string Text, bool ReadOnly) NestDefaultsSource()
+ {
+ if (Find(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey) == null)
+ return (Current() == null ? "Saved on this PC." : "Saved on this PC only: not shared on the server yet.", false);
+ return store?.IsOffline == true
+ ? ("Shared, read-only: the server cannot be reached.", true)
+ : ("Shared with every workstation on the nest server.", false);
+ }
+
+ /// The key of a post's shared settings.
+ public static string PostKey(IPostProcessor postProcessor) => postProcessor.GetType().FullName;
+
+ ///
+ /// Gives a post that shares its settings the server's, re-read for this post, when the
+ /// server holds them. Returns why they are unusable (nothing may be posted), or null.
+ ///
+ public string LoadPostSettings(IJsonConfigPostProcessor post)
+ {
+ var record = Find(SharedSettingKinds.Post, PostKey(post), refresh: true);
+ if (record == null)
+ return null;
+ try
+ {
+ post.LoadConfig(record.Json);
+ return null;
+ }
+ catch (System.Text.Json.JsonException ex)
+ {
+ return $"The shared {post.Name} settings on the nest server could not be read, so nothing was posted.\n\n{ex.Message}";
+ }
+ }
+
+ ///
+ /// Saves a post's settings where they are read: the server once it holds them, otherwise
+ /// the post's own file. JSON equal to (the settings before the
+ /// dialog opened) is not saved, so posting without editing never writes a shared revision.
+ /// Throws when the server cannot take them.
+ ///
+ public void SavePostSettings(IConfigurablePostProcessor postProcessor, string unchanged)
+ {
+ if (postProcessor is not IJsonConfigPostProcessor post)
+ {
+ postProcessor.SaveConfig();
+ return;
+ }
+ var json = post.SerializeConfig();
+ if (json == unchanged)
+ return;
+ if (Current() is { } current && current.Get(SharedSettingKinds.Post, PostKey(post)) != null)
+ current.Save(SharedSettingKinds.Post, PostKey(post), json);
+ else
+ post.SaveConfig();
+ }
+
+ ///
+ /// This PC's machines (with their clamps; only while the server holds none), post settings
+ /// and nest defaults (when gives them) that
+ /// does not hold yet. Documents the server holds are never listed.
+ ///
+ public static IReadOnlyList PendingUploads(
+ SharedSettingsStore current,
+ Func> localMachines,
+ IEnumerable postProcessors,
+ Func localDefaultsJson)
+ {
+ var uploads = new List();
+ if (current.List(SharedSettingKinds.Machine).Count == 0)
+ {
+ foreach (var machine in localMachines())
+ uploads.Add(new(SharedSettingKinds.Machine, machine.Id.ToString("D"), SharedMachineProvider.Serialize(machine),
+ $"Machine: {machine.Name}"));
+ }
+ foreach (var post in (postProcessors ?? []).OfType())
+ {
+ if (current.Get(SharedSettingKinds.Post, PostKey(post)) == null)
+ uploads.Add(new(SharedSettingKinds.Post, PostKey(post), post.SerializeConfig(), $"Post settings: {post.Name}"));
+ }
+ if (current.Get(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey) == null
+ && localDefaultsJson() is { } defaults)
+ uploads.Add(new(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey, defaults, "Nest defaults"));
+ return uploads;
+ }
+
+ /// The question offering to the server.
+ public static string DescribeUploads(IEnumerable uploads) =>
+ "The nest server does not have these settings yet:\n\n"
+ + string.Join("\n", uploads.Select(u => "• " + u.Label))
+ + "\n\nUpload this PC's copies so every workstation nests and posts with them? "
+ + "Until then, each PC keeps using its own.";
+
+ ///
+ /// Saves each upload to ; returns what to tell the user about the
+ /// ones that failed, or null when all were uploaded.
+ ///
+ public static string Upload(SharedSettingsStore current, IEnumerable uploads)
+ {
+ var failures = new List();
+ foreach (var upload in uploads)
+ {
+ try
+ {
+ current.Save(upload.Kind, upload.Key, upload.Json);
+ }
+ catch (IOException ex)
+ {
+ failures.Add($"{upload.Label}: {ex.Message}");
+ }
+ }
+ return failures.Count > 0 ? "Some settings were not uploaded:\n\n" + string.Join("\n", failures) : null;
+ }
+}
diff --git a/OpenNest/Forms/MachineSettings.cs b/OpenNest/Forms/MachineSettings.cs
index 3db059aa..394a6cbb 100644
--- a/OpenNest/Forms/MachineSettings.cs
+++ b/OpenNest/Forms/MachineSettings.cs
@@ -1,137 +1,57 @@
using System;
using System.Collections.Generic;
-using System.IO;
using System.Linq;
using System.Windows.Forms;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Controls;
using OpenNest.Data;
-using OpenNest.IO;
+using OpenNest.Desktop;
namespace OpenNest.Forms
{
///
- /// The desktop entry points for the persisted machines (with their clamps) and nest
- /// defaults. In Database mode each comes from the nest server once it holds a shared copy
- /// (), otherwise from this PC's files. The warning variants
- /// report unusable settings once per session.
+ /// The WinForms entry points for the persisted machines (with their clamps) and nest
+ /// defaults. The rules live in the shared , reading this
+ /// app's settings; this shows its warnings (once per session where noted) and questions.
///
internal static class MachineSettings
{
+ /// The shared machine settings, reading and saving through .
+ internal static MachineSettingsSource Source { get; } = new(SharedSettings.Source, new AppUserSettings());
+
///
/// The machine configurations edited in Tools > Machine Configuration: the server's when
/// it holds any, otherwise this PC's. re-reads the server first.
///
- public static IDataProvider CreateMachineProvider(bool refresh = false) =>
- SharedSettings.Holding(SharedSettingKinds.Machine, refresh) is { } shared
- ? new SharedMachineProvider(shared)
- : CreateLocalMachineProvider();
-
- /// This PC's machine configurations, under %APPDATA%\OpenNest\Machines.
- private static LocalJsonProvider CreateLocalMachineProvider()
- {
- var provider = new LocalJsonProvider(Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "OpenNest",
- "Machines"));
- provider.EnsureDefaults();
- return provider;
- }
+ public static IDataProvider CreateMachineProvider(bool refresh = false) => Source.CreateMachineProvider(refresh);
/// Every readable machine configuration, for choosing acceleration limits.
- public static List LoadMachines() => ReadMachines(() => CreateMachineProvider());
+ public static List LoadMachines() => Source.LoadMachines();
/// Every readable machine configuration on this PC, shared or not, for uploading.
- public static List LoadLocalMachines() => ReadMachines(CreateLocalMachineProvider);
-
- private static List ReadMachines(Func createProvider)
- {
- var machines = new List();
- try
- {
- var provider = createProvider();
- foreach (var summary in provider.GetMachines())
- {
- if (provider.GetMachine(summary.Id) is { } machine)
- machines.Add(machine);
- }
- }
- catch (IOException) { }
- catch (UnauthorizedAccessException) { }
- return machines;
- }
+ public static List LoadLocalMachines() => Source.LoadLocalMachines();
/// Every material and grade on file across the readable machine configurations.
- public static IEnumerable LoadMaterials() =>
- LoadMachines().SelectMany(machine => machine.Materials);
+ public static IEnumerable LoadMaterials() => Source.LoadMaterials();
- private static bool defaultsWarned;
- private static bool clampMigrationAsked;
-
- ///
- /// The clamps new nests assigned receive (New, BOM import, Load
- /// Machine Defaults); off when there is no machine or it has no clamps.
- ///
+ /// The clamps new nests assigned receive.
public static ClampSettings LoadClampSettings(MachineConfig machine) =>
- machine?.GetClampSettings() ?? ClampSettings.Fallback;
+ MachineSettingsSource.LoadClampSettings(machine);
/// Saves as the clamps of the machine with .
- public static void SaveClampSettings(Guid machineId, ClampSettings settings)
- {
- var provider = CreateMachineProvider();
- var machine = provider.GetMachine(machineId)
- ?? throw new IOException("The machine configuration is no longer on file.");
- machine.Clamps = MachineClamps.FromSettings(settings, machine.Units);
- provider.SaveMachine(machine);
- }
+ public static void SaveClampSettings(Guid machineId, ClampSettings settings) =>
+ Source.SaveClampSettings(machineId, settings);
///
- /// Copies the legacy clamps.json (or the server's shared copy) onto a machine once, while
- /// no machine has clamps of its own: the nest defaults' machine, or the only machine. With
- /// several machines and no default the user picks one, asked once per session.
+ /// Copies the legacy clamps onto a machine once (see
+ /// ), asking which machine when
+ /// it cannot tell and warning when the copy fails.
///
public static void MigrateLegacyClamps(IWin32Window owner)
{
- try
- {
- var legacy = LoadLegacyClampSettings();
- var provider = CreateMachineProvider();
- var machines = ReadMachines(() => provider)
- .OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
- .ToList();
- if (!LegacyClampMigration.IsNeeded(legacy, machines))
- return;
-
- var target = LegacyClampMigration.Target(machines, LoadSavedNestDefaults(out _).MachineId);
- if (target == null)
- {
- if (clampMigrationAsked)
- return;
- clampMigrationAsked = true;
- target = AskClampMachine(owner, machines);
- if (target == null)
- return;
- }
-
- LegacyClampMigration.Apply(legacy, target);
- provider.SaveMachine(target);
- }
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
- {
- MessageBox.Show(owner,
- $"The clamp settings could not be copied to a machine:\n\n{ex.Message}\n\n"
- + "New nests get no clamps until a machine has them (Tools > Machine Configuration).",
- "Clamps", MessageBoxButtons.OK, MessageBoxIcon.Warning);
- }
- }
-
- // The pre-machine clamp settings: the server's shared copy once it holds one, otherwise
- // this PC's clamps.json; null when neither exists.
- private static ClampSettings LoadLegacyClampSettings()
- {
- if (SharedSettings.Find(SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey) is { } shared)
- return ClampSettings.Parse(shared.Json, out _);
- return File.Exists(ClampSettings.DefaultPath) ? ClampSettings.Load(ClampSettings.DefaultPath) : null;
+ var warning = Source.MigrateLegacyClamps(machines => AskClampMachine(owner, machines));
+ if (warning != null)
+ MessageBox.Show(owner, warning, "Clamps", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
private static MachineConfig AskClampMachine(IWin32Window owner, IReadOnlyList machines)
@@ -160,174 +80,47 @@ namespace OpenNest.Forms
: null;
}
- ///
- /// The machine new nests are assigned: the nest defaults' machine when it is on file,
- /// otherwise the first by name, or null when there are none.
- ///
- public static MachineConfig DefaultMachine(NestDefaults defaults, IEnumerable machines)
- {
- var sorted = machines.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
- return sorted.FirstOrDefault(m => m.Id == defaults?.MachineId) ?? sorted.FirstOrDefault();
- }
+ /// The machine new nests are assigned (see ).
+ public static MachineConfig DefaultMachine(NestDefaults defaults, IEnumerable machines) =>
+ MachineSettingsSource.DefaultMachine(defaults, machines);
/// The configuration of , or null when none is assigned or it is not on file.
- public static MachineConfig FindMachine(NestMachine machine)
- {
- if (machine == null)
- return null;
- try
- {
- return CreateMachineProvider().GetMachine(machine.Id);
- }
- catch (IOException) { }
- catch (UnauthorizedAccessException) { }
- return null;
- }
+ public static MachineConfig FindMachine(NestMachine machine) => Source.FindMachine(machine);
- ///
- /// Assigns to (null for none) and gives
- /// every sheet that machine's support grid, or none when the machine gives nests no grid.
- ///
- public static void AssignMachine(Nest nest, MachineConfig machine)
- {
- nest.Machine = machine == null ? null : new NestMachine(machine.Id, machine.Name);
- var grid = LoadSupportGridSettings(machine);
- nest.SetSupportBed(grid.Enabled ? grid.BedFor(nest.Units) : null);
- }
+ /// Assigns and its support grid to .
+ public static void AssignMachine(Nest nest, MachineConfig machine) => Source.AssignMachine(nest, machine);
- ///
- /// The cutting parameters a plate of without its own starts from:
- /// the last ones applied (or the built-in defaults), with the lead-ins, lead-outs and kerf
- /// of the nest's machine for its material and thickness when the machine has them. Last
- /// parameters already taken from that machine, material and thickness keep the user's
- /// edits to them.
- ///
- public static CuttingParameters DefaultCuttingParameters(Nest nest)
- {
- var parameters = LastCuttingParameters();
- var machine = FindMachine(nest.Machine);
- if (!MachineCuttingDefaults.IsRecordedFor(parameters, machine, nest))
- MachineCuttingDefaults.ApplyTo(parameters, machine, nest);
- return parameters;
- }
+ /// See .
+ public static CuttingParameters DefaultCuttingParameters(Nest nest) => Source.DefaultCuttingParameters(nest);
- ///
- /// The cutting parameters to plan or place lead-ins on with: the
- /// user's (see ), so an edit reaches nests already
- /// planned. The plate's own are used only when no user settings are saved or readable.
- ///
- public static CuttingParameters CuttingParametersFor(Plate plate, Nest nest) =>
- plate?.CuttingParameters != null && ReadLastCuttingParameters() == null
- ? plate.CuttingParameters
- : DefaultCuttingParameters(nest);
+ /// See .
+ public static CuttingParameters CuttingParametersFor(Plate plate, Nest nest) => Source.CuttingParametersFor(plate, nest);
/// Whether the cutting settings of are kept on its machine's thickness.
- public static bool HasMachineThickness(Nest nest) =>
- nest != null && MachineCuttingDefaults.Find(FindMachine(nest.Machine), nest) != null;
+ public static bool HasMachineThickness(Nest nest) => Source.HasMachineThickness(nest);
+
+ /// See .
+ public static string DescribeCuttingSource(Nest nest) => Source.DescribeCuttingSource(nest);
///
- /// Where the cutting settings of are kept, for the settings
- /// dialogs: the machine's thickness, or why this PC's settings are used instead.
- ///
- public static string DescribeCuttingSource(Nest nest)
- {
- var machine = FindMachine(nest?.Machine);
- if (nest == null || machine == null)
- return "This PC's settings: the nest has no machine.";
- var material = nest.Material?.Name;
- if (string.IsNullOrWhiteSpace(material) || !double.IsFinite(nest.Thickness) || nest.Thickness <= 0)
- return "This PC's settings: the nest has no material and thickness.";
- var job = $"{material}{(string.IsNullOrWhiteSpace(nest.Material.Grade) ? "" : " " + nest.Material.Grade)} "
- + $"{nest.Thickness:0.####} {(nest.Units == Units.Millimeters ? "mm" : "in")}";
- return MachineCuttingDefaults.Find(machine, nest) == null
- ? $"This PC's settings: {machine.Name} has no {job}."
- : $"Saved for {machine.Name} · {job}";
- }
-
- ///
- /// Saves cutting settings edited for : on the machine's thickness
- /// when it has one, otherwise as this PC's settings. When the machine cannot be saved
- /// (the nest server is offline, or someone else changed the machine), the settings are
- /// kept on this PC for this job and the user is told why. Returns whether they reached the machine.
+ /// Saves cutting settings edited for (see
+ /// ),
+ /// telling the user why they could not reach the machine. Returns whether they did.
///
public static bool SaveCuttingParameters(IWin32Window owner, Nest nest, CuttingParameters parameters) =>
- SaveCuttingParameters(nest, parameters, CreateMachineProvider(), error => MessageBox.Show(owner, error,
+ Source.SaveCuttingParameters(nest, parameters, error => MessageBox.Show(owner, error,
"Cutting Parameters", MessageBoxButtons.OK, MessageBoxIcon.Warning));
- ///
- /// with the
- /// machines of , telling why a machine
- /// save failed.
- ///
- internal static bool SaveCuttingParameters(Nest nest, CuttingParameters parameters, IDataProvider provider, Action warn)
- {
- MachineConfig machine = null;
- string error;
- try
- {
- machine = nest?.Machine == null ? null : provider.GetMachine(nest.Machine.Id);
- if (machine == null || !MachineCuttingDefaults.WriteTo(machine, nest, parameters))
- {
- SaveLastCuttingParameters(parameters);
- return false;
- }
- provider.SaveMachine(machine);
- // A copy kept on this PC for this job would hide the machine's from now on.
- var last = ReadLastCuttingParameters();
- if (last != null && MachineCuttingDefaults.IsRecordedFor(last, machine, nest))
- {
- last.MachineName = null;
- SaveLastCuttingParameters(last);
- }
- return true;
- }
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
- {
- error = ex.Message;
- }
-
- // Stamped for this job, so the edit sticks on this PC until the machine takes it.
- var kept = CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(parameters));
- kept.MachineName = machine?.Name ?? nest.Machine.Name;
- kept.MaterialName = nest.Material?.Name;
- kept.Grade = nest.Material?.Grade;
- kept.Thickness = nest.Thickness;
- SaveLastCuttingParameters(kept);
- warn($"The cutting settings could not be saved to {kept.MachineName}:\n\n{error}\n\n"
- + "They are kept on this PC for this material and thickness.");
- return false;
- }
+ /// The save above with the machines of .
+ internal static bool SaveCuttingParameters(Nest nest, CuttingParameters parameters, IDataProvider provider, Action warn) =>
+ Source.SaveCuttingParameters(nest, parameters, provider, warn);
/// Saves as this PC's last cutting settings.
- public static void SaveLastCuttingParameters(CuttingParameters parameters)
- {
- Properties.Settings.Default.CuttingParametersJson = CuttingParametersSerializer.Serialize(parameters);
- Properties.Settings.Default.Save();
- }
+ public static void SaveLastCuttingParameters(CuttingParameters parameters) =>
+ Source.SaveLastCuttingParameters(parameters);
/// The cutting parameters last applied, or the built-in defaults when none are saved or readable.
- public static CuttingParameters LastCuttingParameters() =>
- ReadLastCuttingParameters() ?? new CuttingParameters();
-
- private static CuttingParameters ReadLastCuttingParameters()
- {
- var json = Properties.Settings.Default.CuttingParametersJson;
- if (!string.IsNullOrEmpty(json))
- {
- try
- {
- // Settings saved with the retired "Tab every part" read as tabs by size over
- // every width. A plate's recorded parameters are never migrated.
- var parameters = CuttingParametersSerializer.Deserialize(json);
- Support.SupportTabSettings.MigrateTabEveryPart(parameters);
- return parameters;
- }
- catch
- { /* fall through */
- }
- }
- return null;
- }
+ public static CuttingParameters LastCuttingParameters() => Source.LastCuttingParameters();
///
/// Saves a Nest Info dialog to : a changed material or thickness
@@ -344,24 +137,15 @@ namespace OpenNest.Forms
/// they regenerate.
///
public static void ApplyCutOffDefaults(PlateView view, Nest nest) =>
- MachineCuttingDefaults.ApplyTo(view.CutOffSettings, FindMachine(nest.Machine), nest);
+ Source.ApplyCutOffDefaults(view.CutOffSettings, nest);
- ///
- /// The support grid of ; a machine without one saved yet, or no
- /// machine, uses the legacy shared support-grid.json. Never throws, and loads as off when unreadable.
- ///
+ /// See .
public static SupportGridSettings LoadSupportGridSettings(MachineConfig machine) =>
- machine?.SupportGrid ?? SupportGridSettings.Load(SupportGridSettings.DefaultPath);
+ Source.LoadSupportGridSettings(machine);
/// Saves as the support grid of the machine with .
- public static void SaveSupportGridSettings(Guid machineId, SupportGridSettings settings)
- {
- var provider = CreateMachineProvider();
- var machine = provider.GetMachine(machineId)
- ?? throw new IOException("The machine configuration is no longer on file.");
- machine.SupportGrid = settings;
- provider.SaveMachine(machine);
- }
+ public static void SaveSupportGridSettings(Guid machineId, SupportGridSettings settings) =>
+ Source.SaveSupportGridSettings(machineId, settings);
///
/// Loads the persisted nest defaults (see );
@@ -369,49 +153,24 @@ namespace OpenNest.Forms
///
public static NestDefaults LoadNestDefaults()
{
- var defaults = LoadSavedNestDefaults(out var status);
-
- if (status == NestDefaultsStatus.Invalid && !defaultsWarned)
- {
- defaultsWarned = true;
- var source = SharedNestDefaults() != null ? "on the nest server" : NestDefaults.DefaultPath;
- MessageBox.Show(
- $"The nest defaults could not be read:\n{source}\n\nBuilt-in defaults will be used. Re-save your defaults from Tools > Nest Defaults.",
- "Nest Defaults",
- MessageBoxButtons.OK,
- MessageBoxIcon.Warning
- );
- }
+ var defaults = Source.LoadNestDefaults(out var warning);
+ if (warning != null)
+ MessageBox.Show(warning, "Nest Defaults", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return defaults;
}
- ///
- /// The single desktop entry point for persisted nest defaults. Units
- /// fall back to the legacy DefaultUnit setting whenever the file is
- /// missing or unusable or has no valid unit, so New, BOM import and
- /// the defaults dialog always agree.
- ///
+ /// See .
public static NestDefaults LoadSavedNestDefaults(out NestDefaultsStatus status) =>
- SharedNestDefaults() is { } shared
- ? NestDefaults.Parse(shared.Json, Properties.Settings.Default.DefaultUnit, out status)
- : NestDefaults.Load(NestDefaults.DefaultPath, Properties.Settings.Default.DefaultUnit, out status);
+ Source.LoadSavedNestDefaults(out status);
/// This PC's own nest defaults, shared or not, for uploading.
- public static NestDefaults LoadLocalNestDefaults() =>
- NestDefaults.Load(NestDefaults.DefaultPath, Properties.Settings.Default.DefaultUnit, out _);
+ public static NestDefaults LoadLocalNestDefaults() => Source.LoadLocalNestDefaults();
///
/// Saves the nest defaults where they are read: the server once it holds them, otherwise
- /// this PC's file. Throws when the server cannot take them.
+ /// this PC's file. Throws when the server cannot take them.
///
- public static void SaveNestDefaults(NestDefaults defaults)
- {
- if (SharedSettings.Current() is { } current
- && current.Get(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey) != null)
- current.Save(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey, defaults.ToJson());
- else
- defaults.Save(NestDefaults.DefaultPath);
- }
+ public static void SaveNestDefaults(NestDefaults defaults) => Source.SaveNestDefaults(defaults);
/// Saves the nest defaults, telling the user when they could not be saved.
public static void SaveNestDefaults(IWin32Window owner, NestDefaults defaults)
@@ -420,7 +179,7 @@ namespace OpenNest.Forms
{
SaveNestDefaults(defaults);
}
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ catch (Exception ex) when (ex is System.IO.IOException or UnauthorizedAccessException)
{
MessageBox.Show(owner, "The nest defaults could not be saved.\n\n" + ex.Message, "Nest Defaults",
MessageBoxButtons.OK, MessageBoxIcon.Error);
@@ -437,7 +196,20 @@ namespace OpenNest.Forms
SaveNestDefaults(owner, defaults);
}
- private static SharedSettingRecord SharedNestDefaults() =>
- SharedSettings.Find(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey);
+ /// The app settings the shared rules read and save.
+ private sealed class AppUserSettings : IMachineUserSettings
+ {
+ public Units DefaultUnit => Properties.Settings.Default.DefaultUnit;
+
+ public string CuttingParametersJson
+ {
+ get => Properties.Settings.Default.CuttingParametersJson;
+ set
+ {
+ Properties.Settings.Default.CuttingParametersJson = value;
+ Properties.Settings.Default.Save();
+ }
+ }
+ }
}
}
diff --git a/OpenNest/Forms/SharedSettings.cs b/OpenNest/Forms/SharedSettings.cs
index ff076d53..b5f17dd0 100644
--- a/OpenNest/Forms/SharedSettings.cs
+++ b/OpenNest/Forms/SharedSettings.cs
@@ -1,89 +1,34 @@
-using System;
using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Security.Cryptography;
-using System.Text;
using System.Windows.Forms;
using OpenNest.Data;
+using OpenNest.Desktop;
namespace OpenNest.Forms
{
///
- /// The settings every workstation shares through the nest server in Database mode: machines,
- /// post settings, clamp settings and nest defaults (see docs/shared-settings.md). Each kind
- /// is read from the server once it holds a copy and from this PC's own files until then.
- /// The server's copy is re-read when it is older than a few minutes, and always before
- /// posting or editing machines; while the server is unreachable the last copy read is used
- /// and nothing shared can be changed.
+ /// The WinForms entry points for the settings every workstation shares through the nest
+ /// server in Database mode (see docs/shared-settings.md). The rules live in the shared
+ /// , following this app's storage mode; this shows its
+ /// warnings and questions.
///
internal static class SharedSettings
{
- private static readonly TimeSpan MaxAge = TimeSpan.FromMinutes(2);
- private static readonly TimeSpan OfflineRetry = TimeSpan.FromMinutes(1);
- private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
- private static readonly object sync = new();
- private static SharedSettingsStore store;
- private static string storeUrl;
- private static bool offlineWarned;
+ /// The shared settings for the storage mode and server has now.
+ internal static SharedSettingsSource Source { get; } = new(() => NestStorage.Settings);
///
/// The shared settings for the configured server, re-read from it when
/// is set or the copy is stale; null in File mode or when the
/// server predates shared settings.
///
- public static SharedSettingsStore Current(bool refresh = false)
- {
- var settings = NestStorage.Settings;
- if (!settings.IsDatabaseMode)
- return null;
-
- lock (sync)
- {
- if (store == null || storeUrl != settings.ServerUrl)
- {
- store?.Dispose();
- store = null;
- try
- {
- store = new SharedSettingsStore(
- new RemoteSharedSettings(settings.ServerUrl, Timeout),
- CachePath(settings.ServerUrl),
- Environment.UserName);
- }
- catch (ArgumentException)
- {
- return null;
- }
- storeUrl = settings.ServerUrl;
- }
-
- var age = DateTime.Now - (store.RefreshedAt ?? DateTime.MinValue);
- if (refresh || age > (store.IsOffline ? OfflineRetry : MaxAge))
- store.Refresh();
- return store.IsUnsupported ? null : store;
- }
- }
+ public static SharedSettingsStore Current(bool refresh = false) => Source.Current(refresh);
/// The shared document of under , or null when this PC's own is used.
public static SharedSettingRecord Find(string kind, string key, bool refresh = false) =>
- Current(refresh)?.Get(kind, key);
+ Source.Find(kind, key, refresh);
/// The store when the server holds documents of , so they replace this PC's own.
- public static SharedSettingsStore Holding(string kind, bool refresh = false) =>
- Current(refresh) is { } current && current.List(kind).Count > 0 ? current : null;
-
- /// Where this PC keeps its last copy of a server's shared settings.
- private static string CachePath(string serverUrl)
- {
- var key = (serverUrl ?? "").Trim().TrimEnd('/').ToLowerInvariant();
- var name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key)))[..16];
- return Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "OpenNest",
- "SharedSettings",
- name + ".json");
- }
+ public static SharedSettingsStore Holding(string kind, bool refresh = false) => Source.Holding(kind, refresh);
///
/// Tells the user once per session that the server could not be reached and the last
@@ -91,42 +36,21 @@ namespace OpenNest.Forms
///
public static void WarnIfOffline(IWin32Window owner)
{
- var current = store;
- if (current is not { IsOffline: true } || offlineWarned)
- return;
- offlineWarned = true;
- var copy = current.ReadAt is { } readAt
- ? $"the copy read {readAt:g}"
- : "this PC's own settings (no copy of the shared ones has been read yet)";
- MessageBox.Show(owner,
- $"The nest server could not be reached, so machines, post settings, clamps and nest defaults come from {copy}. "
- + "They cannot be changed until the server is back.\n\n" + current.LastError?.Message,
- "Shared Settings", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ if (Source.TakeOfflineWarning() is { } warning)
+ MessageBox.Show(owner, warning, "Shared Settings", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
/// Where Tools > Machine Configuration reads and saves, for its title.
- public static string MachineSource(IDataProvider provider)
- {
- if (provider is not SharedMachineProvider)
- return Current() == null ? "" : "this PC only: not shared on the server yet";
- return store?.IsOffline == true ? "shared, read-only: the server cannot be reached" : "shared on the nest server";
- }
+ public static string MachineSource(IDataProvider provider) => Source.MachineSource(provider);
///
/// Where Tools > Nest Defaults saves (the server once it holds them, as
- /// does) and whether it can save there now.
+ /// does) and whether it can save there now.
///
- public static (string Text, bool ReadOnly) NestDefaultsSource()
- {
- if (Find(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey) == null)
- return (Current() == null ? "Saved on this PC." : "Saved on this PC only: not shared on the server yet.", false);
- return store?.IsOffline == true
- ? ("Shared, read-only: the server cannot be reached.", true)
- : ("Shared with every workstation on the nest server.", false);
- }
+ public static (string Text, bool ReadOnly) NestDefaultsSource() => Source.NestDefaultsSource();
/// The key of a post's shared settings.
- public static string PostKey(IPostProcessor postProcessor) => postProcessor.GetType().FullName;
+ public static string PostKey(IPostProcessor postProcessor) => SharedSettingsSource.PostKey(postProcessor);
///
/// Gives the server's settings, re-read for this post, when
@@ -136,45 +60,21 @@ namespace OpenNest.Forms
{
if (postProcessor is not IJsonConfigPostProcessor post)
return true;
- var record = Find(SharedSettingKinds.Post, PostKey(post), refresh: true);
+ var error = Source.LoadPostSettings(post);
WarnIfOffline(owner);
- if (record == null)
+ if (error == null)
return true;
- try
- {
- post.LoadConfig(record.Json);
- return true;
- }
- catch (System.Text.Json.JsonException ex)
- {
- MessageBox.Show(owner,
- $"The shared {post.Name} settings on the nest server could not be read, so nothing was posted.\n\n{ex.Message}",
- "Post Settings", MessageBoxButtons.OK, MessageBoxIcon.Error);
- return false;
- }
+ MessageBox.Show(owner, error, "Post Settings", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ return false;
}
///
- /// Saves a post's settings where they are read: the server once it holds them, otherwise
- /// the post's own file. JSON equal to (the settings before the
- /// dialog opened) is not saved, so posting without editing never writes a shared revision.
- /// Throws when the server cannot take them.
+ /// Saves a post's settings where they are read (see
+ /// ). Throws
+ /// when the server cannot take them.
///
- public static void SavePostSettings(IConfigurablePostProcessor postProcessor, string unchanged)
- {
- if (postProcessor is not IJsonConfigPostProcessor post)
- {
- postProcessor.SaveConfig();
- return;
- }
- var json = post.SerializeConfig();
- if (json == unchanged)
- return;
- if (Current() is { } current && current.Get(SharedSettingKinds.Post, PostKey(post)) != null)
- current.Save(SharedSettingKinds.Post, PostKey(post), json);
- else
- post.SaveConfig();
- }
+ public static void SavePostSettings(IConfigurablePostProcessor postProcessor, string unchanged) =>
+ Source.SavePostSettings(postProcessor, unchanged);
///
/// When the server holds none of this PC's machines (with their clamps), post settings or nest
@@ -188,49 +88,17 @@ namespace OpenNest.Forms
if (current == null || current.IsOffline)
return;
- var uploads = new List<(string Kind, string Key, string Json, string Label)>();
- if (current.List(SharedSettingKinds.Machine).Count == 0)
- {
- foreach (var machine in MachineSettings.LoadLocalMachines())
- uploads.Add((SharedSettingKinds.Machine, machine.Id.ToString("D"), SharedMachineProvider.Serialize(machine),
- $"Machine: {machine.Name}"));
- }
- foreach (var post in postProcessors.OfType())
- {
- if (current.Get(SharedSettingKinds.Post, PostKey(post)) == null)
- uploads.Add((SharedSettingKinds.Post, PostKey(post), post.SerializeConfig(), $"Post settings: {post.Name}"));
- }
- if (current.Get(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey) == null
- && File.Exists(NestDefaults.DefaultPath))
- uploads.Add((SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey,
- MachineSettings.LoadLocalNestDefaults().ToJson(), "Nest defaults"));
+ var uploads = MachineSettings.Source.PendingUploads(current, postProcessors);
if (uploads.Count == 0)
return;
- var answer = MessageBox.Show(owner,
- "The nest server does not have these settings yet:\n\n"
- + string.Join("\n", uploads.Select(u => "• " + u.Label))
- + "\n\nUpload this PC's copies so every workstation nests and posts with them? "
- + "Until then, each PC keeps using its own.",
+ var answer = MessageBox.Show(owner, SharedSettingsSource.DescribeUploads(uploads),
"Shared Settings", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (answer != DialogResult.Yes)
return;
- var failures = new List();
- foreach (var upload in uploads)
- {
- try
- {
- current.Save(upload.Kind, upload.Key, upload.Json);
- }
- catch (IOException ex)
- {
- failures.Add($"{upload.Label}: {ex.Message}");
- }
- }
- if (failures.Count > 0)
- MessageBox.Show(owner, "Some settings were not uploaded:\n\n" + string.Join("\n", failures),
- "Shared Settings", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ if (SharedSettingsSource.Upload(current, uploads) is { } failures)
+ MessageBox.Show(owner, failures, "Shared Settings", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
}
diff --git a/docs/shared-settings.md b/docs/shared-settings.md
index 3c584e79..b5d41ba9 100644
--- a/docs/shared-settings.md
+++ b/docs/shared-settings.md
@@ -11,7 +11,9 @@ building nests and posts the same way:
| `clamps` | `default` | Legacy clamp settings, read only to copy them onto a machine once (machines now hold their clamps) | `%APPDATA%\OpenNest\clamps.json` |
| `nest-defaults` | `default` | Tools > Nest Defaults: units, sheet size, quadrant, spacing, default machine, common-line usage | `%APPDATA%\OpenNest\defaults.json` |
-File mode, the console, MCP and API front ends use only this PC's copies.
+File mode, the console, MCP and API front ends use only this PC's copies. Both desktop apps
+read these settings through `OpenNest.Desktop` (`SharedSettingsSource`, `MachineSettingsSource`); the
+Avalonia app follows the storage mode in `storage.json` but does not offer uploads yet.
Nest defaults are saved by Tools > Nest Defaults, Tools > Save Current Plate as Defaults,
Plate > Set as Default, and Nest > Edit's Defaults tab with "Use these defaults for new nests"