refactor(desktop): share machine, nest-default and shared-settings sources

The rules behind the WinForms MachineSettings and SharedSettings move into
OpenNest.Desktop as MachineSettingsSource and SharedSettingsSource, so the
Avalonia app reads machines, clamps and nest defaults the same way: in
Database mode the nest server's copy once it holds one (machines as a
whole, nest defaults and clamps as single documents), otherwise this PC's
files; the copy is re-read after two minutes (one while offline), and while
the server is unreachable the last copy read is used, read-only. Legacy
clamps still move onto the defaults' or the only machine once, asking at
most once per session, and cutting settings edited for a nest still save
onto the machine's thickness, or this PC (stamped for that job when the
machine cannot be saved).

Nothing in the shared code shows UI: it returns the warning texts, takes
the clamp-machine question as a delegate and reads the DefaultUnit and
CuttingParametersJson settings through IMachineUserSettings (with a
user-settings.json implementation under the WinForms names). Cut-off
defaults take CutOffSettings instead of a PlateView. MachineSettings and
SharedSettings stay in the WinForms project as thin wrappers with the same
members, showing the same dialogs and reading Properties.Settings and
NestStorage as before, so their callers are unchanged.

Tests cover File vs Database mode precedence, the offline read-only copy
and its once-per-session warning, staleness and retry timing, uploads,
post settings, legacy clamp migration, the cutting-settings save and the
user-settings file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-11 22:18:14 -05:00
1 parent bfff2a3546
commit 476bed1a05
8 files changed
+1548 -464

No files matched your search

