From d22b50e1711f8d38f9adfcd57b48919d3ae15f4f Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 3 Jun 2026 11:38:49 +0200 Subject: [PATCH] feat(i18n): add Localizer with fallback chain and change event --- src/ClaudeDo.Localization/ILocalizer.cs | 13 +++++ src/ClaudeDo.Localization/Localizer.cs | 55 ++++++++++++++++++ .../LocalizerTests.cs | 58 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 src/ClaudeDo.Localization/ILocalizer.cs create mode 100644 src/ClaudeDo.Localization/Localizer.cs create mode 100644 tests/ClaudeDo.Localization.Tests/LocalizerTests.cs diff --git a/src/ClaudeDo.Localization/ILocalizer.cs b/src/ClaudeDo.Localization/ILocalizer.cs new file mode 100644 index 0000000..79bda7c --- /dev/null +++ b/src/ClaudeDo.Localization/ILocalizer.cs @@ -0,0 +1,13 @@ +namespace ClaudeDo.Localization; + +public readonly record struct LanguageOption(string Code, string Name); + +public interface ILocalizer +{ + string this[string key] { get; } + string Get(string key, params object[] args); + string CurrentCode { get; } + IReadOnlyList AvailableLanguages { get; } + void SetLanguage(string code); + event EventHandler? LanguageChanged; +} diff --git a/src/ClaudeDo.Localization/Localizer.cs b/src/ClaudeDo.Localization/Localizer.cs new file mode 100644 index 0000000..b240c25 --- /dev/null +++ b/src/ClaudeDo.Localization/Localizer.cs @@ -0,0 +1,55 @@ +namespace ClaudeDo.Localization; + +public sealed class Localizer : ILocalizer +{ + private readonly LocaleStore _store; + private readonly string _fallbackCode; + private LocaleFile? _active; + private LocaleFile? _fallback; + + public Localizer(LocaleStore store, string code, string fallbackCode = "en") + { + _store = store; + _fallbackCode = fallbackCode; + _store.TryGet(fallbackCode, out _fallback); + SetLanguage(code); + } + + public string CurrentCode { get; private set; } = ""; + + public IReadOnlyList AvailableLanguages => + _store.Available.Select(f => new LanguageOption(f.Code, f.Name)).ToList(); + + public event EventHandler? LanguageChanged; + + public string this[string key] + { + get + { + if (_active is not null && _active.Strings.TryGetValue(key, out var v)) return v; + if (_fallback is not null && _fallback.Strings.TryGetValue(key, out var fv)) return fv; + return key; + } + } + + public string Get(string key, params object[] args) + { + var fmt = this[key]; + return args.Length == 0 ? fmt : string.Format(fmt, args); + } + + public void SetLanguage(string code) + { + if (_store.TryGet(code, out var f) && f is not null) + { + _active = f; + CurrentCode = f.Code; + } + else + { + _active = _fallback; + CurrentCode = _fallback?.Code ?? code; + } + LanguageChanged?.Invoke(this, EventArgs.Empty); + } +} diff --git a/tests/ClaudeDo.Localization.Tests/LocalizerTests.cs b/tests/ClaudeDo.Localization.Tests/LocalizerTests.cs new file mode 100644 index 0000000..741eb07 --- /dev/null +++ b/tests/ClaudeDo.Localization.Tests/LocalizerTests.cs @@ -0,0 +1,58 @@ +using ClaudeDo.Localization; + +namespace ClaudeDo.Localization.Tests; + +public class LocalizerTests +{ + private static LocaleStore TwoLangStore() + { + var dir = Path.Combine(Path.GetTempPath(), "loc_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "en.json"), + """{ "metadata": { "code": "en", "name": "English" }, "settings": { "save": "Save" }, "msg": { "count": "{0} items" } }"""); + File.WriteAllText(Path.Combine(dir, "de.json"), + """{ "metadata": { "code": "de", "name": "Deutsch" }, "settings": { "save": "Speichern" } }"""); + return LocaleStore.Load(dir); + } + + [Fact] + public void Indexer_returns_active_language_value() + { + var loc = new Localizer(TwoLangStore(), "de", fallbackCode: "en"); + Assert.Equal("Speichern", loc["settings.save"]); + } + + [Fact] + public void Indexer_falls_back_to_english_then_key() + { + var loc = new Localizer(TwoLangStore(), "de", fallbackCode: "en"); + Assert.Equal("{0} items", loc["msg.count"]); + Assert.Equal("nope.missing", loc["nope.missing"]); + } + + [Fact] + public void Get_formats_arguments() + { + var loc = new Localizer(TwoLangStore(), "en", fallbackCode: "en"); + Assert.Equal("3 items", loc.Get("msg.count", 3)); + } + + [Fact] + public void SetLanguage_raises_change_and_switches_values() + { + var loc = new Localizer(TwoLangStore(), "en", fallbackCode: "en"); + var raised = 0; + loc.LanguageChanged += (_, _) => raised++; + loc.SetLanguage("de"); + Assert.Equal("de", loc.CurrentCode); + Assert.Equal("Speichern", loc["settings.save"]); + Assert.Equal(1, raised); + } + + [Fact] + public void AvailableLanguages_lists_code_and_name() + { + var loc = new Localizer(TwoLangStore(), "en", fallbackCode: "en"); + Assert.Contains(loc.AvailableLanguages, l => l.Code == "de" && l.Name == "Deutsch"); + } +}