diff --git a/CLAUDE.md b/CLAUDE.md index e0305e7..0589c2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,9 @@ dotnet run tools/setup.cs -- --group packaging # pinned nfpm into to dotnet build src/MeWeb.Android/MeWeb.Android.csproj \ -p:JavaSdkDirectory=$PWD/tools/jdk/linux-x64/jdk-21.0.12+8 -p:AndroidSdkDirectory=$HOME/Android/Sdk dotnet run --project tools/Screenshots -- dist/screenshots [en,it,...] +dotnet run tools/strings.cs -- check # every language against English +dotnet run tools/strings.cs -- set Key values.json # one key, every language at once +dotnet run tools/languages.cs # regenerate data/languages.json from CLDR dotnet publish src/MeWeb.Desktop -c Release -r win-x64 --self-contained -p:PublishSingleFile=true \ -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true dotnet publish src/MeWeb.Desktop -c Release -r osx-arm64 --self-contained @@ -26,11 +29,11 @@ is root-owned, so CI uses the runner user's own `~/.dotnet` (passolo's `tools/re | Path | What | |---|---| | `src/MeWeb.Shared` | `Endpoints.cs` (routes and hosts), wire contracts, `MeWebJsonContext`, generated `BuildInfo` | -| `src/MeWeb.App` | Avalonia views and view models, `Services/Localizer.cs` (+ `{s:Tr Key}`), `Strings/*.resx`, `Resources/Colors.axaml` | +| `src/MeWeb.App` | Avalonia views and view models, `Services/Localizer.cs` (+ `{s:Tr Key}`), `Strings/.json`, `Resources/Colors.axaml` | | `src/MeWeb.Desktop` | desktop head (`meweb` executable), Windows manifest | | `src/MeWeb.Android` | Android head (`software.arasaka.meweb`), resources, manifest | | `src/MeWeb.Server` | `meweb.arasaka.software`: `/api/info`, `/api/ip` | -| `data/languages.json` | every language with `reviewed` or `draft` | +| `data/languages.json` | 113 languages: code, autonym, English name, `rtl`, `reviewed` or `draft` | | `tools/setup.cs` + `setup.json` | pinned build tools | | `tools/Screenshots` | headless renders of every view in every language | | `branding/` | the icon (SVG source, PNG, ICO) | diff --git a/PATTERNS.md b/PATTERNS.md index b997f07..6ab1c98 100644 --- a/PATTERNS.md +++ b/PATTERNS.md @@ -131,8 +131,11 @@ Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third 38. **Structured logging with `{Placeholder}` templates**, never interpolated strings (Microsoft.Extensions.Logging, as passolo). -39. **Localization-first:** every user-facing string is a resx key in `src/MeWeb.App/Strings/Strings*.resx`; no - literals in XAML or view models (the product name `me-web?` excepted). +39. **Localization-first:** every user-facing string is a key in `src/MeWeb.App/Strings/.json`, one flat + file per language, English the fallback; no literals in XAML or view models (the product name `me-web?` + excepted). JSON rather than resx on purpose: 21 of the 113 official languages are unknown to ICU and to MSBuild's + satellite-culture list, and a file looked up by code needs neither. Keys change only through + `tools/strings.cs set`, and `tools/strings.cs check` (keys and `{n}` placeholders against English) runs in CI. 40. **Central Package Management** (`Directory.Packages.props`); Avalonia packages move together through `AvaloniaVersion`. 41. **Docs as convention:** `AGENT.md` (operational), `CLAUDE.md` (quick reference + pitfalls), `PATTERNS.md` @@ -172,7 +175,10 @@ Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third private, loopback, link-local, CGNAT, ULA and the server's own prefixes. 124. **Shared findings leave only after the person has read exactly what is sent**, and every submission can be deleted with the id and token the app keeps. -125. **Every language ships; drafts say so.** `data/languages.json` marks each language `reviewed` or `draft`; a - draft shows its notice and the "suggest a correction" link. The root `FlowDirection` follows the language. +125. **Every language ships; drafts say so.** `data/languages.json` (written by `tools/languages.cs` from CLDR: + every language official or de facto official in a UN member state, plus the six UN languages) marks each + `reviewed` or `draft` and carries its own `rtl` flag, because ICU does not know every script's direction. The + picker lists a language once its strings file exists; a draft shows its notice and the "suggest a correction" + link; the root `FlowDirection` follows `rtl`. 126. **`?` appears only in display text.** File names, package ids and paths use `MeWeb`, `meweb` and `software.arasaka.meweb`. diff --git a/data/languages.json b/data/languages.json index 5f872b1..57cb4a5 100644 --- a/data/languages.json +++ b/data/languages.json @@ -1,4 +1,793 @@ [ - { "code": "en", "name": "English", "status": "reviewed" }, - { "code": "it", "name": "Italiano", "status": "reviewed" } + { + "code": "am", + "name": "አማርኛ", + "english": "Amharic", + "rtl": false, + "status": "draft" + }, + { + "code": "ar", + "name": "العربية", + "english": "Arabic", + "rtl": true, + "status": "draft" + }, + { + "code": "ay", + "name": "Aymar aru", + "english": "Aymara", + "rtl": false, + "status": "draft" + }, + { + "code": "az", + "name": "Azərbaycan", + "english": "Azerbaijani", + "rtl": false, + "status": "draft" + }, + { + "code": "az-Cyrl", + "name": "Азәрбајҹан (Кирил)", + "english": "Azerbaijani (Cyrillic)", + "rtl": false, + "status": "draft" + }, + { + "code": "be", + "name": "Беларуская", + "english": "Belarusian", + "rtl": false, + "status": "draft" + }, + { + "code": "bg", + "name": "Български", + "english": "Bulgarian", + "rtl": false, + "status": "draft" + }, + { + "code": "bi", + "name": "Bislama", + "english": "Bislama", + "rtl": false, + "status": "draft" + }, + { + "code": "bn", + "name": "বাংলা", + "english": "Bangla", + "rtl": false, + "status": "draft" + }, + { + "code": "bs", + "name": "Bosanski", + "english": "Bosnian", + "rtl": false, + "status": "draft" + }, + { + "code": "bs-Cyrl", + "name": "Босански (ћирилица)", + "english": "Bosnian (Cyrillic)", + "rtl": false, + "status": "draft" + }, + { + "code": "ca", + "name": "Català", + "english": "Catalan", + "rtl": false, + "status": "draft" + }, + { + "code": "cs", + "name": "Čeština", + "english": "Czech", + "rtl": false, + "status": "draft" + }, + { + "code": "da", + "name": "Dansk", + "english": "Danish", + "rtl": false, + "status": "draft" + }, + { + "code": "de", + "name": "Deutsch", + "english": "German", + "rtl": false, + "status": "draft" + }, + { + "code": "dv", + "name": "ދިވެހި", + "english": "Dhivehi", + "rtl": true, + "status": "draft" + }, + { + "code": "dz", + "name": "རྫོང་ཁ", + "english": "Dzongkha", + "rtl": false, + "status": "draft" + }, + { + "code": "el", + "name": "Ελληνικά", + "english": "Greek", + "rtl": false, + "status": "draft" + }, + { + "code": "en", + "name": "English", + "english": "English", + "rtl": false, + "status": "reviewed" + }, + { + "code": "es", + "name": "Español", + "english": "Spanish", + "rtl": false, + "status": "draft" + }, + { + "code": "et", + "name": "Eesti", + "english": "Estonian", + "rtl": false, + "status": "draft" + }, + { + "code": "fa", + "name": "فارسی", + "english": "Persian", + "rtl": true, + "status": "draft" + }, + { + "code": "fi", + "name": "Suomi", + "english": "Finnish", + "rtl": false, + "status": "draft" + }, + { + "code": "fil", + "name": "Filipino", + "english": "Filipino", + "rtl": false, + "status": "draft" + }, + { + "code": "fj", + "name": "Na Vosa Vakaviti", + "english": "Fijian", + "rtl": false, + "status": "draft" + }, + { + "code": "fr", + "name": "Français", + "english": "French", + "rtl": false, + "status": "draft" + }, + { + "code": "ga", + "name": "Gaeilge", + "english": "Irish", + "rtl": false, + "status": "draft" + }, + { + "code": "gil", + "name": "Taetae ni Kiribati", + "english": "Gilbertese", + "rtl": false, + "status": "draft" + }, + { + "code": "gn", + "name": "Avañe'ẽ", + "english": "Guarani", + "rtl": false, + "status": "draft" + }, + { + "code": "gsw", + "name": "Schwiizertüütsch", + "english": "Swiss German", + "rtl": false, + "status": "draft" + }, + { + "code": "he", + "name": "עברית", + "english": "Hebrew", + "rtl": true, + "status": "draft" + }, + { + "code": "hi", + "name": "हिन्दी", + "english": "Hindi", + "rtl": false, + "status": "draft" + }, + { + "code": "hif", + "name": "Fiji Baat", + "english": "Fiji Hindi", + "rtl": false, + "status": "draft" + }, + { + "code": "ho", + "name": "Hiri Motu", + "english": "Hiri Motu", + "rtl": false, + "status": "draft" + }, + { + "code": "hr", + "name": "Hrvatski", + "english": "Croatian", + "rtl": false, + "status": "draft" + }, + { + "code": "ht", + "name": "Kreyòl ayisyen", + "english": "Haitian Creole", + "rtl": false, + "status": "draft" + }, + { + "code": "hu", + "name": "Magyar", + "english": "Hungarian", + "rtl": false, + "status": "draft" + }, + { + "code": "hy", + "name": "Հայերեն", + "english": "Armenian", + "rtl": false, + "status": "draft" + }, + { + "code": "id", + "name": "Indonesia", + "english": "Indonesian", + "rtl": false, + "status": "draft" + }, + { + "code": "is", + "name": "Íslenska", + "english": "Icelandic", + "rtl": false, + "status": "draft" + }, + { + "code": "it", + "name": "Italiano", + "english": "Italian", + "rtl": false, + "status": "reviewed" + }, + { + "code": "ja", + "name": "日本語", + "english": "Japanese", + "rtl": false, + "status": "draft" + }, + { + "code": "ka", + "name": "ქართული", + "english": "Georgian", + "rtl": false, + "status": "draft" + }, + { + "code": "kk", + "name": "Қазақ тілі", + "english": "Kazakh", + "rtl": false, + "status": "draft" + }, + { + "code": "km", + "name": "ខ្មែរ", + "english": "Khmer", + "rtl": false, + "status": "draft" + }, + { + "code": "ko", + "name": "한국어", + "english": "Korean", + "rtl": false, + "status": "draft" + }, + { + "code": "ky", + "name": "Кыргызча", + "english": "Kyrgyz", + "rtl": false, + "status": "draft" + }, + { + "code": "lb", + "name": "Lëtzebuergesch", + "english": "Luxembourgish", + "rtl": false, + "status": "draft" + }, + { + "code": "lo", + "name": "ລາວ", + "english": "Lao", + "rtl": false, + "status": "draft" + }, + { + "code": "lt", + "name": "Lietuvių", + "english": "Lithuanian", + "rtl": false, + "status": "draft" + }, + { + "code": "lv", + "name": "Latviešu", + "english": "Latvian", + "rtl": false, + "status": "draft" + }, + { + "code": "mg", + "name": "Malagasy", + "english": "Malagasy", + "rtl": false, + "status": "draft" + }, + { + "code": "mh", + "name": "Kajin M̧ajeļ", + "english": "Marshallese", + "rtl": false, + "status": "draft" + }, + { + "code": "mi", + "name": "Māori", + "english": "Māori", + "rtl": false, + "status": "draft" + }, + { + "code": "mk", + "name": "Македонски", + "english": "Macedonian", + "rtl": false, + "status": "draft" + }, + { + "code": "mn", + "name": "Монгол", + "english": "Mongolian", + "rtl": false, + "status": "draft" + }, + { + "code": "ms", + "name": "Melayu", + "english": "Malay", + "rtl": false, + "status": "draft" + }, + { + "code": "ms-Arab", + "name": "بهاس ملايو", + "english": "Malay (Arabic script)", + "rtl": true, + "status": "draft" + }, + { + "code": "mt", + "name": "Malti", + "english": "Maltese", + "rtl": false, + "status": "draft" + }, + { + "code": "my", + "name": "မြန်မာ", + "english": "Burmese", + "rtl": false, + "status": "draft" + }, + { + "code": "na", + "name": "Dorerin Naoero", + "english": "Nauruan", + "rtl": false, + "status": "draft" + }, + { + "code": "nb", + "name": "Norsk bokmål", + "english": "Norwegian Bokmål", + "rtl": false, + "status": "draft" + }, + { + "code": "nd", + "name": "IsiNdebele", + "english": "North Ndebele", + "rtl": false, + "status": "draft" + }, + { + "code": "ne", + "name": "नेपाली", + "english": "Nepali", + "rtl": false, + "status": "draft" + }, + { + "code": "nl", + "name": "Nederlands", + "english": "Dutch", + "rtl": false, + "status": "draft" + }, + { + "code": "nn", + "name": "Norsk nynorsk", + "english": "Norwegian Nynorsk", + "rtl": false, + "status": "draft" + }, + { + "code": "no", + "name": "Norsk", + "english": "Norwegian", + "rtl": false, + "status": "draft" + }, + { + "code": "ny", + "name": "Chichewa", + "english": "Chichewa", + "rtl": false, + "status": "draft" + }, + { + "code": "pau", + "name": "Tekoi ra Belau", + "english": "Palauan", + "rtl": false, + "status": "draft" + }, + { + "code": "pl", + "name": "Polski", + "english": "Polish", + "rtl": false, + "status": "draft" + }, + { + "code": "ps", + "name": "پښتو", + "english": "Pashto", + "rtl": true, + "status": "draft" + }, + { + "code": "pt", + "name": "Português", + "english": "Portuguese", + "rtl": false, + "status": "draft" + }, + { + "code": "qu", + "name": "Runasimi", + "english": "Quechua", + "rtl": false, + "status": "draft" + }, + { + "code": "rn", + "name": "Ikirundi", + "english": "Rundi", + "rtl": false, + "status": "draft" + }, + { + "code": "ro", + "name": "Română", + "english": "Romanian", + "rtl": false, + "status": "draft" + }, + { + "code": "ru", + "name": "Русский", + "english": "Russian", + "rtl": false, + "status": "draft" + }, + { + "code": "rw", + "name": "Ikinyarwanda", + "english": "Kinyarwanda", + "rtl": false, + "status": "draft" + }, + { + "code": "sg", + "name": "Sängö", + "english": "Sango", + "rtl": false, + "status": "draft" + }, + { + "code": "si", + "name": "සිංහල", + "english": "Sinhala", + "rtl": false, + "status": "draft" + }, + { + "code": "sk", + "name": "Slovenčina", + "english": "Slovak", + "rtl": false, + "status": "draft" + }, + { + "code": "sl", + "name": "Slovenščina", + "english": "Slovenian", + "rtl": false, + "status": "draft" + }, + { + "code": "sm", + "name": "Gagana Samoa", + "english": "Samoan", + "rtl": false, + "status": "draft" + }, + { + "code": "sn", + "name": "ChiShona", + "english": "Shona", + "rtl": false, + "status": "draft" + }, + { + "code": "so", + "name": "Soomaali", + "english": "Somali", + "rtl": false, + "status": "draft" + }, + { + "code": "sq", + "name": "Shqip", + "english": "Albanian", + "rtl": false, + "status": "draft" + }, + { + "code": "sr", + "name": "Српски", + "english": "Serbian", + "rtl": false, + "status": "draft" + }, + { + "code": "sr-Latn", + "name": "Srpski (latinica)", + "english": "Serbian (Latin)", + "rtl": false, + "status": "draft" + }, + { + "code": "ss", + "name": "SiSwati", + "english": "Swati", + "rtl": false, + "status": "draft" + }, + { + "code": "st", + "name": "Sesotho", + "english": "Southern Sotho", + "rtl": false, + "status": "draft" + }, + { + "code": "sv", + "name": "Svenska", + "english": "Swedish", + "rtl": false, + "status": "draft" + }, + { + "code": "sw", + "name": "Kiswahili", + "english": "Swahili", + "rtl": false, + "status": "draft" + }, + { + "code": "ta", + "name": "தமிழ்", + "english": "Tamil", + "rtl": false, + "status": "draft" + }, + { + "code": "tet", + "name": "Tetun", + "english": "Tetum", + "rtl": false, + "status": "draft" + }, + { + "code": "tg", + "name": "Тоҷикӣ", + "english": "Tajik", + "rtl": false, + "status": "draft" + }, + { + "code": "th", + "name": "ไทย", + "english": "Thai", + "rtl": false, + "status": "draft" + }, + { + "code": "ti", + "name": "ትግርኛ", + "english": "Tigrinya", + "rtl": false, + "status": "draft" + }, + { + "code": "tk", + "name": "Türkmen dili", + "english": "Turkmen", + "rtl": false, + "status": "draft" + }, + { + "code": "tn", + "name": "Setswana", + "english": "Tswana", + "rtl": false, + "status": "draft" + }, + { + "code": "to", + "name": "Lea fakatonga", + "english": "Tongan", + "rtl": false, + "status": "draft" + }, + { + "code": "tpi", + "name": "Tok Pisin", + "english": "Tok Pisin", + "rtl": false, + "status": "draft" + }, + { + "code": "tr", + "name": "Türkçe", + "english": "Turkish", + "rtl": false, + "status": "draft" + }, + { + "code": "tvl", + "name": "Te Ggana Tuuvalu", + "english": "Tuvaluan", + "rtl": false, + "status": "draft" + }, + { + "code": "tzm", + "name": "Tamaziɣt n laṭlaṣ", + "english": "Central Atlas Tamazight", + "rtl": false, + "status": "draft" + }, + { + "code": "uk", + "name": "Українська", + "english": "Ukrainian", + "rtl": false, + "status": "draft" + }, + { + "code": "ur", + "name": "اردو", + "english": "Urdu", + "rtl": true, + "status": "draft" + }, + { + "code": "uz", + "name": "O‘zbek", + "english": "Uzbek", + "rtl": false, + "status": "draft" + }, + { + "code": "uz-Cyrl", + "name": "Ўзбекча (Кирил)", + "english": "Uzbek (Cyrillic)", + "rtl": false, + "status": "draft" + }, + { + "code": "vi", + "name": "Tiếng Việt", + "english": "Vietnamese", + "rtl": false, + "status": "draft" + }, + { + "code": "wni", + "name": "Shindzuani", + "english": "Ndzwani Comorian", + "rtl": false, + "status": "draft" + }, + { + "code": "wo", + "name": "Wolof", + "english": "Wolof", + "rtl": false, + "status": "draft" + }, + { + "code": "yo", + "name": "Èdè Yorùbá", + "english": "Yoruba", + "rtl": false, + "status": "draft" + }, + { + "code": "zdj", + "name": "Shingazidja", + "english": "Ngazidja Comorian", + "rtl": false, + "status": "draft" + }, + { + "code": "zh", + "name": "中文", + "english": "Chinese", + "rtl": false, + "status": "draft" + } ] diff --git a/src/MeWeb.App/MeWeb.App.csproj b/src/MeWeb.App/MeWeb.App.csproj index f60b4b1..f8f1918 100644 --- a/src/MeWeb.App/MeWeb.App.csproj +++ b/src/MeWeb.App/MeWeb.App.csproj @@ -9,6 +9,7 @@ + diff --git a/src/MeWeb.App/Services/AppJsonContext.cs b/src/MeWeb.App/Services/AppJsonContext.cs index cc28bd7..5e44444 100644 --- a/src/MeWeb.App/Services/AppJsonContext.cs +++ b/src/MeWeb.App/Services/AppJsonContext.cs @@ -6,6 +6,7 @@ namespace MeWeb.App.Services; [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true)] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(StoredSettings))] +[JsonSerializable(typeof(Dictionary))] public partial class AppJsonContext : JsonSerializerContext { } diff --git a/src/MeWeb.App/Services/Languages.cs b/src/MeWeb.App/Services/Languages.cs index 6de2bf2..5f9c672 100644 --- a/src/MeWeb.App/Services/Languages.cs +++ b/src/MeWeb.App/Services/Languages.cs @@ -7,6 +7,8 @@ public class Language { public string Code { get; set; } public string Name { get; set; } + public string English { get; set; } + public bool Rtl { get; set; } public string Status { get; set; } public bool IsDraft => Status == "draft"; @@ -14,7 +16,7 @@ public class Language public static class Languages { - public static Language Fallback { get; } = new() { Code = "en", Name = "English", Status = "reviewed" }; + public static Language Fallback { get; } = new() { Code = "en", Name = "English", English = "English", Status = "reviewed" }; public static IReadOnlyList All { get; } = Load(); @@ -36,6 +38,7 @@ public static class Languages using var stream = typeof(Languages).Assembly.GetManifestResourceStream("languages.json"); if (stream is null) return [Fallback]; var list = JsonSerializer.Deserialize(stream, AppJsonContext.Default.ListLanguage) ?? []; - return list.Count == 0 ? [Fallback] : list.OrderBy(l => l.Name, StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.IgnoreCase)).ToList(); + var shipped = list.Where(l => Localizer.Has(l.Code)).ToList(); + return shipped.Count == 0 ? [Fallback] : shipped.OrderBy(l => l.Name, StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.IgnoreCase)).ToList(); } } diff --git a/src/MeWeb.App/Services/Localizer.cs b/src/MeWeb.App/Services/Localizer.cs index ef69964..3141a61 100644 --- a/src/MeWeb.App/Services/Localizer.cs +++ b/src/MeWeb.App/Services/Localizer.cs @@ -1,6 +1,6 @@ using System.ComponentModel; using System.Globalization; -using System.Resources; +using System.Text.Json; using Avalonia.Data; using Avalonia.Markup.Xaml; @@ -10,7 +10,10 @@ namespace MeWeb.App.Services; public sealed class Localizer : INotifyPropertyChanged { - readonly ResourceManager resources = new("MeWeb.App.Strings.Strings", typeof(Localizer).Assembly); + readonly Dictionary english = Load(Languages.Fallback.Code); + Dictionary current; + + public Localizer() => current = english; public static Localizer Instance { get; } = new(); @@ -20,21 +23,45 @@ public sealed class Localizer : INotifyPropertyChanged public CultureInfo Culture { get; private set; } = CultureInfo.GetCultureInfo(Languages.Fallback.Code); - public bool IsRightToLeft => Culture.TextInfo.IsRightToLeft; + public bool IsRightToLeft => Language.Rtl; - public string this[string key] => resources.GetString(key, Culture) ?? key; + public string this[string key] => current.GetValueOrDefault(key) ?? english.GetValueOrDefault(key) ?? key; public string Format(string key, params object[] args) => string.Format(Culture, this[key], args); public void SetLanguage(Language language) { Language = language ?? Languages.Fallback; - Culture = CultureInfo.GetCultureInfo(Language.Code); + current = Language.Code == Languages.Fallback.Code ? english : Load(Language.Code); + Culture = CultureFor(Language.Code); CultureInfo.DefaultThreadCurrentCulture = CultureInfo.DefaultThreadCurrentUICulture = Culture; CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = Culture; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty)); } + + public static bool Has(string code) => typeof(Localizer).Assembly.GetManifestResourceInfo(ResourceName(code)) is not null; + + static string ResourceName(string code) => $"strings.{code}.json"; + + static Dictionary Load(string code) + { + using var stream = typeof(Localizer).Assembly.GetManifestResourceStream(ResourceName(code)); + if (stream is null) return []; + return JsonSerializer.Deserialize(stream, AppJsonContext.Default.DictionaryStringString) ?? []; + } + + static CultureInfo CultureFor(string code) + { + try + { + return CultureInfo.GetCultureInfo(code); + } + catch (CultureNotFoundException) + { + return CultureInfo.InvariantCulture; + } + } } public sealed class TrExtension : MarkupExtension diff --git a/src/MeWeb.App/Strings/Strings.it.resx b/src/MeWeb.App/Strings/Strings.it.resx deleted file mode 100644 index 812dbfe..0000000 --- a/src/MeWeb.App/Strings/Strings.it.resx +++ /dev/null @@ -1,42 +0,0 @@ - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Scopra se da questa connessione può ospitare un server suo, e che cosa lo impedisce. - - - Verifica - - - Questa traduzione è una bozza automatica. - - - Lingua - - - Verifico la connessione… - - - Non connesso a internet - - - Connesso a internet - - - Proponi una correzione - - - me-web? {0} · software libero (AGPL-3.0) - - diff --git a/src/MeWeb.App/Strings/Strings.resx b/src/MeWeb.App/Strings/Strings.resx deleted file mode 100644 index 15d2721..0000000 --- a/src/MeWeb.App/Strings/Strings.resx +++ /dev/null @@ -1,42 +0,0 @@ - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Find out whether you can run your own server from this connection, and what stands in the way. - - - Check - - - This translation is a machine draft. - - - Language - - - Checking the connection… - - - Not connected to the internet - - - Connected to the internet - - - Suggest a correction - - - me-web? {0} · free software (AGPL-3.0) - - diff --git a/src/MeWeb.App/Strings/ar.json b/src/MeWeb.App/Strings/ar.json new file mode 100644 index 0000000..9cc306b --- /dev/null +++ b/src/MeWeb.App/Strings/ar.json @@ -0,0 +1,11 @@ +{ + "AppTagline": "اكتشف ما إذا كان بإمكانك تشغيل خادمك الخاص من هذا الاتصال، وما الذي يعيق ذلك.", + "Check": "افحص", + "DraftNotice": "هذه الترجمة مسودة آلية.", + "Language": "اللغة", + "StatusChecking": "جارٍ فحص الاتصال…", + "StatusOffline": "غير متصل بالإنترنت", + "StatusOnline": "متصل بالإنترنت", + "SuggestCorrection": "اقترح تصحيحًا", + "VersionLine": "me-web? {0} · برنامج حر (AGPL-3.0)" +} diff --git a/src/MeWeb.App/Strings/en.json b/src/MeWeb.App/Strings/en.json new file mode 100644 index 0000000..e1ba265 --- /dev/null +++ b/src/MeWeb.App/Strings/en.json @@ -0,0 +1,11 @@ +{ + "AppTagline": "Find out whether you can run your own server from this connection, and what stands in the way.", + "Check": "Check", + "DraftNotice": "This translation is a machine draft.", + "Language": "Language", + "StatusChecking": "Checking the connection…", + "StatusOffline": "Not connected to the internet", + "StatusOnline": "Connected to the internet", + "SuggestCorrection": "Suggest a correction", + "VersionLine": "me-web? {0} · free software (AGPL-3.0)" +} diff --git a/src/MeWeb.App/Strings/hi.json b/src/MeWeb.App/Strings/hi.json new file mode 100644 index 0000000..027db07 --- /dev/null +++ b/src/MeWeb.App/Strings/hi.json @@ -0,0 +1,11 @@ +{ + "AppTagline": "जानें कि क्या आप इस कनेक्शन से अपना सर्वर चला सकते हैं, और रास्ते में क्या बाधा है।", + "Check": "जाँचें", + "DraftNotice": "यह अनुवाद एक मशीनी मसौदा है।", + "Language": "भाषा", + "StatusChecking": "कनेक्शन की जाँच हो रही है…", + "StatusOffline": "इंटरनेट से कनेक्ट नहीं है", + "StatusOnline": "इंटरनेट से कनेक्ट है", + "SuggestCorrection": "सुधार सुझाएँ", + "VersionLine": "me-web? {0} · मुक्त सॉफ़्टवेयर (AGPL-3.0)" +} diff --git a/src/MeWeb.App/Strings/it.json b/src/MeWeb.App/Strings/it.json new file mode 100644 index 0000000..a0307d8 --- /dev/null +++ b/src/MeWeb.App/Strings/it.json @@ -0,0 +1,11 @@ +{ + "AppTagline": "Scopra se da questa connessione può ospitare un server suo, e che cosa lo impedisce.", + "Check": "Verifica", + "DraftNotice": "Questa traduzione è una bozza automatica.", + "Language": "Lingua", + "StatusChecking": "Verifico la connessione…", + "StatusOffline": "Non connesso a internet", + "StatusOnline": "Connesso a internet", + "SuggestCorrection": "Proponi una correzione", + "VersionLine": "me-web? {0} · software libero (AGPL-3.0)" +} diff --git a/src/MeWeb.App/Strings/zh.json b/src/MeWeb.App/Strings/zh.json new file mode 100644 index 0000000..3e81a90 --- /dev/null +++ b/src/MeWeb.App/Strings/zh.json @@ -0,0 +1,11 @@ +{ + "AppTagline": "了解您能否通过此网络连接运行自己的服务器,以及有哪些障碍。", + "Check": "检测", + "DraftNotice": "此翻译为机器草稿。", + "Language": "语言", + "StatusChecking": "正在检查连接…", + "StatusOffline": "未连接到互联网", + "StatusOnline": "已连接到互联网", + "SuggestCorrection": "建议更正", + "VersionLine": "me-web? {0} · 自由软件(AGPL-3.0)" +} diff --git a/tools/ci.sh b/tools/ci.sh index d09349a..e060103 100755 --- a/tools/ci.sh +++ b/tools/ci.sh @@ -2,6 +2,7 @@ set -e . "$(dirname "$0")/sdk.sh" dotnet --version +dotnet run tools/strings.cs -- check for project in src/MeWeb.Desktop/MeWeb.Desktop.csproj src/MeWeb.Server/MeWeb.Server.csproj tools/Screenshots/Screenshots.csproj; do echo "== $project" dotnet build "$project" -c Release -nologo -nodeReuse:false -warnaserror diff --git a/tools/languages.cs b/tools/languages.cs new file mode 100644 index 0000000..f66a91f --- /dev/null +++ b/tools/languages.cs @@ -0,0 +1,100 @@ +using System.Globalization; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; + +const string Cldr = "47.0.0"; +string[] unLanguages = ["ar", "zh", "en", "fr", "ru", "es"]; +string[] reviewed = ["en", "it"]; +string[] officialStatuses = ["official", "de_facto_official"]; +string[] caselessScripts = ["ka"]; +var supplied = new Dictionary +{ + ["ay"] = ("Aymar aru", "Aymara", false), + ["bi"] = ("Bislama", "Bislama", false), + ["dv"] = ("ދިވެހި", "Dhivehi", true), + ["fj"] = ("Na Vosa Vakaviti", "Fijian", false), + ["gil"] = ("Taetae ni Kiribati", "Gilbertese", false), + ["gn"] = ("Avañe'ẽ", "Guarani", false), + ["hif"] = ("Fiji Baat", "Fiji Hindi", false), + ["ho"] = ("Hiri Motu", "Hiri Motu", false), + ["ht"] = ("Kreyòl ayisyen", "Haitian Creole", false), + ["mh"] = ("Kajin M̧ajeļ", "Marshallese", false), + ["ms-Arab"] = ("بهاس ملايو", "Malay (Arabic script)", true), + ["na"] = ("Dorerin Naoero", "Nauruan", false), + ["ny"] = ("Chichewa", "Chichewa", false), + ["pau"] = ("Tekoi ra Belau", "Palauan", false), + ["sm"] = ("Gagana Samoa", "Samoan", false), + ["ss"] = ("SiSwati", "Swati", false), + ["tet"] = ("Tetun", "Tetum", false), + ["tpi"] = ("Tok Pisin", "Tok Pisin", false), + ["tvl"] = ("Te Ggana Tuuvalu", "Tuvaluan", false), + ["wni"] = ("Shindzuani", "Ndzwani Comorian", false), + ["zdj"] = ("Shingazidja", "Ngazidja Comorian", false), +}; + +var root = FindRoot(); +var target = Path.Combine(root, "data", "languages.json"); +using var http = new HttpClient(); +http.DefaultRequestHeaders.UserAgent.ParseAdd("meweb-languages/1.0"); +var baseUrl = $"https://raw.githubusercontent.com/unicode-org/cldr-json/{Cldr}/cldr-json/cldr-core/supplemental"; +var territories = JsonNode.Parse(await http.GetStringAsync($"{baseUrl}/territoryInfo.json"))["supplemental"]["territoryInfo"].AsObject(); +var containment = JsonNode.Parse(await http.GetStringAsync($"{baseUrl}/territoryContainment.json"))["supplemental"]["territoryContainment"]; +var members = containment["UN"]["_contains"].AsArray().Select(n => (string)n).ToHashSet(); + +var known = CultureInfo.GetCultures(CultureTypes.NeutralCultures | CultureTypes.SpecificCultures).Select(c => c.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); +var codes = new SortedSet(unLanguages, StringComparer.Ordinal); +var missing = new SortedSet(StringComparer.Ordinal); +foreach (var (territory, info) in territories) +{ + if (!members.Contains(territory)) continue; + var population = info["languagePopulation"]?.AsObject(); + if (population is null) continue; + foreach (var (language, details) in population) + { + var status = (string)details["_officialStatus"]; + if (status is null || !officialStatuses.Contains(status)) continue; + var code = language.Replace('_', '-'); + if (known.Contains(code) || supplied.ContainsKey(code)) codes.Add(code); + else missing.Add(code); + } +} + +var previous = File.Exists(target) + ? JsonNode.Parse(File.ReadAllText(target)).AsArray().ToDictionary(n => (string)n["code"], n => (string)n["status"]) + : []; +var list = new JsonArray(); +foreach (var code in codes) +{ + var (name, english, rtl) = supplied.TryGetValue(code, out var given) ? given : FromCulture(code); + var status = reviewed.Contains(code) ? "reviewed" : previous.GetValueOrDefault(code) ?? "draft"; + list.Add((JsonNode)new JsonObject + { + ["code"] = code, + ["name"] = name, + ["english"] = english, + ["rtl"] = rtl, + ["status"] = status, + }); +} +File.WriteAllText(target, list.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }) + "\n"); +Console.WriteLine($"{list.Count} languages written to data/languages.json (CLDR {Cldr}, {members.Count} UN member states)"); +if (missing.Count > 0) Console.WriteLine($"official but neither in ICU nor supplied, left out: {string.Join(", ", missing)}"); +return 0; + +(string Name, string English, bool Rtl) FromCulture(string code) +{ + var culture = CultureInfo.GetCultureInfo(code); + var native = culture.NativeName; + var name = caselessScripts.Contains(code.Split('-')[0]) || native.Length == 0 + ? native + : culture.TextInfo.ToUpper(native[0]) + native[1..]; + return (name, culture.EnglishName, culture.TextInfo.IsRightToLeft); +} + +static string FindRoot() +{ + var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeWeb.slnx"))) dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("run from inside the repository (MeWeb.slnx not found)"); +} diff --git a/tools/strings.cs b/tools/strings.cs new file mode 100644 index 0000000..e618bc1 --- /dev/null +++ b/tools/strings.cs @@ -0,0 +1,71 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +var root = FindRoot(); +var folder = Path.Combine(root, "src", "MeWeb.App", "Strings"); +var options = new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; +var placeholder = new Regex(@"\{\d+\}"); + +if (args.Length >= 1 && args[0] == "check") return Check(); +if (args.Length == 3 && args[0] == "set") return Set(args[1], args[2]); +Console.Error.WriteLine("usage: dotnet run tools/strings.cs -- check | set (values.json maps a language code to its text)"); +return 2; + +int Check() +{ + var english = Read("en"); + var problems = 0; + foreach (var file in Directory.GetFiles(folder, "*.json").OrderBy(f => f, StringComparer.Ordinal)) + { + var code = Path.GetFileNameWithoutExtension(file); + if (code == "en") continue; + var values = Read(code); + var missing = english.Keys.Where(k => !values.ContainsKey(k)).ToList(); + var extra = values.Keys.Where(k => !english.ContainsKey(k)).ToList(); + var mismatched = english.Keys.Where(values.ContainsKey) + .Where(k => !placeholder.Matches(english[k]).Select(m => m.Value).Order().SequenceEqual(placeholder.Matches(values[k]).Select(m => m.Value).Order())).ToList(); + if (missing.Count + extra.Count + mismatched.Count == 0) continue; + problems++; + Console.WriteLine($"{code}: missing [{string.Join(", ", missing)}] extra [{string.Join(", ", extra)}] placeholders [{string.Join(", ", mismatched)}]"); + } + Console.WriteLine(problems == 0 ? $"strings: OK ({english.Count} keys)" : $"strings: {problems} language(s) to fix"); + return problems == 0 ? 0 : 1; +} + +int Set(string key, string valuesPath) +{ + var values = JsonNode.Parse(File.ReadAllText(valuesPath)).AsObject(); + if (values["en"] is null) throw new InvalidOperationException("values.json must carry at least \"en\""); + foreach (var (code, value) in values) + { + var strings = Read(code); + strings[key] = (string)value; + Write(code, strings); + } + Console.WriteLine($"{key}: set in {values.Count} language(s)"); + return 0; +} + +SortedDictionary Read(string code) +{ + var path = Path.Combine(folder, $"{code}.json"); + if (!File.Exists(path)) return new SortedDictionary(StringComparer.Ordinal); + var node = JsonNode.Parse(File.ReadAllText(path)).AsObject(); + return new SortedDictionary(node.ToDictionary(p => p.Key, p => (string)p.Value), StringComparer.Ordinal); +} + +void Write(string code, SortedDictionary strings) +{ + var node = new JsonObject(); + foreach (var (key, value) in strings) node[key] = value; + File.WriteAllText(Path.Combine(folder, $"{code}.json"), node.ToJsonString(options) + "\n"); +} + +static string FindRoot() +{ + var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeWeb.slnx"))) dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("run from inside the repository (MeWeb.slnx not found)"); +}