@@ -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;
/// <summary>
/// A nest server's /api/settings in memory: lists, creates and replaces documents, or refuses
/// every request as an unreachable server while <see cref="Online"/> is false.
/// </summary>
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<SharedSettingRecord> 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 });
/// <summary>A source in Database mode on this server, keeping its last copy in <paramref name="cachePath"/>.</summary>
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<HttpResponseMessage> 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<SharedSettingWrite>(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<T>(HttpStatusCode status, T body) =>
new(status) { Content = JsonContent.Create(body, options: JsonOptions) };
}
/// <summary>The two machine user settings in memory.</summary>
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++;
}
}
}
@@ -0,0 +1,332 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Data;
namespace OpenNest.Desktop.Tests;
/// <summary>
/// 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.
/// </summary>
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<IReadOnlyList<string>>();
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<string>();
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<string>();
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<string, string> { ["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<string, string> { ["DefaultUnit"] = "Furlongs" });
Assert.Equal(Units.Inches, settings.DefaultUnit);
UserSettingsFile.Write(path, new Dictionary<string, string> { ["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);
}
}
@@ -0,0 +1,299 @@
using OpenNest.Data;
namespace OpenNest.Desktop.Tests;
/// <summary>
/// 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.
/// </summary>
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<SharedMachineProvider>(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<LocalJsonProvider>(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<IOException>(() => 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<SharedMachineProvider>(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;
}
}
}
+455
View File
@@ -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;
/// <summary>
/// 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).
/// </summary>
public interface IMachineUserSettings
{
/// <summary>The DefaultUnit setting.</summary>
Units DefaultUnit { get; }
/// <summary>The CuttingParametersJson setting; setting it saves it.</summary>
string CuttingParametersJson { get; set; }
}
/// <summary>
/// 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
/// (<see cref="SharedSettingsSource"/>), 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.
/// </summary>
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;
/// <param name="shared">Where shared copies come from.</param>
/// <param name="user">The per-user settings this reads and saves.</param>
/// <param name="machinesDirectory">This PC's machines; defaults to %APPDATA%\OpenNest\Machines.</param>
/// <param name="nestDefaultsPath">This PC's nest defaults; defaults to <see cref="NestDefaults.DefaultPath"/>.</param>
/// <param name="legacyClampsPath">The pre-machine clamps.json; defaults to <see cref="ClampSettings.DefaultPath"/>.</param>
/// <param name="legacySupportGridPath">The pre-machine support-grid.json; defaults to <see cref="SupportGridSettings.DefaultPath"/>.</param>
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;
}
/// <summary>%APPDATA%\OpenNest\Machines.</summary>
public static string DefaultMachinesDirectory => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenNest",
"Machines");
/// <summary>Where shared copies come from.</summary>
public SharedSettingsSource Shared { get; }
/// <summary>This PC's nest defaults file.</summary>
public string NestDefaultsPath => nestDefaultsPath;
/// <summary>
/// The machine configurations edited in Tools > Machine Configuration: the server's when
/// it holds any, otherwise this PC's. <paramref name="refresh"/> re-reads the server first.
/// </summary>
public IDataProvider CreateMachineProvider(bool refresh = false) =>
Shared.Holding(SharedSettingKinds.Machine, refresh) is { } shared
? new SharedMachineProvider(shared)
: CreateLocalMachineProvider();
/// <summary>This PC's machine configurations, with the built-in ones written on first use.</summary>
public LocalJsonProvider CreateLocalMachineProvider()
{
var provider = new LocalJsonProvider(machinesDirectory);
provider.EnsureDefaults();
return provider;
}
/// <summary>Every readable machine configuration, for choosing acceleration limits.</summary>
public List<MachineConfig> LoadMachines() => ReadMachines(() => CreateMachineProvider());
/// <summary>Every readable machine configuration on this PC, shared or not, for uploading.</summary>
public List<MachineConfig> LoadLocalMachines() => ReadMachines(CreateLocalMachineProvider);
private static List<MachineConfig> ReadMachines(Func<IDataProvider> createProvider)
{
var machines = new List<MachineConfig>();
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;
}
/// <summary>Every material and grade on file across the readable machine configurations.</summary>
public IEnumerable<MaterialConfig> LoadMaterials() =>
LoadMachines().SelectMany(machine => machine.Materials);
/// <summary>
/// The clamps new nests assigned <paramref name="machine"/> receive (New, BOM import, Load
/// Machine Defaults); off when there is no machine or it has no clamps.
/// </summary>
public static ClampSettings LoadClampSettings(MachineConfig machine) =>
machine?.GetClampSettings() ?? ClampSettings.Fallback;
/// <summary>Saves <paramref name="settings"/> as the clamps of the machine with <paramref name="machineId"/>.</summary>
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);
}
/// <summary>
/// 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 <paramref name="askMachine"/> picks one (null for not
/// now), asked once per session. Returns what to tell the user when the copy failed, or null.
/// </summary>
public string MigrateLegacyClamps(Func<IReadOnlyList<MachineConfig>, 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;
}
/// <summary>
/// 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.
/// </summary>
public static MachineConfig DefaultMachine(NestDefaults defaults, IEnumerable<MachineConfig> machines)
{
var sorted = machines.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
return sorted.FirstOrDefault(m => m.Id == defaults?.MachineId) ?? sorted.FirstOrDefault();
}
/// <summary>The configuration of <paramref name="machine"/>, or null when none is assigned or it is not on file.</summary>
public MachineConfig FindMachine(NestMachine machine)
{
if (machine == null)
return null;
try
{
return CreateMachineProvider().GetMachine(machine.Id);
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
return null;
}
/// <summary>
/// Assigns <paramref name="machine"/> to <paramref name="nest"/> (null for none) and gives
/// every sheet that machine's support grid, or none when the machine gives nests no grid.
/// </summary>
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);
}
/// <summary>
/// The cutting parameters a plate of <paramref name="nest"/> 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.
/// </summary>
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;
}
/// <summary>
/// The cutting parameters to plan or place lead-ins on <paramref name="plate"/> with: the
/// user's (see <see cref="DefaultCuttingParameters"/>), so an edit reaches nests already
/// planned. The plate's own are used only when no user settings are saved or readable.
/// </summary>
public CuttingParameters CuttingParametersFor(Plate plate, Nest nest) =>
plate?.CuttingParameters != null && ReadLastCuttingParameters() == null
? plate.CuttingParameters
: DefaultCuttingParameters(nest);
/// <summary>Whether the cutting settings of <paramref name="nest"/> are kept on its machine's thickness.</summary>
public bool HasMachineThickness(Nest nest) =>
nest != null && MachineCuttingDefaults.Find(FindMachine(nest.Machine), nest) != null;
/// <summary>
/// Where the cutting settings of <paramref name="nest"/> are kept, for the settings
/// dialogs: the machine's thickness, or why this PC's settings are used instead.
/// </summary>
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}";
}
/// <summary>
/// Saves cutting settings edited for <paramref name="nest"/>: 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 <paramref name="warn"/> is told why. Returns whether
/// they reached the machine.
/// </summary>
public bool SaveCuttingParameters(Nest nest, CuttingParameters parameters, Action<string> warn) =>
SaveCuttingParameters(nest, parameters, CreateMachineProvider(), warn);
/// <summary>
/// <see cref="SaveCuttingParameters(Nest, CuttingParameters, Action{string})"/> with the
/// machines of <paramref name="provider"/>.
/// </summary>
public bool SaveCuttingParameters(Nest nest, CuttingParameters parameters, IDataProvider provider, Action<string> 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;
}
/// <summary>Saves <paramref name="parameters"/> as this PC's last cutting settings.</summary>
public void SaveLastCuttingParameters(CuttingParameters parameters) =>
user.CuttingParametersJson = CuttingParametersSerializer.Serialize(parameters);
/// <summary>The cutting parameters last applied, or the built-in defaults when none are saved or readable.</summary>
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;
}
/// <summary>
/// Gives <paramref name="settings"/> 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.
/// </summary>
public void ApplyCutOffDefaults(CutOffSettings settings, Nest nest) =>
MachineCuttingDefaults.ApplyTo(settings, FindMachine(nest.Machine), nest);
/// <summary>
/// The support grid of <paramref name="machine"/>; a machine without one saved yet, or no
/// machine, uses the legacy shared support-grid.json. Never throws, and loads as off when unreadable.
/// </summary>
public SupportGridSettings LoadSupportGridSettings(MachineConfig machine) =>
machine?.SupportGrid ?? SupportGridSettings.Load(legacySupportGridPath);
/// <summary>Saves <paramref name="settings"/> as the support grid of the machine with <paramref name="machineId"/>.</summary>
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);
}
/// <summary>
/// Loads the persisted nest defaults (see <see cref="LoadSavedNestDefaults"/>).
/// <paramref name="warning"/> says, the first time in a session, that they could not be
/// read and the built-in defaults are used; it is null otherwise.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public NestDefaults LoadSavedNestDefaults(out NestDefaultsStatus status) =>
SharedNestDefaults() is { } shared
? NestDefaults.Parse(shared.Json, user.DefaultUnit, out status)
: NestDefaults.Load(nestDefaultsPath, user.DefaultUnit, out status);
/// <summary>This PC's own nest defaults, shared or not, for uploading.</summary>
public NestDefaults LoadLocalNestDefaults() =>
NestDefaults.Load(nestDefaultsPath, user.DefaultUnit, out _);
/// <summary>
/// Saves the nest defaults where they are read: the server once it holds them, otherwise
/// this PC's file. Throws <see cref="IOException"/> when the server cannot take them.
/// </summary>
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);
}
/// <summary>
/// This PC's machines, <paramref name="postProcessors"/>' settings and nest defaults that
/// <paramref name="current"/> does not hold yet (see <see cref="SharedSettingsSource.PendingUploads"/>).
/// </summary>
public IReadOnlyList<SharedSettingUpload> PendingUploads(SharedSettingsStore current, IEnumerable<IPostProcessor> postProcessors) =>
SharedSettingsSource.PendingUploads(current, LoadLocalMachines, postProcessors,
() => File.Exists(nestDefaultsPath) ? LoadLocalNestDefaults().ToJson() : null);
private SharedSettingRecord SharedNestDefaults() =>
Shared.Find(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey);
}
/// <summary>
/// <see cref="IMachineUserSettings"/> kept in the desktop user-settings file
/// (<see cref="UserSettingsFile"/>) under the WinForms names and text: DefaultUnit as the unit's
/// name ("Inches" when missing or unreadable) and CuttingParametersJson as JSON text.
/// </summary>
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<Units>(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 ?? "");
}
}
+263
View File
@@ -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;
/// <summary>A document this PC could upload to a nest server that does not hold it yet.</summary>
public sealed record SharedSettingUpload(string Kind, string Key, string Json, string Label);
/// <summary>
/// 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.
/// </summary>
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<NestStorageSettings> storageSettings;
private readonly Func<string, SharedSettingsStore> createStore;
private readonly Func<DateTime> now;
private SharedSettingsStore store;
private string storeUrl;
private bool offlineWarned;
/// <param name="storageSettings">The current storage mode and server, read on every use.</param>
/// <param name="createStore">Makes the store for a server URL; defaults to <see cref="CreateStore"/>.</param>
/// <param name="now">The clock deciding when the copy is stale; defaults to <see cref="DateTime.Now"/>.</param>
public SharedSettingsSource(
Func<NestStorageSettings> storageSettings,
Func<string, SharedSettingsStore> createStore = null,
Func<DateTime> now = null)
{
this.storageSettings = storageSettings ?? throw new ArgumentNullException(nameof(storageSettings));
this.createStore = createStore ?? CreateStore;
this.now = now ?? (() => DateTime.Now);
}
/// <summary>
/// A source reading the storage mode once from %APPDATA%\OpenNest\storage.json, for a front
/// end that does not change it while running.
/// </summary>
public static SharedSettingsSource FromStorageFile(string path = null)
{
var settings = NestStorageSettings.Load(path ?? NestStorageSettings.DefaultPath);
return new SharedSettingsSource(() => settings);
}
/// <summary>
/// The store for <paramref name="serverUrl"/>: requests give up after five seconds and the
/// last copy read is kept under %APPDATA%\OpenNest\SharedSettings. Throws
/// <see cref="ArgumentException"/> for an invalid URL.
/// </summary>
public static SharedSettingsStore CreateStore(string serverUrl) =>
new(new RemoteSharedSettings(serverUrl, Timeout), CachePath(serverUrl), Environment.UserName);
/// <summary>Where this PC keeps its last copy of a server's shared settings.</summary>
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");
}
/// <summary>
/// The shared settings for the configured server, re-read from it when
/// <paramref name="refresh"/> is set or the copy is stale; null in File mode or when the
/// server predates shared settings.
/// </summary>
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;
}
}
/// <summary>The shared document of <paramref name="kind"/> under <paramref name="key"/>, or null when this PC's own is used.</summary>
public SharedSettingRecord Find(string kind, string key, bool refresh = false) =>
Current(refresh)?.Get(kind, key);
/// <summary>The store when the server holds documents of <paramref name="kind"/>, so they replace this PC's own.</summary>
public SharedSettingsStore Holding(string kind, bool refresh = false) =>
Current(refresh) is { } current && current.List(kind).Count > 0 ? current : null;
/// <summary>Whether the last read of the server failed, so the last copy read is in use.</summary>
public bool IsOffline => store?.IsOffline == true;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Where Tools > Machine Configuration reads and saves, for its title.</summary>
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";
}
/// <summary>
/// Where Tools > Nest Defaults saves (the server once it holds them) and whether it can save there now.
/// </summary>
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);
}
/// <summary>The key of a post's shared settings.</summary>
public static string PostKey(IPostProcessor postProcessor) => postProcessor.GetType().FullName;
/// <summary>
/// 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.
/// </summary>
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}";
}
}
/// <summary>
/// Saves a post's settings where they are read: the server once it holds them, otherwise
/// the post's own file. JSON equal to <paramref name="unchanged"/> (the settings before the
/// dialog opened) is not saved, so posting without editing never writes a shared revision.
/// Throws <see cref="IOException"/> when the server cannot take them.
/// </summary>
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();
}
/// <summary>
/// This PC's machines (with their clamps; only while the server holds none), post settings
/// and nest defaults (when <paramref name="localDefaultsJson"/> gives them) that
/// <paramref name="current"/> does not hold yet. Documents the server holds are never listed.
/// </summary>
public static IReadOnlyList<SharedSettingUpload> PendingUploads(
SharedSettingsStore current,
Func<IEnumerable<MachineConfig>> localMachines,
IEnumerable<IPostProcessor> postProcessors,
Func<string> localDefaultsJson)
{
var uploads = new List<SharedSettingUpload>();
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<IJsonConfigPostProcessor>())
{
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;
}
/// <summary>The question offering <paramref name="uploads"/> to the server.</summary>
public static string DescribeUploads(IEnumerable<SharedSettingUpload> 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.";
/// <summary>
/// Saves each upload to <paramref name="current"/>; returns what to tell the user about the
/// ones that failed, or null when all were uploaded.
/// </summary>
public static string Upload(SharedSettingsStore current, IEnumerable<SharedSettingUpload> uploads)
{
var failures = new List<string>();
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;
}
}
+74 -302
View File
@@ -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
{
/// <summary>
/// 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
/// (<see cref="SharedSettings"/>), 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 <see cref="MachineSettingsSource"/>, reading this
/// app's settings; this shows its warnings (once per session where noted) and questions.
/// </summary>
internal static class MachineSettings
{
/// <summary>The shared machine settings, reading and saving through <see cref="Properties.Settings"/>.</summary>
internal static MachineSettingsSource Source { get; } = new(SharedSettings.Source, new AppUserSettings());
/// <summary>
/// The machine configurations edited in Tools > Machine Configuration: the server's when
/// it holds any, otherwise this PC's. <paramref name="refresh"/> re-reads the server first.
/// </summary>
public static IDataProvider CreateMachineProvider(bool refresh = false) =>
SharedSettings.Holding(SharedSettingKinds.Machine, refresh) is { } shared
? new SharedMachineProvider(shared)
: CreateLocalMachineProvider();
/// <summary>This PC's machine configurations, under %APPDATA%\OpenNest\Machines.</summary>
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);
/// <summary>Every readable machine configuration, for choosing acceleration limits.</summary>
public static List<MachineConfig> LoadMachines() => ReadMachines(() => CreateMachineProvider());
public static List<MachineConfig> LoadMachines() => Source.LoadMachines();
/// <summary>Every readable machine configuration on this PC, shared or not, for uploading.</summary>
public static List<MachineConfig> LoadLocalMachines() => ReadMachines(CreateLocalMachineProvider);
private static List<MachineConfig> ReadMachines(Func<IDataProvider> createProvider)
{
var machines = new List<MachineConfig>();
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<MachineConfig> LoadLocalMachines() => Source.LoadLocalMachines();
/// <summary>Every material and grade on file across the readable machine configurations.</summary>
public static IEnumerable<MaterialConfig> LoadMaterials() =>
LoadMachines().SelectMany(machine => machine.Materials);
public static IEnumerable<MaterialConfig> LoadMaterials() => Source.LoadMaterials();
private static bool defaultsWarned;
private static bool clampMigrationAsked;
/// <summary>
/// The clamps new nests assigned <paramref name="machine"/> receive (New, BOM import, Load
/// Machine Defaults); off when there is no machine or it has no clamps.
/// </summary>
/// <summary>The clamps new nests assigned <paramref name="machine"/> receive.</summary>
public static ClampSettings LoadClampSettings(MachineConfig machine) =>
machine?.GetClampSettings() ?? ClampSettings.Fallback;
MachineSettingsSource.LoadClampSettings(machine);
/// <summary>Saves <paramref name="settings"/> as the clamps of the machine with <paramref name="machineId"/>.</summary>
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);
/// <summary>
/// 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
/// <see cref="MachineSettingsSource.MigrateLegacyClamps"/>), asking which machine when
/// it cannot tell and warning when the copy fails.
/// </summary>
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<MachineConfig> machines)
@@ -160,174 +80,47 @@ namespace OpenNest.Forms
: null;
}
/// <summary>
/// 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.
/// </summary>
public static MachineConfig DefaultMachine(NestDefaults defaults, IEnumerable<MachineConfig> machines)
{
var sorted = machines.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
return sorted.FirstOrDefault(m => m.Id == defaults?.MachineId) ?? sorted.FirstOrDefault();
}
/// <summary>The machine new nests are assigned (see <see cref="MachineSettingsSource.DefaultMachine"/>).</summary>
public static MachineConfig DefaultMachine(NestDefaults defaults, IEnumerable<MachineConfig> machines) =>
MachineSettingsSource.DefaultMachine(defaults, machines);
/// <summary>The configuration of <paramref name="machine"/>, or null when none is assigned or it is not on file.</summary>
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);
/// <summary>
/// Assigns <paramref name="machine"/> to <paramref name="nest"/> (null for none) and gives
/// every sheet that machine's support grid, or none when the machine gives nests no grid.
/// </summary>
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);
}
/// <summary>Assigns <paramref name="machine"/> and its support grid to <paramref name="nest"/>.</summary>
public static void AssignMachine(Nest nest, MachineConfig machine) => Source.AssignMachine(nest, machine);
/// <summary>
/// The cutting parameters a plate of <paramref name="nest"/> 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.
/// </summary>
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;
}
/// <summary>See <see cref="MachineSettingsSource.DefaultCuttingParameters"/>.</summary>
public static CuttingParameters DefaultCuttingParameters(Nest nest) => Source.DefaultCuttingParameters(nest);
/// <summary>
/// The cutting parameters to plan or place lead-ins on <paramref name="plate"/> with: the
/// user's (see <see cref="DefaultCuttingParameters"/>), so an edit reaches nests already
/// planned. The plate's own are used only when no user settings are saved or readable.
/// </summary>
public static CuttingParameters CuttingParametersFor(Plate plate, Nest nest) =>
plate?.CuttingParameters != null && ReadLastCuttingParameters() == null
? plate.CuttingParameters
: DefaultCuttingParameters(nest);
/// <summary>See <see cref="MachineSettingsSource.CuttingParametersFor"/>.</summary>
public static CuttingParameters CuttingParametersFor(Plate plate, Nest nest) => Source.CuttingParametersFor(plate, nest);
/// <summary>Whether the cutting settings of <paramref name="nest"/> are kept on its machine's thickness.</summary>
public static bool HasMachineThickness(Nest nest) =>
nest != null && MachineCuttingDefaults.Find(FindMachine(nest.Machine), nest) != null;
public static bool HasMachineThickness(Nest nest) => Source.HasMachineThickness(nest);
/// <summary>See <see cref="MachineSettingsSource.DescribeCuttingSource"/>.</summary>
public static string DescribeCuttingSource(Nest nest) => Source.DescribeCuttingSource(nest);
/// <summary>
/// Where the cutting settings of <paramref name="nest"/> are kept, for the settings
/// dialogs: the machine's thickness, or why this PC's settings are used instead.
/// </summary>
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}";
}
/// <summary>
/// Saves cutting settings edited for <paramref name="nest"/>: 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 <paramref name="nest"/> (see
/// <see cref="MachineSettingsSource.SaveCuttingParameters(Nest, CuttingParameters, Action{string})"/>),
/// telling the user why they could not reach the machine. Returns whether they did.
/// </summary>
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));
/// <summary>
/// <see cref="SaveCuttingParameters(IWin32Window, Nest, CuttingParameters)"/> with the
/// machines of <paramref name="provider"/>, telling <paramref name="warn"/> why a machine
/// save failed.
/// </summary>
internal static bool SaveCuttingParameters(Nest nest, CuttingParameters parameters, IDataProvider provider, Action<string> 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;
}
/// <summary>The save above with the machines of <paramref name="provider"/>.</summary>
internal static bool SaveCuttingParameters(Nest nest, CuttingParameters parameters, IDataProvider provider, Action<string> warn) =>
Source.SaveCuttingParameters(nest, parameters, provider, warn);
/// <summary>Saves <paramref name="parameters"/> as this PC's last cutting settings.</summary>
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);
/// <summary>The cutting parameters last applied, or the built-in defaults when none are saved or readable.</summary>
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();
/// <summary>
/// Saves a Nest Info dialog to <paramref name="nest"/>: a changed material or thickness
@@ -344,24 +137,15 @@ namespace OpenNest.Forms
/// they regenerate.
/// </summary>
public static void ApplyCutOffDefaults(PlateView view, Nest nest) =>
MachineCuttingDefaults.ApplyTo(view.CutOffSettings, FindMachine(nest.Machine), nest);
Source.ApplyCutOffDefaults(view.CutOffSettings, nest);
/// <summary>
/// The support grid of <paramref name="machine"/>; a machine without one saved yet, or no
/// machine, uses the legacy shared support-grid.json. Never throws, and loads as off when unreadable.
/// </summary>
/// <summary>See <see cref="MachineSettingsSource.LoadSupportGridSettings"/>.</summary>
public static SupportGridSettings LoadSupportGridSettings(MachineConfig machine) =>
machine?.SupportGrid ?? SupportGridSettings.Load(SupportGridSettings.DefaultPath);
Source.LoadSupportGridSettings(machine);
/// <summary>Saves <paramref name="settings"/> as the support grid of the machine with <paramref name="machineId"/>.</summary>
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);
/// <summary>
/// Loads the persisted nest defaults (see <see cref="LoadSavedNestDefaults"/>);
@@ -369,49 +153,24 @@ namespace OpenNest.Forms
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <summary>See <see cref="MachineSettingsSource.LoadSavedNestDefaults"/>.</summary>
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);
/// <summary>This PC's own nest defaults, shared or not, for uploading.</summary>
public static NestDefaults LoadLocalNestDefaults() =>
NestDefaults.Load(NestDefaults.DefaultPath, Properties.Settings.Default.DefaultUnit, out _);
public static NestDefaults LoadLocalNestDefaults() => Source.LoadLocalNestDefaults();
/// <summary>
/// Saves the nest defaults where they are read: the server once it holds them, otherwise
/// this PC's file. Throws <see cref="IOException"/> when the server cannot take them.
/// this PC's file. Throws <see cref="System.IO.IOException"/> when the server cannot take them.
/// </summary>
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);
/// <summary>Saves the nest defaults, telling the user when they could not be saved.</summary>
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);
/// <summary>The app settings the shared rules read and save.</summary>
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();
}
}
}
}
}
+29 -161
View File
@@ -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
{
/// <summary>
/// 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
/// <see cref="SharedSettingsSource"/>, following this app's storage mode; this shows its
/// warnings and questions.
/// </summary>
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;
/// <summary>The shared settings for the storage mode and server <see cref="NestStorage"/> has now.</summary>
internal static SharedSettingsSource Source { get; } = new(() => NestStorage.Settings);
/// <summary>
/// The shared settings for the configured server, re-read from it when
/// <paramref name="refresh"/> is set or the copy is stale; null in File mode or when the
/// server predates shared settings.
/// </summary>
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);
/// <summary>The shared document of <paramref name="kind"/> under <paramref name="key"/>, or null when this PC's own is used.</summary>
public static SharedSettingRecord Find(string kind, string key, bool refresh = false) =>
Current(refresh)?.Get(kind, key);
Source.Find(kind, key, refresh);
/// <summary>The store when the server holds documents of <paramref name="kind"/>, so they replace this PC's own.</summary>
public static SharedSettingsStore Holding(string kind, bool refresh = false) =>
Current(refresh) is { } current && current.List(kind).Count > 0 ? current : null;
/// <summary>Where this PC keeps its last copy of a server's shared settings.</summary>
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);
/// <summary>
/// Tells the user once per session that the server could not be reached and the last
@@ -91,42 +36,21 @@ namespace OpenNest.Forms
/// </summary>
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);
}
/// <summary>Where Tools > Machine Configuration reads and saves, for its title.</summary>
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);
/// <summary>
/// Where Tools > Nest Defaults saves (the server once it holds them, as
/// <see cref="MachineSettings.SaveNestDefaults"/> does) and whether it can save there now.
/// <see cref="MachineSettings.SaveNestDefaults(NestDefaults)"/> does) and whether it can save there now.
/// </summary>
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();
/// <summary>The key of a post's shared settings.</summary>
public static string PostKey(IPostProcessor postProcessor) => postProcessor.GetType().FullName;
public static string PostKey(IPostProcessor postProcessor) => SharedSettingsSource.PostKey(postProcessor);
/// <summary>
/// Gives <paramref name="postProcessor"/> 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;
}
/// <summary>
/// Saves a post's settings where they are read: the server once it holds them, otherwise
/// the post's own file. JSON equal to <paramref name="unchanged"/> (the settings before the
/// dialog opened) is not saved, so posting without editing never writes a shared revision.
/// Throws <see cref="IOException"/> when the server cannot take them.
/// Saves a post's settings where they are read (see
/// <see cref="SharedSettingsSource.SavePostSettings"/>). Throws
/// <see cref="System.IO.IOException"/> when the server cannot take them.
/// </summary>
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);
/// <summary>
/// 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<IJsonConfigPostProcessor>())
{
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<string>();
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);
}
}
}
+3 -1
View File
@@ -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"