- The provider card now names the address block your connection belongs to in Asia and the Pacific, in Africa, and in Latin America and the Caribbean, not only in Europe and the Middle East. The blocks come from the address registries APNIC, AFRINIC and LACNIC, used with their permission, and travel inside the app, so this works without a connection too. - In Latin America and the Caribbean the registry gives no names for its blocks, so the card says how the block was handed out instead, for example "passed on by the provider to one of its customers". - The app is about 6 MB larger, because it carries these blocks. - The app's name no longer has quotation marks where it stands on its own: the window title, the name under the icon, the version line and the website's titles. Inside a sentence it keeps them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
7.1 KiB
C#
172 lines
7.1 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 alone = new HashSet<string>(["AppName", "VersionLine"], StringComparer.Ordinal);
|
|
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 => !alone.Contains(p.Key) && 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)}]");
|
|
}
|
|
var quoted = values.Where(p => alone.Contains(p.Key) && named.Matches(p.Value).Any(m => m.Value != Name)).Select(p => p.Key).ToList();
|
|
if (quoted.Count > 0)
|
|
{
|
|
problems++;
|
|
Console.WriteLine($"{code}: {Name} quoted where it stands alone in [{string.Join(", ", quoted)}]");
|
|
}
|
|
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, alone.Contains(p.Key) ? Name : 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)");
|
|
}
|