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"