diff --git a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs index 3c06d46..63eed90 100644 --- a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs +++ b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs @@ -158,4 +158,148 @@ public class AppConfigStoreTests Assert.AreEqual(config.ColumnWidth.Preset, reloaded.ColumnWidth.Preset); Assert.AreEqual(config.Confirmations.DiscardEdit, reloaded.Confirmations.DiscardEdit); } + + [TestMethod] + public void LoadNonObjectRootBacksUpAndReturnsDefaults() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + File.WriteAllText(path, "[1, 2]"); + var store = new AppConfigStore(path); + + Assert.AreEqual(AppConfig.DefaultLanguage, store.Load().Language); + Assert.AreEqual("[1, 2]", File.ReadAllText(path + ".bak")); + } + + [TestMethod] + [DataRow("\"theme\": \"sepia\"")] + [DataRow("\"theme\": 1")] + [DataRow("\"theme\": {}")] + [DataRow("\"language\": \"fr\"")] + [DataRow("\"language\": \"zh-hans\"")] + [DataRow("\"language\": 5")] + [DataRow("\"sort\": { \"card\": \"created-at\" }")] + [DataRow("\"sort\": 7")] + [DataRow("\"column-width\": { \"preset\": \"huge\", \"custom-pixels\": 159 }")] + [DataRow("\"column-width\": { \"custom-pixels\": 721 }")] + [DataRow("\"column-width\": { \"custom-pixels\": \"wide\" }")] + [DataRow("\"confirmations\": { \"delete-card\": \"nope\" }")] + [DataRow("\"version\": \"one\"")] + public void InvalidFieldFallsBackAloneWithoutBackup(string invalidMember) + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + + // The valid fields are non-defaults, so any whole-file fallback would show. + File.WriteAllText(path, $$""" + { + "theme": "dark", + "confirmations": { "discard-edit": false }, + {{invalidMember}} + } + """); + var store = new AppConfigStore(path); + + AppConfig config = store.Load(); + + Assert.IsFalse(File.Exists(path + ".bak")); + AppConfig defaults = new(); + Assert.AreEqual(invalidMember.Contains("theme") ? defaults.Theme : ThemeOption.Dark, config.Theme); + if (!invalidMember.Contains("confirmations")) + { + Assert.IsFalse(config.Confirmations.DiscardEdit); + } + Assert.AreEqual(defaults.Language, config.Language); + Assert.AreEqual(defaults.Sort.Card, config.Sort.Card); + Assert.AreEqual(defaults.ColumnWidth.Preset, config.ColumnWidth.Preset); + Assert.AreEqual(defaults.ColumnWidth.CustomPixels, config.ColumnWidth.CustomPixels); + Assert.AreEqual(defaults.Confirmations.DeleteCard, config.Confirmations.DeleteCard); + Assert.AreEqual(defaults.Version, config.Version); + } + + [TestMethod] + public void InvalidNestedFieldKeepsItsSiblings() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + File.WriteAllText(path, """{ "column-width": { "preset": "bogus", "custom-pixels": 500 } }"""); + + AppConfig config = new AppConfigStore(path).Load(); + + Assert.AreEqual(ColumnWidthPreset.Standard, config.ColumnWidth.Preset); + Assert.AreEqual(500, config.ColumnWidth.CustomPixels); + } + + [TestMethod] + public void ColumnWidthBoundsAreInclusive() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + + File.WriteAllText(path, """{ "column-width": { "custom-pixels": 160 } }"""); + Assert.AreEqual(160, new AppConfigStore(path).Load().ColumnWidth.CustomPixels); + + File.WriteAllText(path, """{ "column-width": { "custom-pixels": 720 } }"""); + Assert.AreEqual(720, new AppConfigStore(path).Load().ColumnWidth.CustomPixels); + } + + [TestMethod] + public void UnchangedConfigurationIsNotWritten() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + var store = new AppConfigStore(path); + + AppConfig config = store.Load(); + + Assert.IsFalse(store.SaveIfChanged(config)); + Assert.IsFalse(File.Exists(path)); + } + + [TestMethod] + public void FieldFallbackAloneDoesNotRewriteTheFile() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + const string original = """{ "theme": "sepia" }"""; + File.WriteAllText(path, original); + var store = new AppConfigStore(path); + + AppConfig config = store.Load(); + + Assert.IsFalse(store.SaveIfChanged(config)); + Assert.AreEqual(original, File.ReadAllText(path)); + } + + [TestMethod] + public void ChangedConfigurationIsWrittenOnce() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + var store = new AppConfigStore(path); + AppConfig config = store.Load(); + + config.Sort.Card = CardSortOption.UpdatedAt; + + Assert.IsTrue(store.SaveIfChanged(config)); + Assert.AreEqual(CardSortOption.UpdatedAt, new AppConfigStore(path).Load().Sort.Card); + + // Saved state is the new baseline. + Assert.IsFalse(store.SaveIfChanged(config)); + } + + [TestMethod] + public void ChangeRevertedWithinSessionIsNotWritten() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + var store = new AppConfigStore(path); + AppConfig config = store.Load(); + + config.Theme = ThemeOption.Dark; + config.Theme = ThemeOption.FollowSystem; + + Assert.IsFalse(store.SaveIfChanged(config)); + Assert.IsFalse(File.Exists(path)); + } } diff --git a/YKanBan/Services/AppServices.cs b/YKanBan/Services/AppServices.cs index ac6b593..37cb201 100644 --- a/YKanBan/Services/AppServices.cs +++ b/YKanBan/Services/AppServices.cs @@ -6,7 +6,7 @@ namespace YKanBan.Services; /// /// Composition root for the whole process: owns the loaded configuration and /// the single open workspace session. The configuration is persisted once at -/// shutdown; the session (lock plus connection) lives until disposal. +/// shutdown, and only when this session changed it; the session (lock plus connection) lives until disposal. /// public sealed class AppServices : IDisposable { @@ -49,9 +49,9 @@ public sealed class AppServices : IDisposable } /// - /// Persists the configuration; called once at shutdown. + /// Persists the configuration when this session changed a setting; called once at shutdown. /// - public void SaveConfiguration() => ConfigStore.Save(Config); + public void SaveConfiguration() => ConfigStore.SaveIfChanged(Config); /// /// Releases the workspace session, if any. diff --git a/YKanBan/Storage/AppData/AppConfig.cs b/YKanBan/Storage/AppData/AppConfig.cs index 65beb6a..ec52fc4 100644 --- a/YKanBan/Storage/AppData/AppConfig.cs +++ b/YKanBan/Storage/AppData/AppConfig.cs @@ -141,6 +141,26 @@ public sealed class AppConfig /// public const string DefaultLanguage = "en"; + /// + /// Simplified Chinese UI language code. + /// + public const string SimplifiedChineseLanguage = "zh-Hans"; + + /// + /// Gets the supported UI language codes; any other stored value falls back to . + /// + public static IReadOnlyList SupportedLanguages { get; } = [DefaultLanguage, SimplifiedChineseLanguage]; + + /// + /// Smallest accepted custom column width in pixels. + /// + public const int MinColumnPixels = 160; + + /// + /// Largest accepted custom column width in pixels. + /// + public const int MaxColumnPixels = 720; + /// /// Gets or sets the configuration format version of this file. /// diff --git a/YKanBan/Storage/AppData/AppConfigStore.cs b/YKanBan/Storage/AppData/AppConfigStore.cs index 97e10ab..d9a18c4 100644 --- a/YKanBan/Storage/AppData/AppConfigStore.cs +++ b/YKanBan/Storage/AppData/AppConfigStore.cs @@ -1,13 +1,17 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; namespace YKanBan.Storage.AppData; /// /// Loads and saves app.json with graceful degradation: a missing file yields -/// defaults; a corrupt file is backed up to app.json.bak and then defaults are -/// used. Saving writes the file directly (no atomic replace) — losing the last -/// session's settings to a crash is an accepted trade-off. +/// defaults; a file that is not a JSON object is backed up to app.json.bak and +/// then defaults are used; an individual invalid field (unknown enum value or +/// language, out-of-range width, wrong type) falls back to its own default while +/// the other fields are kept. Saving happens only when the configuration differs +/// from what was loaded, and writes the file directly (no atomic replace) — +/// losing the last session's settings to a crash is an accepted trade-off. /// public sealed class AppConfigStore { @@ -16,10 +20,24 @@ public sealed class AppConfigStore Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Ignore, - // Enums round-trip through the kebab-case strings declared via [EnumMember]. - Converters = { new StringEnumConverter() }, + // Enums round-trip through the kebab-case strings declared via [EnumMember]; + // numeric enum values are rejected like any other unknown value. + Converters = { new StringEnumConverter { AllowIntegerValues = false } }, }; + private static readonly JsonSerializer FieldTolerantSerializer = JsonSerializer.Create(new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + Converters = { new StringEnumConverter { AllowIntegerValues = false } }, + + // The JSON text is already known to be well formed here, so every error is a + // single member that fails to convert: skip it and keep that member's default. + Error = (_, args) => args.ErrorContext.Handled = true, + }); + + /// The serialized form of the configuration as last loaded or saved. + private string _persistedSnapshot = Serialize(new AppConfig()); + /// /// Initializes a store bound to a specific app.json path. /// @@ -32,12 +50,57 @@ public sealed class AppConfigStore public string FilePath { get; } /// - /// Loads the configuration. A missing file yields defaults; a corrupt file - /// is backed up to app.json.bak and then defaults are used. + /// Loads the configuration and records it as the persisted baseline for + /// . A missing file yields defaults; a file that + /// is not a JSON object is backed up to app.json.bak and then defaults are + /// used; invalid fields fall back to their defaults individually. /// /// The loaded configuration, or defaults when none could be read. /// The file exists but cannot be read. public AppConfig Load() + { + AppConfig config = Read(); + + // The baseline is the effective configuration, so field fallbacks alone never trigger a write. + _persistedSnapshot = Serialize(config); + return config; + } + + /// + /// Writes the configuration only when it differs from the loaded or last + /// saved one, i.e. when this session changed a setting. + /// + /// The live configuration. + /// when the file was written. + public bool SaveIfChanged(AppConfig config) + { + string json = Serialize(config); + if (json == _persistedSnapshot) + { + return false; + } + + Write(json); + _persistedSnapshot = json; + return true; + } + + /// + /// Writes the configuration directly to disk (no atomic replace), unconditionally. + /// + /// The configuration to serialize. + public void Save(AppConfig config) + { + string json = Serialize(config); + Write(json); + _persistedSnapshot = json; + } + + /// + /// Reads and sanitizes the file. + /// + /// The effective configuration. + private AppConfig Read() { // Missing file is a normal first-run state: run with defaults. if (!File.Exists(FilePath)) @@ -46,42 +109,98 @@ public sealed class AppConfigStore } string json = File.ReadAllText(FilePath); + JToken root; try { - // A JSON null literal deserializes to null; treat it like defaults. - AppConfig? config = JsonConvert.DeserializeObject(json, SerializerSettings); - if (config is null) - { - return new AppConfig(); - } - - // Older or hand-edited files may miss whole sections; each falls back to its defaults. - config.Sort ??= new SortSettings(); - config.ColumnWidth ??= new ColumnWidthSettings(); - config.Confirmations ??= new ConfirmationSettings(); - return config; + root = JToken.Parse(json); } catch (JsonException) { - // Corrupt: keep a copy for inspection, then continue with defaults. - File.Copy(FilePath, FilePath + ".bak", overwrite: true); + return BackUpAndUseDefaults(); + } + + // A JSON null literal carries no settings at all; treat it like a missing file. + if (root.Type == JTokenType.Null) + { return new AppConfig(); } + + // Well-formed JSON of the wrong shape has no field to keep: handle it like corrupt text. + if (root is not JObject rootObject) + { + return BackUpAndUseDefaults(); + } + + AppConfig config = rootObject.ToObject(FieldTolerantSerializer) ?? new AppConfig(); + Sanitize(config); + return config; + } + + /// + /// Keeps a copy of an unusable file for inspection, then continues with defaults. + /// + /// The default configuration. + private AppConfig BackUpAndUseDefaults() + { + File.Copy(FilePath, FilePath + ".bak", overwrite: true); + return new AppConfig(); + } + + /// + /// Replaces every value that converted but is outside its domain with the field default. + /// + /// The freshly deserialized configuration. + private static void Sanitize(AppConfig config) + { + var defaults = new AppConfig(); + + // Older or hand-edited files may miss whole sections; each falls back to its defaults. + config.Sort ??= new SortSettings(); + config.ColumnWidth ??= new ColumnWidthSettings(); + config.Confirmations ??= new ConfirmationSettings(); + + if (config.Language is null || !AppConfig.SupportedLanguages.Contains(config.Language, StringComparer.Ordinal)) + { + config.Language = defaults.Language; + } + if (!Enum.IsDefined(config.Theme)) + { + config.Theme = defaults.Theme; + } + if (!Enum.IsDefined(config.Sort.Card)) + { + config.Sort.Card = defaults.Sort.Card; + } + if (!Enum.IsDefined(config.ColumnWidth.Preset)) + { + config.ColumnWidth.Preset = defaults.ColumnWidth.Preset; + } + if (config.ColumnWidth.CustomPixels is < AppConfig.MinColumnPixels or > AppConfig.MaxColumnPixels) + { + config.ColumnWidth.CustomPixels = defaults.ColumnWidth.CustomPixels; + } } /// - /// Writes the configuration directly to disk (no atomic replace). + /// Writes the serialized configuration, creating the parent folder on demand. /// - /// The configuration to serialize. - public void Save(AppConfig config) + /// The serialized configuration. + private void Write(string json) { - // Create the parent folder on demand; normally it already exists. + // Normally the folder already exists. string? directory = Path.GetDirectoryName(FilePath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } - File.WriteAllText(FilePath, JsonConvert.SerializeObject(config, SerializerSettings)); + File.WriteAllText(FilePath, json); } + + /// + /// Serializes a configuration in the on-disk format. + /// + /// The configuration. + /// The JSON text. + private static string Serialize(AppConfig config) => JsonConvert.SerializeObject(config, SerializerSettings); } diff --git a/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs index cc83055..94f33fd 100644 --- a/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs @@ -26,7 +26,7 @@ public sealed partial class SettingsDialogViewModel : ViewModelBase Languages = [ new OptionItem(AppConfig.DefaultLanguage, Resources.Settings_LanguageEnglish), - new OptionItem("zh-Hans", Resources.Settings_LanguageChinese), + new OptionItem(AppConfig.SimplifiedChineseLanguage, Resources.Settings_LanguageChinese), ]; selectedLanguage = Languages.FirstOrDefault(option => (string)option.Value == _config.Language) ?? Languages[0];