diff --git a/src/wan24-I8NKws/KwsCatalog.cs b/src/wan24-I8NKws/KwsCatalog.cs new file mode 100644 index 0000000..de92c01 --- /dev/null +++ b/src/wan24-I8NKws/KwsCatalog.cs @@ -0,0 +1,88 @@ +using System.ComponentModel.DataAnnotations; +using wan24.Core; +using wan24.ObjectValidation; + +namespace wan24.I8NKws +{ + /// + /// KWS catalog + /// + public sealed class KwsCatalog + { + /// + /// Constructor + /// + public KwsCatalog() { } + + /// + /// Project name + /// + public string Project { get; set; } = string.Empty; + + /// + /// Created time (UTC) + /// + public DateTime Created { get; set; } = DateTime.UtcNow; + + /// + /// Modified time (UTC) + /// + public DateTime Modified { get; set; } = DateTime.UtcNow; + + /// + /// Translator name + /// + public string Translator { get; set; } = string.Empty; + + /// + /// Locale identifier + /// + [RegularExpression(RegularExpressions.LOCALE_WITH_DASH)] + public string Locale { get; set; } = "en-US"; + + /// + /// If the text is written right to left + /// + public bool RightToLeft { get; set; } + + /// + /// Keywords + /// + public HashSet Keywords { get; } = []; + + /// + /// Validate the catalog + /// + /// Throw an exception on error? + /// Require all translations to be complete? + /// If the catalog is valid + /// Catalog is invalid + public bool Validate(in bool throwOnError = true, in bool requireCompleteTranslations = false) + { + if (Keywords.Count == 0) + { + if (!throwOnError) return false; + throw new InvalidDataException("Missing keywords"); + } + foreach (KwsKeyword keyword in Keywords) + { + if (string.IsNullOrEmpty(keyword.ID)) + { + if (!throwOnError) return false; + throw new InvalidDataException("Found keyword with missing ID"); + } + if (requireCompleteTranslations && keyword.TranslationMissing) + { + if (!throwOnError) return false; + throw new InvalidDataException($"Missing translation of ID \"{keyword.ID}\""); + } + } + if(!this.TryValidateObject(out List results, throwOnError: false)) + { + if (!throwOnError) return false; + throw new InvalidDataException($"Found {results.Count} object errors - first error: {results.First().ErrorMessage}"); + } + return true; + } + } +} diff --git a/src/wan24-I8NKws/KwsKeyword.cs b/src/wan24-I8NKws/KwsKeyword.cs new file mode 100644 index 0000000..bc3fbbb --- /dev/null +++ b/src/wan24-I8NKws/KwsKeyword.cs @@ -0,0 +1,122 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace wan24.I8NKws +{ + /// + /// KWS keyword record + /// + public sealed record class KwsKeyword + { + /// + /// Constructor + /// + public KwsKeyword() { } + + /// + /// Constructor + /// + /// ID + public KwsKeyword(in string id) + { + if (id.Length < 1) throw new ArgumentException("ID required", nameof(id)); + ID = id; + } + + /// + /// ID (keyword) + /// + [MinLength(1)] + public string ID { get; private set; } = null!; + + /// + /// Previous IDs (extended when the ID is being updated; last entry was the latest ID) + /// + public HashSet PreviousIds { get; private set; } = []; + + /// + /// Extracted time (UTC) + /// + public DateTime Extracted { get; set; } = DateTime.UtcNow; + + /// + /// Updated time (UTC) + /// + public DateTime Updated { get; set; } = DateTime.UtcNow; + + /// + /// Latest translator name + /// + public string Translator { get; set; } = string.Empty; + + /// + /// Developer comments + /// + public string DeveloperComments { get; set; } = string.Empty; + + /// + /// Translator comments + /// + public string TranslatorComments { get; set; } = string.Empty; + + /// + /// If this keyword is obsolete and should not be exported + /// + public bool Obsolete { get; set; } + + /// + /// If the update of the ID has been done automatic using fuzzy logic search + /// + public bool Fuzzy { get; set; } + + /// + /// If a translation is missing (no translation at all, or any empty translations) + /// + [JsonIgnore] + public bool TranslationMissing => Translations.Count == 0 || Translations.Any(t => t.Length == 0); + + /// + /// Translations + /// + public List Translations { get; } = []; + + /// + /// Source references + /// + public HashSet SourceReferences { get; } = []; + + /// + /// Update the ID + /// + /// New ID + public void UpdateId(in string newId) + { + if (Obsolete) throw new InvalidOperationException(); + if (newId.Length < 1) throw new ArgumentException("ID required", nameof(newId)); + string oldId = ID; + if (newId == oldId) return; + ID = newId; + PreviousIds.Remove(oldId); + PreviousIds.Add(oldId); + } + + /// + /// Undo an ID update + /// + /// Target ID to use + public void UndoIdUpdate(string? id = null) + { + if (Obsolete || PreviousIds.Count == 0) throw new InvalidOperationException(); + if (id is null) + { + id = PreviousIds.Last(); + } + else if (!PreviousIds.Contains(id)) + { + throw new ArgumentException("Unknown previous ID", nameof(id)); + } + ID = id; + PreviousIds = [.. PreviousIds.SkipWhile(pid => pid != id).Skip(1)]; + } + } +} diff --git a/src/wan24-I8NKws/KwsSourceReference.cs b/src/wan24-I8NKws/KwsSourceReference.cs new file mode 100644 index 0000000..e0c04c7 --- /dev/null +++ b/src/wan24-I8NKws/KwsSourceReference.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace wan24.I8NKws +{ + /// + /// KWS source reference + /// + public sealed record class KwsSourceReference + { + /// + /// Constructor + /// + public KwsSourceReference() { } + + /// + /// Filename + /// + [MinLength(1)] + public required string FileName { get; init; } + + /// + /// Line number (starts with 1) + /// + [Range(1, int.MaxValue)] + public required int LineNumber { get; init; } + } +} diff --git a/src/wan24-I8NKws/LICENSE b/src/wan24-I8NKws/LICENSE new file mode 100644 index 0000000..486ce20 --- /dev/null +++ b/src/wan24-I8NKws/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Andreas Zimmermann, wan24.de + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/wan24-I8NKws/README.md b/src/wan24-I8NKws/README.md new file mode 100644 index 0000000..9bb3c27 --- /dev/null +++ b/src/wan24-I8NKws/README.md @@ -0,0 +1,228 @@ +# wan24-I8NTool + +This is a small dotnet tool for extracting stings to translate from source +code and writing the result in the PO format to a file or STDOUT. It can also +be used to create i8n files, which are easy to use from any app. + +It's pre-configured for use with the +[`wan24-Core`](https://github.com/WAN-Solutions/wan24-Core) translation +helpers for C#, but it can be customized easily for any environment and any +programming language by customizing the used regular expressions in your own +configuration file (find available presets in the `config` folder of this +repository). + +## Usage + +### Where to get it + +This is a dotnet tool and can be installed from the command line: + +```bash +dotnet tool install -g wan24-I8NTool +``` + +The default installation folder is + +- `%USER%\.dotnet\tools` for Windows +- `~/.dotnet/tools` for Linux (or MAC) + +**NOTE**: Please ensure that your global .NET tool path is in the `PATH` +environment variable (open a new Windows terminal after adding the path using +_Settings_ -> _System_ -> _Extended system settings_ -> _Extended_ -> +_Environment variables_). + +### Simple usage + +With pre-configuration for .NET: + +```bash +wan24I8NTool --input ./ > keywords.po +``` + +You can find other pre-configurations in the `config/*.json` files in the +GitHub repository: + +```bash +wan24I8NTool --config file.json --input ./ > keywords.po +``` + +### Default keyword extraction process + +Per default keywords will be found by the regular expressions you can find in +`config/dotnet.json` (without replacement). They'll then be post-processed by +the replacement expressions, until the final quoted keyword was extracted from +a line. + +To force including any string (from a constant definition, for example), +simply add a comment `// wan24I8NTool:include` at the end of the line - +example: + +```cs +public const string NAME = "Any PO included keyword";// wan24I8NTool:include +``` + +**NOTE**: (Multiline) concatenated string value definitions (like +`"Part a" + "Part b"`) or interpolations can't be parsed. The matched keyword +must be C style escaped. + +### Custom parser configuration + +In the `config/dotnet.json` file of this repository you find the default +configuration. You can download and modify it for your needs, and use it with +the `--config` parameter. + +The configuration allows to define regular expressions, where + +- an array with two elements is a regular expression (and its `RegexOptions` +enumeration value) which needs to match the string to use +- an array with three elements is used to replace a pattern (the 3rd element +is the replacement), if the regular expression does match + +Example parser JSON configuration: + +```json +{ + "SingleThread": false,// (optional) Set to true to disable multithreading (may be overridden by -singleThread) + "Encoding": "UTF-8",// (optional) Source encoding to use (default is UTF-8; may be overridden by --encoding) + "Patterns": [// (optional) + ["Any regular expression", "None"],// Search expression example + ["Any regular search expression", "None", "Replacement"],// Replacement expression example + ... + ], + "FileExtensions": [// (optional) File extensions to include when walking through a folder tree (may be overridden by --ext) + ".ext", + ... + ], + "MergeOutput": true,// (optional) Merge the extracted keywords to the existing output PO file + "FailOnError": true,// (optional) To fail thewhole process on any error + "Merge": false// (optional) Set to true to merge your custom configuration with the default configuration +} +``` + +The parser looks for any matching search-only expression, then applies all +matching replacement expressions to refer to the keyword to use, finally. If +no replacement matched the search expression string, the full search match +will be the used keyword. + +During merging, lists will be combined, and single options will be overwritten. + +There are some more optional keys for advanced configuration: + +- `Core`: [`wan24-Core`](https://github.com/WAN-Solutions/wan24-Core) +configuration using a `AppConfig` structure +- `CLI`: [`wan24-CLI`](https://github.com/nd1012/wan24-CLI) configuration +using a `CliAppConfig` structure + +### Build, extract, display and use an i8n file + +i8n files contain optional compressed translation terms. They can be created +from PO/MO and/or JSON dictionary (keyword as key, translation as an array of +strings as value) input files like this: + +```bash +wan24I8NTool i8n -compress --poInput /path/to/input.po --output /path/to/output.i8n +``` + +An i8n file can be embedded into an app, for example. + +To convert all `*.json|po|mo` files in the current folder to `*.i8n` files: + +```bash +wan24I8NTool i8n buildmany -compress -verbose +``` + +To display some i8n file informations: + +```bash +wan24I8NTool i8n display --input /path/to/input.i8n +``` + +To extract some i8n file to a JSON file (prettified): + +```bash +wan24I8NTool i8n extract --input /path/to/input.i8n --jsonOutput /path/to/output.json +``` + +To extract some i8n file to a PO file: + +```bash +wan24I8NTool i8n extract --input /path/to/input.i8n --poOutput /path/to/output.po +``` + +**NOTE**: For more options and usage instructions please use the CLI API help +(see below). + +#TODO Add wan24-I8N usage instructions + +**TIPP**: You can use the i8n API for converting, merging and validating the +supported source formats also. + +#### i8n file structure in detail + +If you didn't skip writing a header during build, the first byte contains the +version number and a flag (bit 8), if the body is compressed. The file body is +a JSON encoded dictionary, having the keyword as ID, and the translations as +value (an array of strings with none, one or multiple (plural) translations). + +If compressed, the `wan24-Compression` default compression algorithm was used. +This is Brotli at the time of writing. But please note that +`wan24-Compression` writes a non-standard header before the body, which is +required for compatibility of newer `wan24-Compression` library versions with +older compressed contents. + +**NOTE**: For using compressed i8n files, you'll have to use the +[`wan24-Compression`](https://www.nuget.org/packages/wan24-Compression) NuGet +package in your .NET app for decompressing the body. + +Please see the `I8NApi(.Internals).cs` source code in this GitHub repository +for C# code examples. + +**TIPP**: Use compression and the i8n header only, if you're using the i8n +file from a .NET app. Without a header and compression you can simply +deserialize the JSON dictionary from the i8n file using any modern programming +language. + +### Manual usage from the command line + +If you want to call the dotnet tool manually and use advanced options, you can +display help like this: + +```bash +wan24I8NTool help (--api API (--method METHOD)) (-details) +``` + +For individual usage support, please +[open an issue here](https://github.com/nd1012/wan24-I8NTool/issues). + +**NOTE**: The `wan4-Core` CLI configuration (`CliConfig`) will be applied, so +advanced configuration is possible using those special command line arguments. + +### Steps to i8n + +Internationalization (i8n) for apps is a common task to make string used in +apps translatable. gettext is a tool which have been around for many years now +and seem to satisfy developers, translators and end users. + +The steps to i8n your app are: + +1. use i8n methods in your code when you want to translate a term +1. extract keywords (terms) from your source code into a PO file using an +extractor +1. translate the terms using an editor tool and create a MO file +1. load the MO file using your apps gettext-supporting library + +`wan24-I8NTool` is a CLI tool which you can use as extractor to +automatize things a bit more. + +If you'd like to use the i8n file format from `wan24-I8NTool` in your +.NET app, the last step is replaced by: + +- convert the PO/MO file to an i8n file using `wan24-I8NTool` +- load the i8n file using your .NET app using the `wan24-I8N` library + +This is one additional step, but maybe worth it, if you don't want to miss +features like compressed i8n files ready-to-use i8n `wan24-Core` localization +(l10n) features. You'll also not need to reference any gettext supporting +library or do the parsing of the PO/MO format by yourself. + +#TODO Links to Github diff --git a/src/wan24-I8NKws/wan24-I8NKws.csproj b/src/wan24-I8NKws/wan24-I8NKws.csproj new file mode 100644 index 0000000..fba3d85 --- /dev/null +++ b/src/wan24-I8NKws/wan24-I8NKws.csproj @@ -0,0 +1,53 @@ + + + + net8.0 + wan24.I8NKws + enable + enable + wan24I8NKws + True + Debug;Release;Trunk + wan24-I8NKws + wan24-I8NKws + 1.0.0 + nd1012 + Andreas Zimmermann, wan24.de + wan24-I8NKws + Keyword catalog for translations + (c)2024 Andreas Zimmermann, wan24.de + https://github.com/nd1012/wan24-I8NTool + README.md + https://github.com/nd1012/wan24-I8NTool + git + i8n + LICENSE + True + + + + + + + + + + + + + + + + + + + \ + True + + + \ + True + + + + diff --git a/src/wan24-I8NTool CLI/wan24-I8NTool CLI.csproj b/src/wan24-I8NTool CLI/wan24-I8NTool CLI.csproj index 39d060a..eeac390 100644 --- a/src/wan24-I8NTool CLI/wan24-I8NTool CLI.csproj +++ b/src/wan24-I8NTool CLI/wan24-I8NTool CLI.csproj @@ -14,7 +14,7 @@ nd1012 Andreas Zimmermann, wan24.de wan24-I8NTool - Poedit source code extractor + Source code keyword extractor for i8n (c)2024 Andreas Zimmermann, wan24.de https://github.com/nd1012/wan24-I8NTool README.md @@ -51,6 +51,7 @@ + diff --git a/src/wan24-I8NTool.sln b/src/wan24-I8NTool.sln index d9a70df..284f44d 100644 --- a/src/wan24-I8NTool.sln +++ b/src/wan24-I8NTool.sln @@ -15,6 +15,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stream-Serializer-Extension EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "wan24-Compression", "..\..\wan24-Compression\src\wan24-Compression\wan24-Compression.csproj", "{6378E572-5858-4766-9D26-ADB0DF766BCC}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "wan24-I8NKws", "wan24-I8NKws\wan24-I8NKws.csproj", "{646B706C-F4AD-4007-9873-9628DCB9DE84}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -48,6 +50,12 @@ Global {6378E572-5858-4766-9D26-ADB0DF766BCC}.Release|Any CPU.ActiveCfg = Release|Any CPU {6378E572-5858-4766-9D26-ADB0DF766BCC}.Trunk|Any CPU.ActiveCfg = Trunk|Any CPU {6378E572-5858-4766-9D26-ADB0DF766BCC}.Trunk|Any CPU.Build.0 = Trunk|Any CPU + {646B706C-F4AD-4007-9873-9628DCB9DE84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {646B706C-F4AD-4007-9873-9628DCB9DE84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {646B706C-F4AD-4007-9873-9628DCB9DE84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {646B706C-F4AD-4007-9873-9628DCB9DE84}.Release|Any CPU.Build.0 = Release|Any CPU + {646B706C-F4AD-4007-9873-9628DCB9DE84}.Trunk|Any CPU.ActiveCfg = Trunk|Any CPU + {646B706C-F4AD-4007-9873-9628DCB9DE84}.Trunk|Any CPU.Build.0 = Trunk|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE