diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e5f811..36cea23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,10 @@ name: CI on: - pull_request: push: + branches: ["main"] + pull_request: + branches: ["main"] workflow_dispatch: inputs: is_build: @@ -21,24 +23,8 @@ jobs: with: dotnet-version: 8.0.x - - name: Build LocalizerScript - run: | - cd .\Scripts - dotnet build - - - name: Run LocalizerScript - run: | - cd .\Scripts\bin\Debug\net8.0 - .\LocalizerScript.exe "${{github.workspace}}/Views" "${{github.workspace}}/Strings" - - - name: Upload .msixupload to artifacts - uses: actions/upload-artifact@v4 - with: - name: Strings - path: "${{github.workspace}}/Strings" - - - - - + - name: Build Localizer + run: dotnet build FluentLauncher.Infra.Localizer + - name: Run Localizer + run: dotnet run --project FluentLauncher.Infra.Localizer -- --src "Views" --out "Strings" --languages en-US zh-Hans zh-Hant ru-RU uk-UA diff --git a/Scripts/LocalizerScript/LocalizerScript.csproj b/FluentLauncher.Infra.Localizer/FluentLauncher.Infra.Localizer.csproj similarity index 52% rename from Scripts/LocalizerScript/LocalizerScript.csproj rename to FluentLauncher.Infra.Localizer/FluentLauncher.Infra.Localizer.csproj index 29d8a9e..b81bf97 100644 --- a/Scripts/LocalizerScript/LocalizerScript.csproj +++ b/FluentLauncher.Infra.Localizer/FluentLauncher.Infra.Localizer.csproj @@ -1,16 +1,14 @@ - + Exe net8.0 - enable - disable - ..\bin - AnyCPU;x64 + enable + diff --git a/FluentLauncher.Infra.Localizer/Program.cs b/FluentLauncher.Infra.Localizer/Program.cs new file mode 100644 index 0000000..4f40433 --- /dev/null +++ b/FluentLauncher.Infra.Localizer/Program.cs @@ -0,0 +1,241 @@ +using Csv; +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Data; +using System.IO; +using System.Linq; +using System.Text; + +List Warnings = new(); +List Errors = new(); + +var srcOption = new Option("--src", "The source folder containing the .csv files") { IsRequired = true }; +var outOption = new Option("--out", "The output folder for .resw files") { IsRequired = true }; +var languagesOption = new Option>("--languages", "All languages for translation") { IsRequired = true, AllowMultipleArgumentsPerToken = true }; +var defaultLanguageOption = new Option("--default-language", () => "", "Default language of the app"); +defaultLanguageOption.AddValidator(result => +{ + IEnumerable languages = result.GetValueForOption(languagesOption)!; + string defaultLanguage = result.GetValueForOption(defaultLanguageOption)!; + if (defaultLanguage != "" && !languages.Contains(defaultLanguage)) + result.ErrorMessage = "Default language must be in the list of languages"; +}); + +var rootCommand = new RootCommand("Convert .csv files to .resw files for UWP/WinUI localization"); +rootCommand.AddOption(srcOption); +rootCommand.AddOption(outOption); +rootCommand.AddOption(languagesOption); +rootCommand.AddOption(defaultLanguageOption); +rootCommand.SetHandler(ConvertCsvToResw, srcOption, outOption, languagesOption, defaultLanguageOption); +rootCommand.Invoke(args); + +void ConvertCsvToResw(string srcPath, string outPath, IEnumerable languages, string defaultLanguage) +{ + DirectoryInfo srcFolder = new(srcPath); + DirectoryInfo outFolder = new(outPath); + + // Init string resource table (key=language code, value=translated string resources) + var strings = new Dictionary>(); + foreach (string lang in languages) + { + strings[lang] = new(); + } + + // Enumerate and parse all CSV files + foreach (FileInfo file in srcFolder.EnumerateFiles("*.csv", SearchOption.AllDirectories)) + { + string relativePath = Path.GetRelativePath(srcFolder.FullName, file.FullName); + foreach (var str in ParseCsv(file, relativePath, languages)) + { + foreach (string lang in languages) + { + string resourceId = relativePath[0..^".csv".Length].Replace(Path.DirectorySeparatorChar, '_') + "_" + str.GetName(); + strings[lang][resourceId] = str.Translations[lang]; + } + } + + } + + // Print errors (invalid CSV files) + Console.ForegroundColor = ConsoleColor.Red; + + foreach (var item in Errors) + Console.WriteLine(item); + + if (Errors.Count > 0) + { + Console.WriteLine($"Failed to generate .resw files due to {Errors.Count} errors."); + Environment.Exit(-1); + } + + // Print warnings (missing translations) + Console.ForegroundColor = ConsoleColor.Yellow; + + foreach (var item in Warnings) + Console.WriteLine(item); + + Console.ForegroundColor = ConsoleColor.Green; + + // Generate .resw files + if (!Directory.Exists(outFolder.FullName)) + Directory.CreateDirectory(outFolder.FullName); + + foreach (string lang in languages) + { + // Build .resw file + var reswBuilder = new StringBuilder(); + + reswBuilder.AppendLine(""" + + + + 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 + + """); + + foreach ((string key, string translatedString) in strings[lang]) + { + reswBuilder.AppendLine($""" + + {translatedString} + + """); + } + + reswBuilder.AppendLine(""" + + """); + + // Write to file + + string outputPath = lang == defaultLanguage + ? Path.Combine(outFolder.FullName, $"Resources.resw") + : Path.Combine(outFolder.FullName, $"Resources.lang-{lang}.resw"); + var outputFile = new FileInfo(outputPath); + File.WriteAllText(outputFile.FullName, reswBuilder.ToString()); + Console.WriteLine($"[INFO] Generated translation for {lang}: {outputFile.FullName}"); + } + Console.WriteLine($"Successfully generated {strings.First().Value.Count} translations for {languages.Count()} languages."); +} + + +// Parse a CSV file +IEnumerable ParseCsv(FileInfo csvFile, string relativePath, IEnumerable languages) +{ + var csvLines = CsvReader.ReadFromText(File.ReadAllText(csvFile.FullName)); + + // Check CSV headers + var line = csvLines.FirstOrDefault(); + if (line is null) // Empty file + return []; + + bool invalid = false; + if (!line.HasColumn("Id")) + { + Errors.Add($"[ERROR] {relativePath}: Missing column \"Id\""); + invalid = true; + } + + if (!line.HasColumn("Property")) + { + Errors.Add($"[ERROR] {relativePath}: Missing column \"Property\""); + invalid = true; + } + + foreach (string lang in languages) + { + if (!line.HasColumn(lang)) + { + Errors.Add($"[ERROR] {relativePath}: Missing column for translation to {lang}"); + invalid = true; + } + } + + if (invalid) return []; + + // Parse lines + IEnumerable lines = csvLines + .Select(line => ParseLine(line, relativePath, languages)) + .Where(x => x is not null)!; + return lines; +} + +// Parse a line in the CSV file +StringResource? ParseLine(ICsvLine line, string relativePath, IEnumerable languages) +{ + // Error checking + if (string.IsNullOrWhiteSpace(line["Id"])) + { + Errors.Add($"[ERROR] {relativePath}, Line {line.Index}: Id must not be empty"); + return null; + } + + if (line["Id"].StartsWith('_') && !string.IsNullOrEmpty(line["Property"])) + { + Errors.Add($"[ERROR] {relativePath}, Line {line.Index}: Property must be empty for strings for code-behind"); + return null; + } + + // Parse translations + Dictionary translations = new(); + + foreach (string lang in languages) + { + if (line[lang] == "") // Missing translation + { + Warnings.Add($"[WARNING] {relativePath}, Line {line.Index}: Missing translation to {lang}"); + } + + translations[lang] = line[lang]; + } + + var resource = new StringResource + { + Uid = line["Id"], + Property = line["Property"], + Translations = translations + }; + + return resource; +} + + +/// +/// Represents a string resource with translations for different languages +/// +record class StringResource +{ + /// + /// x:Uid of the component (if used in XAML) or ID of the resource (if used in code behind) + /// + public required string Uid { get; init; } + + /// + /// Property name of the component (if used in XAML) + /// + public required string Property { get; init; } + + /// + /// Translations for different languages (key=language code, value=translated string) + /// + public required Dictionary Translations { get; init; } + + public string GetName() + { + if (Uid.StartsWith('_')) + return Uid; + + return $"{Uid}.{Property}"; + } +} \ No newline at end of file diff --git a/FluentLauncher.Infra.Localizer/Properties/launchSettings.json b/FluentLauncher.Infra.Localizer/Properties/launchSettings.json new file mode 100644 index 0000000..1ce140d --- /dev/null +++ b/FluentLauncher.Infra.Localizer/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "GenerateReswFiles": { + "commandName": "Project", + "commandLineArgs": "--src Views --out Strings --languages en-US zh-Hans zh-Hant ru-RU uk-UA", + "workingDirectory": ".." + } + } +} \ No newline at end of file diff --git a/Scripts/LocalizerScript.sln b/Scripts/LocalizerScript.sln deleted file mode 100644 index 8953835..0000000 --- a/Scripts/LocalizerScript.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.6.33723.286 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LocalizerScript", "LocalizerScript\LocalizerScript.csproj", "{CA124E49-92CC-42E8-AB9D-66F1C2C76F12}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {CA124E49-92CC-42E8-AB9D-66F1C2C76F12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CA124E49-92CC-42E8-AB9D-66F1C2C76F12}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CA124E49-92CC-42E8-AB9D-66F1C2C76F12}.Debug|x64.ActiveCfg = Debug|x64 - {CA124E49-92CC-42E8-AB9D-66F1C2C76F12}.Debug|x64.Build.0 = Debug|x64 - {CA124E49-92CC-42E8-AB9D-66F1C2C76F12}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CA124E49-92CC-42E8-AB9D-66F1C2C76F12}.Release|Any CPU.Build.0 = Release|Any CPU - {CA124E49-92CC-42E8-AB9D-66F1C2C76F12}.Release|x64.ActiveCfg = Release|x64 - {CA124E49-92CC-42E8-AB9D-66F1C2C76F12}.Release|x64.Build.0 = Release|x64 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {F6113517-B49A-4F64-B491-14F5DB7E6611} - EndGlobalSection -EndGlobal diff --git a/Scripts/LocalizerScript/Program.cs b/Scripts/LocalizerScript/Program.cs deleted file mode 100644 index c8012c7..0000000 --- a/Scripts/LocalizerScript/Program.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System.Text; - -namespace LocalizerScript; - -internal class Program -{ - private static readonly List Languages = new() - { - "en-US", - "zh-Hans", - "zh-Hant", - "ru-RU", - "uk-UA" - }; - - private static DirectoryInfo OutputsFolder; - private static DirectoryInfo SourcesFolder; - - private static readonly List Warns = new(); - private static readonly List Errors = new(); - - static void Main(string[] args) - { - SourcesFolder = new DirectoryInfo(args[0]); - OutputsFolder = new DirectoryInfo(args[1]); - - var keyValuePairs = GetResources(SourcesFolder); - - var languages = new Dictionary>(); - int found = 0; - - foreach (var item in Languages) - languages.Add(item, new()); - - foreach (var keyValuePair in keyValuePairs) - { - var relativePath = keyValuePair.Key.Trim('\\'); - - foreach (var @string in keyValuePair.Value) - { - var Path_Name = relativePath.Replace(".csv", string.Empty).Replace('\\', '_') + "_" + @string.GetName(); - found++; - - int empty = 0; - - foreach (var value in @string.Values) - { - if (string.IsNullOrEmpty(value.Value)) - empty++; - - languages[value.Key].Add(Path_Name, value.Value); - } - - if (empty > 0) - Warns.Add($"[WARN]:at {relativePath}, 资源 {@string.GetName()} 有 {empty} 个空项"); - } - } - - Console.WriteLine($"[INFO] 已找到 {found} 个资源"); - - Console.ForegroundColor = ConsoleColor.Yellow; - - foreach (var item in Warns) - Console.WriteLine(item); - - Console.ForegroundColor = ConsoleColor.Red; - - foreach (var item in Errors) - Console.WriteLine(item); - - if (Errors.Count > 0) - throw new Exception("Invalid Csvs"); - - Console.ForegroundColor = ConsoleColor.Green; - - foreach (var item in languages) - { - var outputFile = new FileInfo(Path.Combine(OutputsFolder.FullName, "Strings", item.Key, "Resources.resw")); - - if (!outputFile.Directory.Exists) - outputFile.Directory.Create(); - - File.WriteAllText(outputFile.FullName, BuildResw(item.Value)); - Console.WriteLine($"[INFO] 已生成资源文件:{outputFile.FullName}"); - } - - Console.ReadLine(); - } - - private static Dictionary> GetResources(DirectoryInfo directory) - { - var dic = new Dictionary>(); - - foreach (var file in directory.EnumerateFiles()) - { - if (file.Extension.Equals(".csv")) - { - var csvLines = Csv.CsvReader.ReadFromText(File.ReadAllText(file.FullName)); - - var relativePath = file.FullName.Replace(SourcesFolder.FullName, string.Empty); - var lines = csvLines.Select(x => ParseLine(relativePath, x)).Where(x => x != null).ToList(); - - dic.Add(relativePath, lines); - } - } - - foreach (var directoryInfo in directory.EnumerateDirectories()) - foreach (var item in GetResources(directoryInfo)) - dic.Add(item.Key, item.Value); - - return dic; - } - - private static StringResource ParseLine(string relativePath, Csv.ICsvLine line) - { - var values = line.Values.ToList(); - - if (values.Count != Languages.Count + 2) - { - Errors.Add($"[ERROR]:at {relativePath}, Line {line.Index} : 项数目不正确"); - return null; - } - - if (string.IsNullOrEmpty(values[0])) - { - Errors.Add($"[ERROR]:at {relativePath}, Line {line.Index} : 资源Id 不能为空"); - return null; - } - - if (values[0].StartsWith('_') && !string.IsNullOrEmpty(values[1])) - { - Errors.Add($"[ERROR]:at {relativePath}, Line {line.Index} : 资源Id 标记为后台代码,但 资源属性Id 又不为空"); - return null; - } - - var @string = new StringResource - { - Uid = values[0], - Property = values[1] - }; - - values.RemoveAt(0); - values.RemoveAt(0); - - var dic = new Dictionary(); - - for (int i = 0; i < values.Count; i++) - dic.Add(Languages[i], values[i]); - - @string.Values = dic; - - return @string; - } - - static string head = """ - - - - 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 - - """; - - static string dataTemplate = """ - - ${Value} - - """; - - static string end = """ - - """; - - private static string BuildResw(Dictionary values) - { - var @string = new StringBuilder(); - - @string.AppendLine(head); - - foreach (var item in values) - @string.AppendLine(dataTemplate.Replace("${Name}", item.Key).Replace("${Value}", item.Value)); - - @string.AppendLine(end); - - return @string.ToString(); - } -} - -public class StringResource -{ - public string Uid { get; set; } - - public string Property { get; set; } - - public Dictionary Values { get; set; } - - public string GetName() - { - if (Uid.StartsWith('_')) - return Uid; - - return $"{Uid}.{Property}"; - } -} \ No newline at end of file diff --git a/Scripts/LocalizerScript/Properties/launchSettings.json b/Scripts/LocalizerScript/Properties/launchSettings.json deleted file mode 100644 index e1448b9..0000000 --- a/Scripts/LocalizerScript/Properties/launchSettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "profiles": { - "LocalizerScript": { - "commandName": "Project", - "commandLineArgs": ".\\Views .\\", - "workingDirectory": "..\\..\\" - } - } -} \ No newline at end of file diff --git a/Views/Dictionaries/TaskViewStyleDictionary.csv b/Views/Dictionaries/TaskViewStyleDictionary.csv index 87fdf0d..a4714ee 100644 --- a/Views/Dictionaries/TaskViewStyleDictionary.csv +++ b/Views/Dictionaries/TaskViewStyleDictionary.csv @@ -1,4 +1,4 @@ -Id,Property,en-US,zh-Hans,zh-Hant,ru-RU,uk-UA +Id,Property,en-US,zh-Hans,zh-Hant,ru-RU,uk-UA DownloadGameResourceTitle,Text,Download Resource:,下载资源:,下載資源:,Ресурс загрузки: ,Ресурс завантаження: InstallInstanceTitle,Text,Install Instance:,安装实例:,安裝實例:,Установить экземпляр: ,Встановити екземпляр: LaunchTitle,Text,Launch Minecraft:,启动 Minecraft:,啟動 Minecraft:,Запустить Minecraft: ,Запустити Minecraft: diff --git a/Views/ShellPage.csv b/Views/ShellPage.csv index 60ed662..7b85995 100644 --- a/Views/ShellPage.csv +++ b/Views/ShellPage.csv @@ -1,4 +1,4 @@ -Id,Property,en-US,zh-Hans,zh-Hant,ru-RU +Id,Property,en-US,zh-Hans,zh-Hant,ru-RU,uk-UA NV_Item_1,Content,Home,主页面,主頁面,Главная,Головна NV_Item_2,Content,Cores,核心,核心,Ядра,Ядра NV_Item_3,Content,Download Resources,资源下载,資源下載,Скачать Ресурсы,Завантажити Ресурси diff --git a/build.bat b/build.bat deleted file mode 100644 index f6ee1b9..0000000 --- a/build.bat +++ /dev/null @@ -1,6 +0,0 @@ -@echo off -cd .\Scripts -dotnet build --configuration Release /p:Platform="Any CPU" -cd ..\ -.\Scripts\bin\Release\net8.0\LocalizerScript.exe .\Views .\ -pause \ No newline at end of file