Skip to content

Commit e11a257

Browse files
authored
Redesign localization tool (#12)
* Rewrite localizer * Add language options * Update command description * Fix errors * Add debug profile * Update log * Remove build.bat * Fix translation * Update log * Fix CI * Update CI * Fix CI
1 parent 845e3d3 commit e11a257

File tree

10 files changed

+262
-286
lines changed

10 files changed

+262
-286
lines changed

.github/workflows/ci.yml

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
name: CI
22

33
on:
4-
pull_request:
54
push:
5+
branches: ["main"]
6+
pull_request:
7+
branches: ["main"]
68
workflow_dispatch:
79
inputs:
810
is_build:
@@ -21,24 +23,8 @@ jobs:
2123
with:
2224
dotnet-version: 8.0.x
2325

24-
- name: Build LocalizerScript
25-
run: |
26-
cd .\Scripts
27-
dotnet build
28-
29-
- name: Run LocalizerScript
30-
run: |
31-
cd .\Scripts\bin\Debug\net8.0
32-
.\LocalizerScript.exe "${{github.workspace}}/Views" "${{github.workspace}}/Strings"
33-
34-
- name: Upload .msixupload to artifacts
35-
uses: actions/upload-artifact@v4
36-
with:
37-
name: Strings
38-
path: "${{github.workspace}}/Strings"
39-
40-
41-
42-
43-
26+
- name: Build Localizer
27+
run: dotnet build FluentLauncher.Infra.Localizer
4428

29+
- name: Run Localizer
30+
run: dotnet run --project FluentLauncher.Infra.Localizer -- --src "Views" --out "Strings" --languages en-US zh-Hans zh-Hant ru-RU uk-UA
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<OutputType>Exe</OutputType>
55
<TargetFramework>net8.0</TargetFramework>
6-
<ImplicitUsings>enable</ImplicitUsings>
7-
<Nullable>disable</Nullable>
8-
<BaseOutputPath>..\bin</BaseOutputPath>
9-
<Platforms>AnyCPU;x64</Platforms>
6+
<Nullable>enable</Nullable>
107
</PropertyGroup>
118

129
<ItemGroup>
1310
<PackageReference Include="Csv" Version="2.0.93" />
11+
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
1412
</ItemGroup>
1513

1614
</Project>
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
using Csv;
2+
using System;
3+
using System.Collections.Generic;
4+
using System.CommandLine;
5+
using System.CommandLine.Parsing;
6+
using System.Data;
7+
using System.IO;
8+
using System.Linq;
9+
using System.Text;
10+
11+
List<string> Warnings = new();
12+
List<string> Errors = new();
13+
14+
var srcOption = new Option<string>("--src", "The source folder containing the .csv files") { IsRequired = true };
15+
var outOption = new Option<string>("--out", "The output folder for .resw files") { IsRequired = true };
16+
var languagesOption = new Option<IEnumerable<string>>("--languages", "All languages for translation") { IsRequired = true, AllowMultipleArgumentsPerToken = true };
17+
var defaultLanguageOption = new Option<string>("--default-language", () => "", "Default language of the app");
18+
defaultLanguageOption.AddValidator(result =>
19+
{
20+
IEnumerable<string> languages = result.GetValueForOption(languagesOption)!;
21+
string defaultLanguage = result.GetValueForOption(defaultLanguageOption)!;
22+
if (defaultLanguage != "" && !languages.Contains(defaultLanguage))
23+
result.ErrorMessage = "Default language must be in the list of languages";
24+
});
25+
26+
var rootCommand = new RootCommand("Convert .csv files to .resw files for UWP/WinUI localization");
27+
rootCommand.AddOption(srcOption);
28+
rootCommand.AddOption(outOption);
29+
rootCommand.AddOption(languagesOption);
30+
rootCommand.AddOption(defaultLanguageOption);
31+
rootCommand.SetHandler(ConvertCsvToResw, srcOption, outOption, languagesOption, defaultLanguageOption);
32+
rootCommand.Invoke(args);
33+
34+
void ConvertCsvToResw(string srcPath, string outPath, IEnumerable<string> languages, string defaultLanguage)
35+
{
36+
DirectoryInfo srcFolder = new(srcPath);
37+
DirectoryInfo outFolder = new(outPath);
38+
39+
// Init string resource table (key=language code, value=translated string resources)
40+
var strings = new Dictionary<string, Dictionary<string, string>>();
41+
foreach (string lang in languages)
42+
{
43+
strings[lang] = new();
44+
}
45+
46+
// Enumerate and parse all CSV files
47+
foreach (FileInfo file in srcFolder.EnumerateFiles("*.csv", SearchOption.AllDirectories))
48+
{
49+
string relativePath = Path.GetRelativePath(srcFolder.FullName, file.FullName);
50+
foreach (var str in ParseCsv(file, relativePath, languages))
51+
{
52+
foreach (string lang in languages)
53+
{
54+
string resourceId = relativePath[0..^".csv".Length].Replace(Path.DirectorySeparatorChar, '_') + "_" + str.GetName();
55+
strings[lang][resourceId] = str.Translations[lang];
56+
}
57+
}
58+
59+
}
60+
61+
// Print errors (invalid CSV files)
62+
Console.ForegroundColor = ConsoleColor.Red;
63+
64+
foreach (var item in Errors)
65+
Console.WriteLine(item);
66+
67+
if (Errors.Count > 0)
68+
{
69+
Console.WriteLine($"Failed to generate .resw files due to {Errors.Count} errors.");
70+
Environment.Exit(-1);
71+
}
72+
73+
// Print warnings (missing translations)
74+
Console.ForegroundColor = ConsoleColor.Yellow;
75+
76+
foreach (var item in Warnings)
77+
Console.WriteLine(item);
78+
79+
Console.ForegroundColor = ConsoleColor.Green;
80+
81+
// Generate .resw files
82+
if (!Directory.Exists(outFolder.FullName))
83+
Directory.CreateDirectory(outFolder.FullName);
84+
85+
foreach (string lang in languages)
86+
{
87+
// Build .resw file
88+
var reswBuilder = new StringBuilder();
89+
90+
reswBuilder.AppendLine("""
91+
<?xml version="1.0" encoding="utf-8"?>
92+
<root>
93+
<resheader name="resmimetype">
94+
<value>text/microsoft-resx</value>
95+
</resheader>
96+
<resheader name="version">
97+
<value>2.0</value>
98+
</resheader>
99+
<resheader name="reader">
100+
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
101+
</resheader>
102+
<resheader name="writer">
103+
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
104+
</resheader>
105+
""");
106+
107+
foreach ((string key, string translatedString) in strings[lang])
108+
{
109+
reswBuilder.AppendLine($"""
110+
<data name="{key}" xml:space="preserve">
111+
<value>{translatedString}</value>
112+
</data>
113+
""");
114+
}
115+
116+
reswBuilder.AppendLine("""
117+
</root>
118+
""");
119+
120+
// Write to file
121+
122+
string outputPath = lang == defaultLanguage
123+
? Path.Combine(outFolder.FullName, $"Resources.resw")
124+
: Path.Combine(outFolder.FullName, $"Resources.lang-{lang}.resw");
125+
var outputFile = new FileInfo(outputPath);
126+
File.WriteAllText(outputFile.FullName, reswBuilder.ToString());
127+
Console.WriteLine($"[INFO] Generated translation for {lang}: {outputFile.FullName}");
128+
}
129+
Console.WriteLine($"Successfully generated {strings.First().Value.Count} translations for {languages.Count()} languages.");
130+
}
131+
132+
133+
// Parse a CSV file
134+
IEnumerable<StringResource> ParseCsv(FileInfo csvFile, string relativePath, IEnumerable<string> languages)
135+
{
136+
var csvLines = CsvReader.ReadFromText(File.ReadAllText(csvFile.FullName));
137+
138+
// Check CSV headers
139+
var line = csvLines.FirstOrDefault();
140+
if (line is null) // Empty file
141+
return [];
142+
143+
bool invalid = false;
144+
if (!line.HasColumn("Id"))
145+
{
146+
Errors.Add($"[ERROR] {relativePath}: Missing column \"Id\"");
147+
invalid = true;
148+
}
149+
150+
if (!line.HasColumn("Property"))
151+
{
152+
Errors.Add($"[ERROR] {relativePath}: Missing column \"Property\"");
153+
invalid = true;
154+
}
155+
156+
foreach (string lang in languages)
157+
{
158+
if (!line.HasColumn(lang))
159+
{
160+
Errors.Add($"[ERROR] {relativePath}: Missing column for translation to {lang}");
161+
invalid = true;
162+
}
163+
}
164+
165+
if (invalid) return [];
166+
167+
// Parse lines
168+
IEnumerable<StringResource> lines = csvLines
169+
.Select(line => ParseLine(line, relativePath, languages))
170+
.Where(x => x is not null)!;
171+
return lines;
172+
}
173+
174+
// Parse a line in the CSV file
175+
StringResource? ParseLine(ICsvLine line, string relativePath, IEnumerable<string> languages)
176+
{
177+
// Error checking
178+
if (string.IsNullOrWhiteSpace(line["Id"]))
179+
{
180+
Errors.Add($"[ERROR] {relativePath}, Line {line.Index}: Id must not be empty");
181+
return null;
182+
}
183+
184+
if (line["Id"].StartsWith('_') && !string.IsNullOrEmpty(line["Property"]))
185+
{
186+
Errors.Add($"[ERROR] {relativePath}, Line {line.Index}: Property must be empty for strings for code-behind");
187+
return null;
188+
}
189+
190+
// Parse translations
191+
Dictionary<string, string> translations = new();
192+
193+
foreach (string lang in languages)
194+
{
195+
if (line[lang] == "") // Missing translation
196+
{
197+
Warnings.Add($"[WARNING] {relativePath}, Line {line.Index}: Missing translation to {lang}");
198+
}
199+
200+
translations[lang] = line[lang];
201+
}
202+
203+
var resource = new StringResource
204+
{
205+
Uid = line["Id"],
206+
Property = line["Property"],
207+
Translations = translations
208+
};
209+
210+
return resource;
211+
}
212+
213+
214+
/// <summary>
215+
/// Represents a string resource with translations for different languages
216+
/// </summary>
217+
record class StringResource
218+
{
219+
/// <summary>
220+
/// x:Uid of the component (if used in XAML) or ID of the resource (if used in code behind)
221+
/// </summary>
222+
public required string Uid { get; init; }
223+
224+
/// <summary>
225+
/// Property name of the component (if used in XAML)
226+
/// </summary>
227+
public required string Property { get; init; }
228+
229+
/// <summary>
230+
/// Translations for different languages (key=language code, value=translated string)
231+
/// </summary>
232+
public required Dictionary<string, string> Translations { get; init; }
233+
234+
public string GetName()
235+
{
236+
if (Uid.StartsWith('_'))
237+
return Uid;
238+
239+
return $"{Uid}.{Property}";
240+
}
241+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"profiles": {
3+
"GenerateReswFiles": {
4+
"commandName": "Project",
5+
"commandLineArgs": "--src Views --out Strings --languages en-US zh-Hans zh-Hant ru-RU uk-UA",
6+
"workingDirectory": ".."
7+
}
8+
}
9+
}

Scripts/LocalizerScript.sln

Lines changed: 0 additions & 31 deletions
This file was deleted.

0 commit comments

Comments
 (0)