- The name is now always written in quotation marks, each language with its own: “me-web?”, «me-web?», „me-web?“, « me-web? », 「me-web?」. Without them the question mark read as the end of a sentence. In Arabic, Hebrew and the other right-to-left languages the question mark no longer jumps to the wrong side of the name. - Your addresses, your router, your network, the reverse name, the route and the code of a submission you sent now sit in a small box with a copy button, ready to paste into a message to your provider or anywhere else. - Every translation except English and Italian was rewritten for people who are not technical: everyday words first, the technical term in brackets, and one word for one thing across the whole app. They are still machine drafts; "Suggest a correction" is welcome in any language. - A few lines are plainer in English and Italian too: the test servers for calls, and the server in the middle that carries a call when two homes can't connect directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
165 lines
6.7 KiB
C#
165 lines
6.7 KiB
C#
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+\}");
|
|
const string Name = "me-web?";
|
|
var quotes = JsonNode.Parse(File.ReadAllText(Path.Combine(root, "data", "languages.json"))).AsArray()
|
|
.ToDictionary(n => (string)n["code"], n => ((string)n["quotes"][0], (string)n["quotes"][1]));
|
|
var named = new Regex(@"(?:«[\u00A0\u202F ]|[""'“”„‟«»‹›「」『』‘’‚])?me-web\?(?:[\u00A0\u202F ]»|[""'“”„‟«»‹›「」『』‘’‚])?");
|
|
|
|
if (args.Length >= 1 && args[0] == "check") return Check();
|
|
if (args.Length == 3 && args[0] == "set") return Set(args[1], args[2]);
|
|
if (args.Length == 2 && args[0] == "merge") return Merge(args[1]);
|
|
if (args.Length == 3 && args[0] == "import") return Import(args[1], args[2]);
|
|
if (args.Length == 2 && args[0] == "drop") return Drop(args[1]);
|
|
if (args.Length == 1 && args[0] == "quote") return Quote();
|
|
Console.Error.WriteLine("usage: dotnet run tools/strings.cs -- check | set <Key> <values.json> | merge <keys.json> | import <code> <strings.json> | drop <Key> | quote");
|
|
Console.Error.WriteLine(" values.json maps a language code to its text; keys.json maps keys to such maps; strings.json maps keys to one language's 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);
|
|
var values = Read(code);
|
|
var (open, close) = quotes[code];
|
|
var bare = values.Where(p => Bare(p.Value, open, close)).Select(p => p.Key).ToList();
|
|
if (bare.Count > 0)
|
|
{
|
|
problems++;
|
|
Console.WriteLine($"{code}: {Name} not between {open.Trim()} and {close.Trim()} in [{string.Join(", ", bare)}]");
|
|
}
|
|
if (code == "en") continue;
|
|
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;
|
|
}
|
|
|
|
int Merge(string path)
|
|
{
|
|
var keys = JsonNode.Parse(File.ReadAllText(path)).AsObject();
|
|
var byLanguage = new Dictionary<string, SortedDictionary<string, string>>();
|
|
foreach (var (key, values) in keys)
|
|
{
|
|
if (values?["en"] is null) throw new InvalidOperationException($"{key} carries no \"en\"");
|
|
foreach (var (code, value) in values.AsObject())
|
|
{
|
|
if (!byLanguage.TryGetValue(code, out var strings)) byLanguage[code] = strings = Read(code);
|
|
strings[key] = (string)value;
|
|
}
|
|
}
|
|
foreach (var (code, strings) in byLanguage) Write(code, strings);
|
|
Console.WriteLine($"merge: {keys.Count} key(s) into {byLanguage.Count} language(s)");
|
|
return 0;
|
|
}
|
|
|
|
int Import(string code, string path)
|
|
{
|
|
var english = Read("en");
|
|
var incoming = JsonNode.Parse(File.ReadAllText(path)).AsObject();
|
|
var strings = Read(code);
|
|
var unknown = new List<string>();
|
|
foreach (var (key, value) in incoming)
|
|
{
|
|
if (!english.ContainsKey(key))
|
|
{
|
|
unknown.Add(key);
|
|
continue;
|
|
}
|
|
strings[key] = (string)value;
|
|
}
|
|
Write(code, strings);
|
|
Console.WriteLine($"import {code}: {incoming.Count - unknown.Count} key(s){(unknown.Count > 0 ? $", ignored unknown [{string.Join(", ", unknown)}]" : "")}");
|
|
return unknown.Count == 0 ? 0 : 1;
|
|
}
|
|
|
|
int Drop(string key)
|
|
{
|
|
var count = 0;
|
|
foreach (var file in Directory.GetFiles(folder, "*.json"))
|
|
{
|
|
var code = Path.GetFileNameWithoutExtension(file);
|
|
var strings = Read(code);
|
|
if (!strings.Remove(key)) continue;
|
|
Write(code, strings);
|
|
count++;
|
|
}
|
|
Console.WriteLine($"{key}: dropped from {count} language(s)");
|
|
return 0;
|
|
}
|
|
|
|
int Quote()
|
|
{
|
|
var changed = 0;
|
|
foreach (var file in Directory.GetFiles(folder, "*.json").OrderBy(f => f, StringComparer.Ordinal))
|
|
{
|
|
var code = Path.GetFileNameWithoutExtension(file);
|
|
var (open, close) = quotes[code];
|
|
var strings = Read(code);
|
|
var quoted = new SortedDictionary<string, string>(strings.ToDictionary(p => p.Key, p => named.Replace(p.Value, open + Name + close)), StringComparer.Ordinal);
|
|
if (quoted.SequenceEqual(strings)) continue;
|
|
Write(code, quoted);
|
|
changed++;
|
|
}
|
|
Console.WriteLine($"quote: {changed} language(s) changed");
|
|
return 0;
|
|
}
|
|
|
|
static bool Bare(string text, string open, string close)
|
|
{
|
|
for (var at = text.IndexOf(Name, StringComparison.Ordinal); at >= 0; at = text.IndexOf(Name, at + Name.Length, StringComparison.Ordinal))
|
|
if (!text[..at].EndsWith(open, StringComparison.Ordinal) || !text[(at + Name.Length)..].StartsWith(close, StringComparison.Ordinal)) return true;
|
|
return false;
|
|
}
|
|
|
|
SortedDictionary<string, string> Read(string code)
|
|
{
|
|
var path = Path.Combine(folder, $"{code}.json");
|
|
if (!File.Exists(path)) return new SortedDictionary<string, string>(StringComparer.Ordinal);
|
|
var node = JsonNode.Parse(File.ReadAllText(path)).AsObject();
|
|
return new SortedDictionary<string, string>(node.ToDictionary(p => p.Key, p => (string)p.Value), StringComparer.Ordinal);
|
|
}
|
|
|
|
void Write(string code, SortedDictionary<string, string> 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)");
|
|
}
|