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];