diff --git a/OpenNest.Desktop.Tests/DesktopOptionsTests.cs b/OpenNest.Desktop.Tests/DesktopOptionsTests.cs new file mode 100644 index 00000000..9029432a --- /dev/null +++ b/OpenNest.Desktop.Tests/DesktopOptionsTests.cs @@ -0,0 +1,336 @@ +using OpenNest.Data; +using OpenNest.Engine.Fill; +using OpenNest.Engine.Strategies; + +namespace OpenNest.Desktop.Tests; + +public class DesktopOptionsTests +{ + // As the WinForms app stores them in user-settings.json. + private static Dictionary WinFormsSaved() => new() + { + ["CreateNewNestOnOpen"] = "False", + ["AutoSizePlateFactor"] = "0.25", + ["CuttingPlanEffort"] = "2.5", + ["ActiveColorScheme"] = "Dark", + ["ClonePlacementOrder"] = "VerticalFirst", + ["DisabledStrategies"] = "Pairs,Extents", + ["UpdateIncludeDevBuilds"] = "True", + ["UpdateFeedUrl"] = "http://feed.example/releases", + ["LastFeedrate"] = "250", + }; + + [Fact] + public void Read_TheWinFormsValues() + { + var options = DesktopOptions.Read(WinFormsSaved()); + + Assert.False(options.CreateNewNestOnOpen); + Assert.Equal(0.25, options.AutoSizePlateFactor); + Assert.Equal(2.5, options.CuttingPlanEffort); + Assert.Equal("Dark", options.ColorScheme); + Assert.Equal(SettleOrder.VerticalFirst, options.ClonePlacementOrder); + Assert.Equal(new[] { "Extents", "Pairs" }, options.DisabledStrategies.OrderBy(n => n)); + Assert.True(options.UpdateIncludeDevBuilds); + } + + [Fact] + public void Read_NothingSaved_IsTheWinFormsDefaults() + { + var options = DesktopOptions.Read(new Dictionary()); + + Assert.True(options.CreateNewNestOnOpen); + Assert.Equal(0.5, options.AutoSizePlateFactor); + Assert.Equal(1.0, options.CuttingPlanEffort); + Assert.Equal("Classic", options.ColorScheme); + Assert.Equal(SettleOrder.ClosestToCorner, options.ClonePlacementOrder); + Assert.Empty(options.DisabledStrategies); + Assert.False(options.UpdateIncludeDevBuilds); + } + + [Fact] + public void Read_UnreadableValues_AreTheWinFormsDefaults() + { + var options = DesktopOptions.Read(new Dictionary + { + ["CreateNewNestOnOpen"] = "maybe", + ["AutoSizePlateFactor"] = "half", + ["CuttingPlanEffort"] = "", + ["ClonePlacementOrder"] = "Sideways", + ["DisabledStrategies"] = " , ,", + ["UpdateIncludeDevBuilds"] = "1", + }); + + Assert.Equal(DesktopOptions.Defaults with { DisabledStrategies = options.DisabledStrategies }, options); + Assert.Empty(options.DisabledStrategies); + } + + [Fact] + public void WriteTo_GivesBackTheWinFormsTextUnchanged_AndKeepsOtherSettings() + { + var stored = WinFormsSaved(); + + var written = new Dictionary(stored); + DesktopOptions.Read(stored).WriteTo(written); + + // The set is written back in its own order; the rest is the stored text. + Assert.Equal( + FillStrategyOptions.ParseDisabled(stored["DisabledStrategies"]), + FillStrategyOptions.ParseDisabled(written["DisabledStrategies"])); + written["DisabledStrategies"] = stored["DisabledStrategies"]; + Assert.Equal(stored, written); + } + + [Fact] + public void WriteTo_TheWinFormsTextForms_AndNeverTheFeedUrl() + { + var written = new Dictionary(); + + new DesktopOptions + { + AutoSizePlateFactor = 0.1, + CuttingPlanEffort = 3, + ColorScheme = "", + DisabledStrategies = new[] { "Row" }, + }.WriteTo(written); + + Assert.Equal("True", written["CreateNewNestOnOpen"]); + Assert.Equal("False", written["UpdateIncludeDevBuilds"]); + Assert.Equal("0.1", written["AutoSizePlateFactor"]); + Assert.Equal("3", written["CuttingPlanEffort"]); + Assert.Equal("Classic", written["ActiveColorScheme"]); + Assert.Equal("ClosestToCorner", written["ClonePlacementOrder"]); + Assert.Equal("Row", written["DisabledStrategies"]); + Assert.False(written.ContainsKey("UpdateFeedUrl")); + } + + [Theory] + [InlineData(0.1, 0.5)] + [InlineData(25.0, 10.0)] + [InlineData(double.NaN, 1.0)] + [InlineData(4.0, 4.0)] + public void CuttingPlanEffortForEditing_IsTheValuePlanCuttingUses(double saved, double shown) + { + Assert.Equal(shown, new DesktopOptions { CuttingPlanEffort = saved }.CuttingPlanEffortForEditing); + } + + [Theory] + [InlineData(-1.0, 0.0)] + [InlineData(250.0, 100.0)] + [InlineData(double.PositiveInfinity, 0.5)] + [InlineData(0.375, 0.375)] + public void AutoSizePlateFactorForEditing_IsWithinTheBoxRange(double saved, double shown) + { + Assert.Equal(shown, new DesktopOptions { AutoSizePlateFactor = saved }.AutoSizePlateFactorForEditing); + } + + [Fact] + public void ColorSchemeIndex_ExactName_ElseTheFirst() + { + var names = new[] { "Classic", "Pastel", "Dark" }; + + Assert.Equal(2, new DesktopOptions { ColorScheme = "Dark" }.ColorSchemeIndex(names)); + Assert.Equal(0, new DesktopOptions { ColorScheme = "dark" }.ColorSchemeIndex(names)); + Assert.Equal(0, new DesktopOptions { ColorScheme = "Gone" }.ColorSchemeIndex(names)); + } + + [Fact] + public void ClonePlacement_ChoicesInComboOrder() + { + Assert.Equal( + new[] { SettleOrder.ClosestToCorner, SettleOrder.HorizontalFirst, SettleOrder.VerticalFirst }, + ClonePlacement.Choices.Select(c => c.Order)); + Assert.Equal(1, ClonePlacement.IndexOf(SettleOrder.HorizontalFirst)); + Assert.Equal(SettleOrder.VerticalFirst, ClonePlacement.At(2)); + Assert.Equal(SettleOrder.ClosestToCorner, ClonePlacement.At(-1)); + } + + [Theory] + [InlineData("HorizontalFirst", SettleOrder.HorizontalFirst)] + [InlineData("7", SettleOrder.ClosestToCorner)] + [InlineData(null, SettleOrder.ClosestToCorner)] + [InlineData("", SettleOrder.ClosestToCorner)] + public void ClonePlacement_Parse_UnknownIsClosestToOrigin(string? stored, SettleOrder expected) + { + Assert.Equal(expected, ClonePlacement.Parse(stored!)); + } + + [Fact] + public void FillStrategyOptions_ParseIgnoresCaseAndBlanks() + { + var names = FillStrategyOptions.ParseDisabled(" pairs , ,Row,ROW"); + + Assert.Equal(2, names.Count); + Assert.Contains("Pairs", names); + Assert.Contains("row", names); + Assert.Empty(FillStrategyOptions.ParseDisabled(null!)); + } + + [Fact] + public void FillStrategyOptions_RowsListEveryStrategyInPipelineOrder() + { + Assert.Equal( + FillStrategyRegistry.AllStrategies.Select(s => (s.Name, s.Phase, s.Order)), + FillStrategyOptions.Rows.Select(r => (r.Name, r.Phase, r.Order))); + } + + [Fact] + public void UpdateFeed_SavesTheNormalizedUrl() + { + string? saved = null; + + var (notice, url) = UpdateFeedOption.Save(" https://feed.example/opennest/ ", u => saved = u); + + Assert.Equal("https://feed.example/opennest", url); + Assert.Equal(url, saved); + Assert.Equal(OptionNoticeKind.Information, notice.Kind); + Assert.Contains("Restart OpenNest", notice.Text); + } + + [Fact] + public void UpdateFeed_BlankClearsIt() + { + string? saved = null; + + var (notice, url) = UpdateFeedOption.Save(" ", u => saved = u); + + Assert.Equal("", url); + Assert.Equal("", saved); + Assert.StartsWith("Update feed URL cleared.", notice.Text); + } + + [Theory] + [InlineData("ftp://feed.example")] + [InlineData("not a url")] + public void UpdateFeed_RefusesAnythingButHttp(string text) + { + var called = false; + + var (notice, url) = UpdateFeedOption.Save(text, _ => called = true); + + Assert.Null(url); + Assert.False(called); + Assert.Equal(OptionNoticeKind.Warning, notice.Kind); + } + + [Fact] + public void UpdateFeed_AFailedWriteIsReported() + { + var (notice, url) = UpdateFeedOption.Save("http://feed", _ => throw new IOException("disk full")); + + Assert.Null(url); + Assert.Equal(OptionNoticeKind.Error, notice.Kind); + Assert.Contains("disk full", notice.Text); + } + + [Fact] + public void UpdateFeed_ReadsTheSavedUrl() + { + Assert.Equal("http://feed.example/releases", UpdateFeedOption.Read(WinFormsSaved())); + Assert.Equal("", UpdateFeedOption.Read(new Dictionary())); + } + + [Fact] + public void ServerUrl_RefusesAnInvalidUrl() + { + var (notice, url) = NestServerUrlOption.Save("", _ => throw new InvalidOperationException()); + + Assert.Null(url); + Assert.Equal(OptionNoticeKind.Warning, notice.Kind); + Assert.Equal("Enter a valid http or https server URL.", notice.Text); + } + + [Fact] + public void ServerUrl_AFailedWriteIsReported() + { + var (notice, url) = NestServerUrlOption.Save("http://nest:8080", _ => throw new IOException("locked")); + + Assert.Null(url); + Assert.Equal(OptionNoticeKind.Error, notice.Kind); + Assert.Equal("Could not save the server URL: locked", notice.Text); + } + + [Fact] + public void ServerUrl_SaveToFileKeepsTheStorageMode() + { + var directory = Path.Combine(Path.GetTempPath(), "opennest-options-" + Guid.NewGuid().ToString("N")); + var path = Path.Combine(directory, "storage.json"); + try + { + new NestStorageSettings { Mode = NestStorageMode.Database, ServerUrl = "http://old:8080" }.Save(path); + + var (notice, url) = NestServerUrlOption.Save(" http://nest.lan:8080/ ", + u => NestServerUrlOption.SaveToFile(path, u)); + + Assert.Equal(OptionNoticeKind.Information, notice.Kind); + Assert.Equal("http://nest.lan:8080", url); + var saved = NestStorageSettings.Load(path); + Assert.Equal(NestStorageMode.Database, saved.Mode); + Assert.Equal("http://nest.lan:8080", saved.ServerUrl); + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, true); + } + } + + [Fact] + public void ServerUrl_SaveToFileWithNoFileIsFileMode() + { + var directory = Path.Combine(Path.GetTempPath(), "opennest-options-" + Guid.NewGuid().ToString("N")); + var path = Path.Combine(directory, "storage.json"); + try + { + var saved = NestServerUrlOption.SaveToFile(path, "http://nest:8080"); + + Assert.Equal(NestStorageMode.File, saved.Mode); + Assert.Equal("http://nest:8080", NestStorageSettings.Load(path).ServerUrl); + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, true); + } + } +} + +// Applying options changes the process-wide strategy set and colour scheme. +[Collection(ColorSchemeState.Collection)] +public class DesktopOptionsApplyTests : IDisposable +{ + private readonly ColorSchemeState saved = new(); + private readonly string[] disabledBefore = FillStrategyRegistry.DisabledNames.ToArray(); + + public void Dispose() + { + FillStrategyOptions.Apply(disabledBefore); + saved.Restore(); + } + + [Fact] + public void Apply_DisablesExactlyTheSavedStrategies_AndUsesTheScheme() + { + var all = FillStrategyRegistry.AllStrategies.Select(s => s.Name).ToArray(); + FillStrategyRegistry.Disable(all[0]); + + new DesktopOptions + { + ColorScheme = "Dark", + DisabledStrategies = new[] { all[1].ToUpperInvariant() }, + }.Apply(); + + Assert.Equal(all.Where(n => n != all[1]), FillStrategyRegistry.Strategies.Select(s => s.Name)); + Assert.Equal("Dark", ColorScheme.Default.Name); + } + + [Fact] + public void ApplySaved_NoneSavedEnablesEveryStrategy() + { + FillStrategyRegistry.Disable(FillStrategyRegistry.AllStrategies[0].Name); + + FillStrategyOptions.ApplySaved(new Dictionary()); + + Assert.Equal(FillStrategyRegistry.AllStrategies, FillStrategyRegistry.Strategies); + } +} diff --git a/OpenNest.Desktop/DesktopOptions.cs b/OpenNest.Desktop/DesktopOptions.cs new file mode 100644 index 00000000..25179504 --- /dev/null +++ b/OpenNest.Desktop/DesktopOptions.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Data; +using OpenNest.Engine; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Engine.Fill; +using OpenNest.Engine.Strategies; + +namespace OpenNest.Desktop; + +/// +/// The values Tools > Options saves with its Save button, under the WinForms user-setting +/// names both desktop apps share in user-settings.json. A missing or unreadable value is its +/// WinForms default. The update feed URL and the nest server URL have their own buttons +/// (, ), so Save leaves them alone. +/// +public sealed record DesktopOptions +{ + public const string CreateNewNestOnOpenName = "CreateNewNestOnOpen"; + public const string AutoSizePlateFactorName = "AutoSizePlateFactor"; + public const string CuttingPlanEffortName = "CuttingPlanEffort"; + public const string ClonePlacementOrderName = "ClonePlacementOrder"; + public const string DisabledStrategiesName = "DisabledStrategies"; + public const string UpdateIncludeDevBuildsName = "UpdateIncludeDevBuilds"; + + /// The auto-size factor's range and step, as the WinForms box allows. + public const double AutoSizePlateFactorMinimum = 0; + public const double AutoSizePlateFactorMaximum = 100; + public const double AutoSizePlateFactorIncrement = 0.125; + public const int AutoSizePlateFactorDecimals = 4; + + /// Cut planning effort's step; its range is Plan Cutting's budget multiplier range. + public const double CuttingPlanEffortIncrement = 0.1; + + public static double CuttingPlanEffortMinimum => CuttingPlanBatch.MinimumBudgetMultiplier; + public static double CuttingPlanEffortMaximum => CuttingPlanBatch.MaximumBudgetMultiplier; + + /// The WinForms setting defaults. + public static DesktopOptions Defaults { get; } = new(); + + /// Create a new nest when the app starts. + public bool CreateNewNestOnOpen { get; init; } = true; + + /// The amount Resize Plate rounds the plate size up to. + public double AutoSizePlateFactor { get; init; } = 0.5; + + /// Plan Cutting's search budget multiplier (1 is normal). + public double CuttingPlanEffort { get; init; } = CuttingPlanBatch.DefaultBudgetMultiplier; + + /// The colour scheme's name, as stores it. + public string ColorScheme { get; init; } = ColorSchemeRegistry.DefaultSchemeName; + + /// Which way Shift+click and Ctrl+click clones push the copy. + public SettleOrder ClonePlacementOrder { get; init; } = SettleOrder.ClosestToCorner; + + /// Fill strategies left out of the pipeline, stored comma-separated by name. + public IReadOnlyCollection DisabledStrategies { get; init; } = Array.Empty(); + + /// Check the rolling development-build release as well as stable releases. + public bool UpdateIncludeDevBuilds { get; init; } + + /// The saved values; one missing or unreadable is its WinForms default. + public static DesktopOptions Read(IReadOnlyDictionary settings) + { + ArgumentNullException.ThrowIfNull(settings); + return new DesktopOptions + { + CreateNewNestOnOpen = UserSettingText.ReadBool(settings, CreateNewNestOnOpenName, Defaults.CreateNewNestOnOpen), + AutoSizePlateFactor = UserSettingText.ReadDouble(settings, AutoSizePlateFactorName, Defaults.AutoSizePlateFactor), + CuttingPlanEffort = UserSettingText.ReadDouble(settings, CuttingPlanEffortName, Defaults.CuttingPlanEffort), + ColorScheme = ColorSchemeRegistry.SavedSchemeName(settings), + ClonePlacementOrder = ClonePlacement.Parse( + UserSettingText.ReadString(settings, ClonePlacementOrderName, null)), + DisabledStrategies = FillStrategyOptions.ParseDisabled( + UserSettingText.ReadString(settings, DisabledStrategiesName, "")), + UpdateIncludeDevBuilds = UserSettingText.ReadBool(settings, UpdateIncludeDevBuildsName, Defaults.UpdateIncludeDevBuilds), + }; + } + + /// + /// Stores the values Save writes, in the WinForms text form; other settings are kept, and + /// the cut planning effort is stored as entered (Plan Cutting clamps it when it reads it). + /// + public void WriteTo(IDictionary settings) + { + ArgumentNullException.ThrowIfNull(settings); + settings[CreateNewNestOnOpenName] = UserSettingText.Format(CreateNewNestOnOpen); + settings[UpdateIncludeDevBuildsName] = UserSettingText.Format(UpdateIncludeDevBuilds); + settings[AutoSizePlateFactorName] = UserSettingText.Format(AutoSizePlateFactor); + settings[CuttingPlanEffortName] = UserSettingText.Format(CuttingPlanEffort); + settings[ColorSchemeRegistry.SettingName] = string.IsNullOrEmpty(ColorScheme) + ? ColorSchemeRegistry.DefaultSchemeName + : ColorScheme; + settings[ClonePlacementOrderName] = ClonePlacementOrder.ToString(); + settings[DisabledStrategiesName] = FillStrategyOptions.FormatDisabled(DisabledStrategies); + } + + /// The colour schemes to choose from: the built-ins, then those in Schemes. + public static IReadOnlyList ColorSchemeNames => + ColorSchemeRegistry.AllSchemes.Select(s => s.Name).ToList(); + + /// + /// The index of the saved scheme in (exact name), or the first + /// scheme when it is no longer there, as the WinForms combo selects it. + /// + public int ColorSchemeIndex(IReadOnlyList names) + { + ArgumentNullException.ThrowIfNull(names); + for (var i = 0; i < names.Count; i++) + { + if (names[i] == ColorScheme) + return i; + } + return 0; + } + + /// The auto-size factor as the Options box shows it: within its range. + public double AutoSizePlateFactorForEditing => + double.IsFinite(AutoSizePlateFactor) + ? System.Math.Clamp(AutoSizePlateFactor, AutoSizePlateFactorMinimum, AutoSizePlateFactorMaximum) + : Defaults.AutoSizePlateFactor; + + /// The cut planning effort as the Options box shows it: the value Plan Cutting uses. + public double CuttingPlanEffortForEditing => CuttingPlanBatch.ClampBudgetMultiplier(CuttingPlanEffort); + + /// + /// Applies what takes effect without a restart: the fill strategy enablement and the colour + /// scheme (whose lets each front end recolour and + /// repaint). Run after Save and, from the saved settings, at startup. + /// + public void Apply() + { + FillStrategyOptions.Apply(DisabledStrategies); + ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get(ColorScheme)); + } +} + +/// The Options choices for Shift+click and Ctrl+click clone placement, in combo order. +public static class ClonePlacement +{ + public static IReadOnlyList<(SettleOrder Order, string Text)> Choices { get; } = new[] + { + (SettleOrder.ClosestToCorner, "Closest to origin"), + (SettleOrder.HorizontalFirst, "Push horizontally first"), + (SettleOrder.VerticalFirst, "Push vertically first"), + }; + + /// The stored enum name's order; an unknown name is the closest-to-origin search. + public static SettleOrder Parse(string stored) => + Enum.TryParse(stored, out var order) && Enum.IsDefined(order) + ? order + : SettleOrder.ClosestToCorner; + + /// The combo index of , the first for one not listed. + public static int IndexOf(SettleOrder order) + { + for (var i = 0; i < Choices.Count; i++) + { + if (Choices[i].Order == order) + return i; + } + return 0; + } + + /// The order at a combo index; no selection is the first choice. + public static SettleOrder At(int index) => Choices[System.Math.Clamp(index, 0, Choices.Count - 1)].Order; +} + +/// A fill strategy as the Options grid lists it. +public sealed record FillStrategyRow(string Name, NestPhase Phase, int Order); + +/// +/// The Options fill strategy grid and the DisabledStrategies setting: names separated by commas, +/// matched without regard to case, applied to . +/// +public static class FillStrategyOptions +{ + /// Every built-in strategy, in pipeline order. + public static IReadOnlyList Rows => + FillStrategyRegistry.AllStrategies.Select(s => new FillStrategyRow(s.Name, s.Phase, s.Order)).ToList(); + + /// The names in a stored value; blank entries are skipped. + public static HashSet ParseDisabled(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return new HashSet(StringComparer.OrdinalIgnoreCase); + + return new HashSet( + value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0), + StringComparer.OrdinalIgnoreCase); + } + + /// The stored form of . + public static string FormatDisabled(IEnumerable names) => + names == null ? "" : string.Join(",", names); + + /// Re-enables every strategy, then disables . + public static void Apply(IEnumerable disabled) + { + FillStrategyRegistry.Enable(FillStrategyRegistry.AllStrategies.Select(s => s.Name).ToArray()); + var names = disabled?.ToArray() ?? Array.Empty(); + if (names.Length > 0) + FillStrategyRegistry.Disable(names); + } + + /// Applies the DisabledStrategies value stored in . + public static void ApplySaved(IReadOnlyDictionary settings) => + Apply(ParseDisabled(UserSettingText.ReadString(settings, DesktopOptions.DisabledStrategiesName, ""))); +} + +public enum OptionNoticeKind +{ + Information, + Warning, + Error, +} + +/// What to tell the user after an Options button, and how. +public sealed record OptionNotice(string Title, string Text, OptionNoticeKind Kind); + +/// +/// Tools > Options > Set / Change Server: the nest server URL in storage.json, keeping the +/// storage mode (File / Database is chosen under File > Storage Mode). +/// +public static class NestServerUrlOption +{ + public const string Title = "Server URL"; + + /// + /// Validates and passes the normalized URL to , + /// which writes storage.json; returns what to tell the user and the URL saved (null when not). + /// + public static (OptionNotice Notice, string SavedUrl) Save(string text, Action save) + { + ArgumentNullException.ThrowIfNull(save); + if (!NestStorageSettings.TryNormalizeServerUrl(text, out var url)) + return (new OptionNotice(Title, "Enter a valid http or https server URL.", OptionNoticeKind.Warning), null); + + try + { + save(url); + return (new OptionNotice(Title, "Server URL saved. Storage mode has not changed.", + OptionNoticeKind.Information), url); + } + catch (Exception ex) + { + return (new OptionNotice(Title, $"Could not save the server URL: {ex.Message}", OptionNoticeKind.Error), null); + } + } + + /// Writes into the storage.json at , keeping its mode. + public static NestStorageSettings SaveToFile(string path, string serverUrl) + { + var next = new NestStorageSettings + { + Mode = NestStorageSettings.Load(path).Mode, + ServerUrl = serverUrl, + }; + next.Save(path); + return NestStorageSettings.Load(path); + } +} + +/// +/// Tools > Options > Save Feed URL: the UpdateFeedUrl user setting. Blank means the default +/// source: the nest server's mirror in Database mode (GitHub Releases if it has none), otherwise +/// GitHub Releases. A change takes effect after a restart. +/// +public static class UpdateFeedOption +{ + public const string SettingName = "UpdateFeedUrl"; + public const string Title = "Update Feed URL"; + + /// The saved feed URL; blank (the default) when none. + public static string Read(IReadOnlyDictionary settings) => + UserSettingText.ReadString(settings, SettingName, ""); + + /// + /// Validates (blank clears it) and passes the normalized URL to + /// ; returns what to tell the user and the URL saved (null when not). + /// + public static (OptionNotice Notice, string SavedUrl) Save(string text, Action save) + { + ArgumentNullException.ThrowIfNull(save); + text = text?.Trim() ?? ""; + string url; + string saved; + if (text.Length == 0) + { + url = ""; + saved = "Update feed URL cleared. After a restart, updates come from " + + "the nest server's mirror in Database mode (GitHub Releases if it has none), " + + "otherwise from GitHub Releases."; + } + else if (NestStorageSettings.TryNormalizeServerUrl(text, out url)) + { + saved = "Update feed URL saved. Restart OpenNest for the new feed to take effect."; + } + else + { + return (new OptionNotice(Title, "Enter a valid http or https feed URL, or clear the field to " + + "use the default source: the nest server's mirror in Database mode, " + + "otherwise GitHub Releases.", OptionNoticeKind.Warning), null); + } + + try + { + save(url); + return (new OptionNotice(Title, saved, OptionNoticeKind.Information), url); + } + catch (Exception ex) + { + return (new OptionNotice(Title, $"Could not save the update feed URL: {ex.Message}", OptionNoticeKind.Error), null); + } + } +}