2026-10-15 01:18:13 +02:00
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+\}" );
2026-10-15 22:49:40 +02:00
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 ]»|[""'“”„‟«»‹›「」『』‘’‚])?" );
2026-10-15 01:18:13 +02:00
if ( args . Length >= 1 && args [ 0 ] == "check" ) return Check ();
if ( args . Length == 3 && args [ 0 ] == "set" ) return Set ( args [ 1 ], args [ 2 ]);
2026-10-15 04:39:03 +02:00
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 ]);
2026-10-15 22:49:40 +02:00
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" );
2026-10-15 04:39:03 +02:00
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" );
2026-10-15 01:18:13 +02:00
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 );
2026-10-15 22:49:40 +02:00
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 ;
2026-10-15 01:18:13 +02:00
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 ;
}
2026-10-15 04:39:03 +02:00
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 ;
}
2026-10-15 22:49:40 +02:00
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 ;
}
2026-10-15 01:18:13 +02:00
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)" );
}