From b4d61905035a8ad1e1ea931587232b3e0ea17ea0 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Mon, 4 May 2026 16:04:15 +0200 Subject: [PATCH] Tool for adding languages to Power Platform projects --- .../Skills/index.json | 3 +- .../Skills/localization.md | 89 ++++++++ .../Localization/LanguageCodeResolver.cs | 43 ++++ .../Localization/LocalizationAddCliCommand.cs | 56 +++++ .../Localization/LocalizationCliCommand.cs | 22 ++ .../LocalizationExportCliCommand.cs | 183 +++++++++++++++++ .../LocalizationImportCliCommand.cs | 157 ++++++++++++++ .../Localization/LocalizationScanner.cs | 157 ++++++++++++++ .../LocalizationShowCliCommand.cs | 101 +++++++++ .../Localization/LocalizationWriter.cs | 181 +++++++++++++++++ .../Localization/SystemAttributesFilter.cs | 191 ++++++++++++++++++ .../Localization/TranslationFile.cs | 49 +++++ .../Localization/TranslationIo.cs | 26 +++ .../WorkspaceCliCommand.cs | 4 +- src/TALXIS.CLI.MCP/CliSubprocessRunner.cs | 4 +- .../CopilotInstructionsManager.cs | 26 +++ src/TALXIS.CLI.MCP/Program.cs | 9 +- src/TALXIS.CLI.MCP/RootsService.cs | 13 +- 18 files changed, 1307 insertions(+), 7 deletions(-) create mode 100644 src/TALXIS.CLI.Features.Docs/Skills/localization.md create mode 100644 src/TALXIS.CLI.Features.Workspace/Localization/LanguageCodeResolver.cs create mode 100644 src/TALXIS.CLI.Features.Workspace/Localization/LocalizationAddCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Workspace/Localization/LocalizationCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Workspace/Localization/LocalizationExportCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Workspace/Localization/LocalizationImportCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Workspace/Localization/LocalizationScanner.cs create mode 100644 src/TALXIS.CLI.Features.Workspace/Localization/LocalizationShowCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Workspace/Localization/LocalizationWriter.cs create mode 100644 src/TALXIS.CLI.Features.Workspace/Localization/SystemAttributesFilter.cs create mode 100644 src/TALXIS.CLI.Features.Workspace/Localization/TranslationFile.cs create mode 100644 src/TALXIS.CLI.Features.Workspace/Localization/TranslationIo.cs diff --git a/src/TALXIS.CLI.Features.Docs/Skills/index.json b/src/TALXIS.CLI.Features.Docs/Skills/index.json index 67ecc80..40b64a6 100644 --- a/src/TALXIS.CLI.Features.Docs/Skills/index.json +++ b/src/TALXIS.CLI.Features.Docs/Skills/index.json @@ -14,5 +14,6 @@ {"id": "pcf-controls", "title": "PCF Control Development", "summary": "PCF project structure, ControlManifest, lifecycle methods, dataset vs field controls, and build workflow.", "tags": ["workspace", "local-development", "pcf"]}, {"id": "build-errors", "title": "Build Error Recovery", "summary": "Diagnose and fix TALXISXSD001, TALXISGUID001, and other build validation errors.", "tags": ["troubleshooting", "build", "validation"]}, {"id": "data-querying", "title": "Data Querying", "summary": "Query Dataverse data using SQL, OData, and FetchXML — choosing the right language, OData patterns, aggregation, and pagination.", "tags": ["data-operations", "environment"]}, - {"id": "security-roles", "title": "Security Role Scaffolding", "summary": "Scaffold security roles, add privileges, and assign roles to model-driven apps.", "tags": ["workspace", "local-development", "security"]} + {"id": "security-roles", "title": "Security Role Scaffolding", "summary": "Scaffold security roles, add privileges, and assign roles to model-driven apps.", "tags": ["workspace", "local-development", "security"]}, + {"id": "localization", "title": "Localization & Multi-Language Support", "summary": "Add a language to a Power Platform project: register LCID, extract source strings to JSON, translate with the LLM, import back into XML alongside the existing language.", "tags": ["workspace", "local-development", "localization"]} ] diff --git a/src/TALXIS.CLI.Features.Docs/Skills/localization.md b/src/TALXIS.CLI.Features.Docs/Skills/localization.md new file mode 100644 index 0000000..215c6e1 --- /dev/null +++ b/src/TALXIS.CLI.Features.Docs/Skills/localization.md @@ -0,0 +1,89 @@ +# Localization (Adding Languages to a Power Platform Project) + +## Key Concept + +Power Platform stores localized text **inline in the same XML files** as the source language. Adding a language means **adding parallel entries**, never replacing the existing ones. Each localizable element gets a sibling element with the new `languagecode=""` (or `LCID=""` for SiteMap) and a translated value attribute. + +The `txc` workspace localization tools handle all the XML mechanics. Your job (or the LLM's job) is to translate strings in a flat JSON file — never to walk and edit XML by hand. + +## Workflow Chain + +1. **`workspace_localization_add`** — register the new LCID in every `Customizations.xml` of the workspace so solutions declare support for it. +2. **`workspace_localization_export`** — extract every untranslated source string into a directory of per-source-file JSONs (default: `./translations-/`). The output mirrors the workspace folder structure with `.json` instead of `.xml`, e.g. `translations-cs-CZ/src/Solutions.DataModel/Entities/udpp_warehouseitem/Entity.json`. One source XML produces one translation JSON. Each entry has a stable `id`, the `source` text, and `target: null`. Most files are small (3-10 entries); the largest entity files are ~50. +3. **Translate each JSON file in turn.** For every file in the output directory: Read it with the Read tool, set the `target` field of each entry using your own LLM knowledge of the target language, Write the file back. **Do this file-by-file** — do not write a script, do not use shell to read or aggregate, do not call an external translation API. Translation is the LLM's job. Move on to the next JSON when done. +4. **`workspace_localization_import`** — point `--file` at the output directory; the CLI walks all `*.json` recursively and applies them. The CLI inserts a `languagecode=""` (or `LCID=""`) sibling next to each English element. Existing entries are never modified or removed. Re-running is idempotent. **The `--file` path is deleted on a clean run** (no parse errors, no broken files) — the JSON has done its job. Pass `--keep` to retain it, or any error/broken JSON automatically retains the whole path for inspection. +5. **`workspace_localization_show`** — verify coverage. Reports total / translated / missing / coverage percent for the target language. + +## Tools + +| Tool | Purpose | +|---|---| +| `workspace_localization_add` | Register an LCID in `Customizations.xml`. Idempotent. | +| `workspace_localization_export` | Extract untranslated strings to JSON. Read-only on the workspace. | +| `workspace_localization_import` | Apply translated JSON back into XML. Idempotent: never replaces existing entries. | +| `workspace_localization_show` | Coverage report for a target language. Read-only. | + +All four accept `--workspace ` (defaults to the workspace root) and language as either a locale (`cs-CZ`) or LCID (`1029`). + +## System attributes are excluded by default + +Power Platform entities inherit ~160 system attributes (createdon, modifiedon, owner, statecode, activity-related fields, status reasons, etc.) — Dataverse localizes these itself based on the user's locale, so they should not be translated in the solution. `export` and `show` filter them out automatically: a source string that matches a known system attribute is skipped if it appears inside an `Entity.xml`. Forms, ribbon, and saved queries are not affected by this filter. + +Effect on a real project: an `Entity.json` that would have been ~50 entries (mostly system fields) collapses to ~10–15 entries — only the user's custom fields and the entity display name. + +To include system attributes in the export (rare — only when explicitly retranslating platform-localized fields), pass `--add-system-attributes` on both `export` and `show` so the counts match. + +## Translation Rules + +When filling `target` fields: + +- **Translate freely** — display names, descriptions, labels, tooltips, button titles, view names — using your knowledge of the target language. Power Platform metadata is not literary text; aim for what a native speaker would expect to see in a business app. +- **Keep as-is** — brand names (`Visa`, `Mastercard`), product/company identifiers (e.g. `UDPP`), schema/logical names, application slug names (`warehouseapp`), GUIDs, and anything that looks like an internal identifier. +- **Match casing conventions** of the target language for proper nouns and titles (Czech, German, French capitalize differently than English). +- **Don't invent translations for empty source strings** — leave them empty. + +## Coverage Across XML Formats + +The scanner finds localizable elements with either `languagecode=""` (most Dataverse XML) or `LCID=""` (SiteMap). Covered file types: + +- `Solution.xml` — solution + publisher metadata +- `Customizations.xml` — language registration block +- `Entity.xml` — entity, collection, field, description names +- `OptionSets/*.xml` — option set + option labels and descriptions +- `FormXml/**/*.xml` — section, tab, field labels in main, card, and quick-view forms +- `SavedQueries/*.xml` — view names +- `RibbonDiff.xml` — ribbon button titles +- `AppModuleSiteMaps/**/AppModuleSiteMap.xml` — sitemap titles (via `LCID="..."`) +- `AppModules/**/AppModule.xml` — app display names +- `Other/Relationships/*.xml` — relationship descriptions + +Files outside this set (PCF `ControlManifest.Input.xml` `*-key` references, Reqnroll `reqnroll.json`, plugin `.cs` source) are not auto-localized. + +## Common Scenario — Add Czech Support End-to-End + +``` +1. workspace_localization_add { language: "cs-CZ" } +2. workspace_localization_export { language: "cs-CZ" } + → produces a directory like translations-cs-CZ/ with ~30 small JSONs +3. For each JSON in that directory: + - Read it + - Set target on every entry using your Czech knowledge + - Write it back +4. workspace_localization_import { file: "translations-cs-CZ" } + → CLI walks the whole directory and applies every JSON +5. workspace_localization_show { language: "cs-CZ" } # verify coverage = 100% +``` + +## What NOT to Do + +- ❌ **Don't write a PowerShell/Python/Node script to add translations.** The whole point of this workflow is that the LLM translates in-context. A script can only do hardcoded string substitutions or call external APIs — both produce poor quality and miss the point. +- ❌ **Don't use shell to read, parse, deduplicate, or filter the translation JSON either** (`Get-Content`, `cat`, `jq`, `ConvertFrom-Json`, `Sort-Object -Unique`, etc.). Use the Read tool. If the file has duplicate sources, handle dedup mentally as you translate. Any pwsh/bash command that touches the translation JSON is a sign you're trying to short-circuit the in-context workflow. +- ❌ **Don't write the JSON back via shell either** (`Set-Content`, `Out-File`, `sed`, etc.). Use Write or Edit. No "one patch" scripts that apply translations from a lookup table — that is functionally a translation script. +- ❌ **Don't call Write twice in a row on the same JSON file.** The second Write must contain the **entire** updated file content, not just the new bits. Two partial writes produce `{...}{...}` concatenated objects that import cannot parse and will be reported as broken. +- ❌ **Don't edit XML files directly.** Always go through the four MCP tools. Manual edits will break stable IDs. +- ❌ **Don't delete or modify existing English (`languagecode="1033"`) entries.** Import only adds; preserve the source. +- ❌ **Don't translate identifiers, slugs, or schema names.** Leave application names like `warehouseapp` untranslated unless the user explicitly asks for a translated slug. +- ❌ **Don't run `import` before filling `target` values.** Empty targets are skipped, so you'll get coverage = 0%. +- ❌ **Don't copy English into target fields as a placeholder** — that hides untranslated entries from the next export's `--only-missing` filter. + +See also: [project-structure](project-structure.md), [component-creation](component-creation.md) diff --git a/src/TALXIS.CLI.Features.Workspace/Localization/LanguageCodeResolver.cs b/src/TALXIS.CLI.Features.Workspace/Localization/LanguageCodeResolver.cs new file mode 100644 index 0000000..d9bf697 --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/Localization/LanguageCodeResolver.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace TALXIS.CLI.Features.Workspace.Localization; + +public static class LanguageCodeResolver +{ + public static string Resolve(string input) + { + if (string.IsNullOrWhiteSpace(input)) + throw new ArgumentException("Language code must be provided.", nameof(input)); + + var trimmed = input.Trim(); + + if (int.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out var lcid)) + return lcid.ToString(CultureInfo.InvariantCulture); + + try + { + var culture = CultureInfo.GetCultureInfo(trimmed); + return culture.LCID.ToString(CultureInfo.InvariantCulture); + } + catch (CultureNotFoundException) + { + throw new ArgumentException($"Unknown language '{input}'. Use a locale (e.g. cs-CZ) or LCID number (e.g. 1029).", nameof(input)); + } + } + + public static string ToLocale(string lcid) + { + if (int.TryParse(lcid, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) + { + try + { + return CultureInfo.GetCultureInfo(parsed).Name; + } + catch (CultureNotFoundException) + { + return lcid; + } + } + return lcid; + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationAddCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationAddCliCommand.cs new file mode 100644 index 0000000..aaee20c --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationAddCliCommand.cs @@ -0,0 +1,56 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Workspace.Localization; + +[CliIdempotent] +[CliCommand( + Name = "add", + Description = "Register a language (LCID) in every Customizations.xml of the workspace so solutions declare support for it.")] +public class LocalizationAddCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(LocalizationAddCliCommand)); + + [CliOption(Name = "--language", Description = "Language to add (locale like cs-CZ or LCID like 1029).")] + public required string Language { get; set; } + + [CliOption(Name = "--workspace", Description = "Workspace root (defaults to current directory).", Required = false)] + public string? Workspace { get; set; } + + protected override Task ExecuteAsync() + { + var root = Path.GetFullPath(Workspace ?? Directory.GetCurrentDirectory()); + if (!Directory.Exists(root)) + { + Logger.LogError("Workspace not found: {Path}", root); + return Task.FromResult(ExitValidationError); + } + + var lcid = LanguageCodeResolver.Resolve(Language); + var locale = LanguageCodeResolver.ToLocale(lcid); + var result = LocalizationWriter.AddLanguageToCustomizations(root, lcid); + + var data = new + { + lcid, + locale, + filesTouched = result.FilesTouched, + alreadyHad = result.Already, + }; + OutputFormatter.WriteData(data, d => + { + if (d.filesTouched == 0 && d.alreadyHad == 0) + { + OutputWriter.WriteLine($"No Customizations.xml under {root} — nothing to register. (If the language isn't yet declared at solution level, run `add` from the solution or repo root.)"); + } + else + { + OutputWriter.WriteLine($"Registered LCID {d.lcid} ({d.locale}) in {d.filesTouched} Customizations.xml file(s); {d.alreadyHad} already had it."); + } + }); + + return Task.FromResult(ExitSuccess); + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationCliCommand.cs new file mode 100644 index 0000000..bbe1dcb --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationCliCommand.cs @@ -0,0 +1,22 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Workspace.Localization; + +[CliCommand( + Name = "localization", + Alias = "l10n", + Description = "Manage localization of Power Platform solution projects (add languages, export/import translations).", + Children = new[] + { + typeof(LocalizationAddCliCommand), + typeof(LocalizationExportCliCommand), + typeof(LocalizationImportCliCommand), + typeof(LocalizationShowCliCommand) + })] +public class LocalizationCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationExportCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationExportCliCommand.cs new file mode 100644 index 0000000..d6fee74 --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationExportCliCommand.cs @@ -0,0 +1,183 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Workspace.Localization; + +[CliReadOnly] +[CliCommand( + Name = "export", + Description = "Extract all localizable strings from the workspace into one translation JSON file per source XML file (mirrors workspace structure under /).")] +public class LocalizationExportCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(LocalizationExportCliCommand)); + + [CliOption(Name = "--language", Description = "Target language to translate into (locale like cs-CZ or LCID like 1029).")] + public required string Language { get; set; } + + [CliOption(Name = "--source-language", Description = "Source language to extract from (default: en-US / 1033).", Required = false)] + public string SourceLanguage { get; set; } = "1033"; + + [CliOption(Name = "--workspace", Description = "Workspace root (defaults to current directory).", Required = false)] + public string? Workspace { get; set; } + + [CliOption(Name = "--output", Description = "Output directory for per-file translation JSONs (default: ./translations-). Mirrors workspace folder structure with .json instead of .xml.", Required = false)] + public string? Output { get; set; } + + [CliOption(Name = "--only-missing", Description = "Include only strings that have no translation yet in the target language (default: true).", Required = false)] + public bool OnlyMissing { get; set; } = true; + + [CliOption(Name = "--single-file", Description = "Legacy single-file mode: write all entries into the path given by --output instead of per-file JSONs.", Required = false)] + public bool SingleFile { get; set; } = false; + + [CliOption(Name = "--add-system-attributes", Description = "Include Power Platform system attributes (createdon, owner, statecode, etc.) in Entity.xml exports. Default: excluded — these are localized by the platform itself based on the user's locale.", Required = false)] + public bool AddSystemAttributes { get; set; } + + protected override Task ExecuteAsync() + { + var root = Path.GetFullPath(Workspace ?? Directory.GetCurrentDirectory()); + if (!Directory.Exists(root)) + { + Logger.LogError("Workspace not found: {Path}", root); + return Task.FromResult(ExitValidationError); + } + + var sourceLcid = LanguageCodeResolver.Resolve(SourceLanguage); + var targetLcid = LanguageCodeResolver.Resolve(Language); + var locale = LanguageCodeResolver.ToLocale(targetLcid); + var generatedAt = DateTime.UtcNow.ToString("o"); + + var sites = AddSystemAttributes + ? LocalizationScanner.Scan(root, sourceLcid).ToList() + : LocalizationScanner.Scan(root, sourceLcid) + .Where(s => !SystemAttributesFilter.ShouldExclude(s)) + .ToList(); + + if (SingleFile) + { + var singlePath = Output ?? Path.Combine(root, $"translations-{locale}.json"); + return Task.FromResult(WriteSingleFile(root, sites, sourceLcid, targetLcid, locale, generatedAt, singlePath)); + } + + var outDir = Output ?? Path.Combine(root, $"translations-{locale}"); + return Task.FromResult(WritePerFile(root, sites, sourceLcid, targetLcid, locale, generatedAt, outDir)); + } + + private int WriteSingleFile(string root, List sites, string sourceLcid, string targetLcid, string locale, string generatedAt, string outPath) + { + var file = new TranslationFile + { + SourceLanguage = sourceLcid, + TargetLanguage = targetLcid, + GeneratedAt = generatedAt, + Workspace = root, + }; + + foreach (var site in sites) + { + var existing = LoadExistingTranslation(root, site, targetLcid); + if (OnlyMissing && existing != null) continue; + file.Strings.Add(BuildUnit(site, existing)); + } + + TranslationIo.Write(outPath, file); + + var data = new + { + mode = "single-file", + count = file.Strings.Count, + targetLanguage = targetLcid, + targetLocale = locale, + outputPath = outPath, + }; + OutputFormatter.WriteData(data, d => + OutputWriter.WriteLine($"Exported {d.count} string(s) for {d.targetLocale} ({d.targetLanguage}) to {d.outputPath}")); + return ExitSuccess; + } + + private int WritePerFile(string root, List sites, string sourceLcid, string targetLcid, string locale, string generatedAt, string outDir) + { + Directory.CreateDirectory(outDir); + + int filesWritten = 0; + int totalStrings = 0; + + foreach (var group in sites.GroupBy(s => s.FileRelativePath)) + { + var bucket = new TranslationFile + { + SourceLanguage = sourceLcid, + TargetLanguage = targetLcid, + GeneratedAt = generatedAt, + Workspace = root, + }; + + foreach (var site in group) + { + var existing = LoadExistingTranslation(root, site, targetLcid); + if (OnlyMissing && existing != null) continue; + bucket.Strings.Add(BuildUnit(site, existing)); + } + + if (bucket.Strings.Count == 0) continue; + + // Mirror workspace structure: src/.../Entity.xml -> outDir/src/.../Entity.json + var jsonRel = Path.ChangeExtension(group.Key, ".json"); + var outFile = Path.Combine(outDir, jsonRel.Replace('/', Path.DirectorySeparatorChar)); + TranslationIo.Write(outFile, bucket); + + filesWritten++; + totalStrings += bucket.Strings.Count; + } + + var data = new + { + mode = "per-file", + filesWritten, + count = totalStrings, + targetLanguage = targetLcid, + targetLocale = locale, + outputDir = outDir, + }; + OutputFormatter.WriteData(data, d => + OutputWriter.WriteLine($"Exported {d.count} string(s) across {d.filesWritten} file(s) for {d.targetLocale} ({d.targetLanguage}) to {d.outputDir}")); + return ExitSuccess; + } + + private static TranslationUnit BuildUnit(LocalizableSite site, string? existing) => new() + { + Id = site.Id, + File = site.FileRelativePath, + XPath = site.XPath, + LanguageAttr = site.LanguageAttr, + ValueAttr = site.ValueAttr, + Source = site.Source, + Target = existing, + }; + + private static string? LoadExistingTranslation(string workspaceRoot, LocalizableSite site, string targetLcid) + { + var path = Path.Combine(workspaceRoot, site.FileRelativePath.Replace('/', Path.DirectorySeparatorChar)); + try + { + var doc = System.Xml.Linq.XDocument.Load(path, System.Xml.Linq.LoadOptions.PreserveWhitespace); + var source = LocalizationScanner.LocateByXPath(doc, site.XPath); + if (source?.Parent == null) return null; + foreach (var sibling in source.Parent.Elements(source.Name)) + { + if (ReferenceEquals(sibling, source)) continue; + var attr = sibling.Attribute(site.LanguageAttr); + if (attr == null || attr.Value != targetLcid) continue; + if (site.ValueAttr != null) + return sibling.Attribute(site.ValueAttr)?.Value; + return sibling.Value; + } + } + catch + { + // ignore + } + return null; + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationImportCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationImportCliCommand.cs new file mode 100644 index 0000000..7bcc7d3 --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationImportCliCommand.cs @@ -0,0 +1,157 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Workspace.Localization; + +[CliIdempotent] +[CliCommand( + Name = "import", + Description = "Apply translations from a translation JSON file (or a directory of per-file JSONs from `localization export`) back into workspace XML, adding (not replacing) language-specific entries.")] +public class LocalizationImportCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(LocalizationImportCliCommand)); + + [CliOption(Name = "--file", Description = "Path to a translation JSON file or a directory containing per-file translation JSONs.")] + public required string File { get; set; } + + [CliOption(Name = "--workspace", Description = "Workspace root (defaults to current directory).", Required = false)] + public string? Workspace { get; set; } + + [CliOption(Name = "--keep", Description = "Keep the translation file or directory passed to --file after a successful import. Default: it is deleted once everything imports cleanly. On any parse error or broken file the path is always retained for inspection.", Required = false)] + public bool Keep { get; set; } + + protected override Task ExecuteAsync() + { + var root = Path.GetFullPath(Workspace ?? Directory.GetCurrentDirectory()); + if (!Directory.Exists(root)) + { + Logger.LogError("Workspace not found: {Path}", root); + return Task.FromResult(ExitValidationError); + } + + var inputs = ResolveInputFiles(); + if (inputs == null) return Task.FromResult(ExitValidationError); + if (inputs.Count == 0) + { + Logger.LogError("No translation JSON files found at: {Path}", File); + return Task.FromResult(ExitValidationError); + } + + int totalAdded = 0, totalUpdated = 0, totalSkipped = 0, totalErrors = 0; + int filesOk = 0; + var brokenFiles = new List(); + + foreach (var path in inputs) + { + var rel = Path.GetRelativePath(root, path).Replace('\\', '/'); + var (translation, parseError) = TryReadTranslation(path); + if (parseError != null) + { + Logger.LogError("Failed to parse {Path}: {Message}", rel, parseError); + brokenFiles.Add(rel); + totalErrors++; + continue; + } + + if (string.IsNullOrEmpty(translation!.TargetLanguage)) + { + Logger.LogWarning("Skipping {Path}: no targetLanguage.", rel); + totalErrors++; + continue; + } + + var result = LocalizationWriter.Apply(root, translation); + totalAdded += result.Added; + totalUpdated += result.Updated; + totalSkipped += result.Skipped; + totalErrors += result.Errors.Count; + filesOk++; + + foreach (var err in result.Errors) + Logger.LogWarning("{Error}", err); + } + + // Cleanup: when everything imported cleanly, the translation file/dir + // has served its purpose and can be removed. On any error we keep it + // so the user can inspect / fix / retry. `--keep` overrides cleanup. + bool fullSuccess = brokenFiles.Count == 0 && totalErrors == 0; + bool cleanedUp = false; + if (fullSuccess && !Keep) + { + cleanedUp = TryDeletePath(File); + } + + var data = new + { + filesProcessed = inputs.Count, + filesOk, + filesBroken = brokenFiles.Count, + broken = brokenFiles, + added = totalAdded, + updated = totalUpdated, + skipped = totalSkipped, + errors = totalErrors, + cleanedUp, + }; + var inputPath = File; + OutputFormatter.WriteData(data, d => + { + OutputWriter.WriteLine($"Processed {d.filesProcessed} file(s). OK: {d.filesOk}, Broken: {d.filesBroken}, Added: {d.added}, Updated: {d.updated}, Skipped: {d.skipped}"); + if (d.cleanedUp) + OutputWriter.WriteLine($"Removed {inputPath} (pass --keep to retain)."); + if (d.broken.Count > 0) + { + OutputWriter.WriteLine("Broken files (could not be parsed):"); + foreach (var b in d.broken) + OutputWriter.WriteLine($" - {b}"); + } + }); + + return Task.FromResult(brokenFiles.Count == 0 ? ExitSuccess : ExitError); + } + + private static bool TryDeletePath(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + return true; + } + if (System.IO.File.Exists(path)) + { + System.IO.File.Delete(path); + return true; + } + } + catch + { + // Best-effort cleanup; failure is non-fatal. + } + return false; + } + + private static (TranslationFile? File, string? Error) TryReadTranslation(string path) + { + try { return (TranslationIo.Read(path), null); } + catch (Exception ex) { return (null, ex.Message); } + } + + private List? ResolveInputFiles() + { + if (Directory.Exists(File)) + { + return Directory.EnumerateFiles(File, "*.json", SearchOption.AllDirectories) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + if (System.IO.File.Exists(File)) + return new List { File }; + + Logger.LogError("Translation file or directory not found: {Path}", File); + return null; + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationScanner.cs b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationScanner.cs new file mode 100644 index 0000000..a7681dc --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationScanner.cs @@ -0,0 +1,157 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; + +namespace TALXIS.CLI.Features.Workspace.Localization; + +public sealed record LocalizableSite( + string FileRelativePath, + string XPath, + string LanguageAttr, + string? ValueAttr, + string Source, + string Id); + +public static class LocalizationScanner +{ + private static readonly string[] IgnoredDirs = new[] + { + ".git", "bin", "obj", "node_modules", ".vs", ".idea", "packages" + }; + + public static IEnumerable EnumerateXmlFiles(string workspaceRoot) + { + foreach (var path in Directory.EnumerateFiles(workspaceRoot, "*.xml", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(workspaceRoot, path).Replace('\\', '/'); + if (IgnoredDirs.Any(d => rel.Split('/').Contains(d, StringComparer.OrdinalIgnoreCase))) + continue; + yield return path; + } + } + + public static IEnumerable Scan(string workspaceRoot, string sourceLcid) + { + foreach (var file in EnumerateXmlFiles(workspaceRoot)) + { + XDocument doc; + try + { + doc = XDocument.Load(file, LoadOptions.PreserveWhitespace); + } + catch + { + continue; + } + + if (doc.Root == null) continue; + + var rel = Path.GetRelativePath(workspaceRoot, file).Replace('\\', '/'); + foreach (var el in doc.Descendants()) + { + var (langAttr, langValue) = GetLanguageAttribute(el); + if (langAttr == null || langValue != sourceLcid) continue; + + var valueAttr = GetValueAttributeName(el); + string source; + if (valueAttr != null) + { + source = el.Attribute(valueAttr)?.Value ?? string.Empty; + } + else + { + source = el.Nodes().OfType().FirstOrDefault()?.Value ?? string.Empty; + } + + if (string.IsNullOrWhiteSpace(source)) continue; + if (valueAttr == null) continue; + + var xpath = BuildXPath(el); + var id = MakeId(rel, xpath); + + yield return new LocalizableSite( + rel, + xpath, + langAttr, + valueAttr, + source, + id); + } + } + } + + public static (string? Name, string? Value) GetLanguageAttribute(XElement el) + { + foreach (var attr in el.Attributes()) + { + if (attr.Name.LocalName.Equals("languagecode", StringComparison.OrdinalIgnoreCase) || + attr.Name.LocalName.Equals("LCID", StringComparison.OrdinalIgnoreCase)) + { + return (attr.Name.LocalName, attr.Value); + } + } + return (null, null); + } + + public static string? GetValueAttributeName(XElement el) + { + if (el.Attribute("description") != null) return "description"; + if (el.Attribute("Title") != null) return "Title"; + return null; + } + + public static string BuildXPath(XElement el) + { + var parts = new List(); + XElement? current = el; + while (current != null) + { + var parent = current.Parent; + int index = 1; + if (parent != null) + { + var siblings = parent.Elements(current.Name).ToList(); + index = siblings.IndexOf(current) + 1; + } + parts.Insert(0, $"{current.Name.LocalName}[{index}]"); + current = parent; + } + return "/" + string.Join("/", parts); + } + + public static string MakeId(string fileRel, string xpath) + { + var bytes = Encoding.UTF8.GetBytes(fileRel + "|" + xpath); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).Substring(0, 12).ToLowerInvariant(); + } + + public static XElement? LocateByXPath(XDocument doc, string xpath) + { + var segments = xpath.TrimStart('/').Split('/'); + XElement? current = doc.Root; + if (current == null) return null; + + var first = ParseSegment(segments[0]); + if (!current.Name.LocalName.Equals(first.tag, StringComparison.Ordinal)) return null; + + for (int i = 1; i < segments.Length; i++) + { + if (current == null) return null; + var (tag, index) = ParseSegment(segments[i]); + var matching = current.Elements().Where(e => e.Name.LocalName.Equals(tag, StringComparison.Ordinal)).ToList(); + if (index - 1 < 0 || index - 1 >= matching.Count) return null; + current = matching[index - 1]; + } + return current; + } + + private static (string tag, int index) ParseSegment(string segment) + { + var bracket = segment.IndexOf('['); + if (bracket < 0) return (segment, 1); + var tag = segment.Substring(0, bracket); + var idx = int.Parse(segment.Substring(bracket + 1, segment.Length - bracket - 2)); + return (tag, idx); + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationShowCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationShowCliCommand.cs new file mode 100644 index 0000000..9bdf6b0 --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationShowCliCommand.cs @@ -0,0 +1,101 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Logging; + +namespace TALXIS.CLI.Features.Workspace.Localization; + +[CliReadOnly] +[CliCommand( + Name = "show", + Description = "Show translation coverage for a target language across the workspace.")] +public class LocalizationShowCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(LocalizationShowCliCommand)); + + [CliOption(Name = "--language", Description = "Target language (locale like cs-CZ or LCID like 1029).")] + public required string Language { get; set; } + + [CliOption(Name = "--source-language", Description = "Source language (default: 1033 / en-US).", Required = false)] + public string SourceLanguage { get; set; } = "1033"; + + [CliOption(Name = "--workspace", Description = "Workspace root (defaults to current directory).", Required = false)] + public string? Workspace { get; set; } + + [CliOption(Name = "--add-system-attributes", Description = "Include Power Platform system attributes (createdon, owner, statecode, etc.) in the count. Default: excluded, matching the same default on `export`.", Required = false)] + public bool AddSystemAttributes { get; set; } + + protected override Task ExecuteAsync() + { + var root = Path.GetFullPath(Workspace ?? Directory.GetCurrentDirectory()); + if (!Directory.Exists(root)) + { + Logger.LogError("Workspace not found: {Path}", root); + return Task.FromResult(ExitValidationError); + } + + var sourceLcid = LanguageCodeResolver.Resolve(SourceLanguage); + var targetLcid = LanguageCodeResolver.Resolve(Language); + + var sites = AddSystemAttributes + ? LocalizationScanner.Scan(root, sourceLcid).ToList() + : LocalizationScanner.Scan(root, sourceLcid) + .Where(s => !SystemAttributesFilter.ShouldExclude(s)) + .ToList(); + int total = sites.Count; + int translated = 0; + + foreach (var site in sites) + { + if (HasTranslation(root, site, targetLcid)) + translated++; + } + + int missing = total - translated; + double coverage = total > 0 ? (double)translated / total * 100.0 : 100.0; + + var data = new + { + sourceLanguage = sourceLcid, + targetLanguage = targetLcid, + targetLocale = LanguageCodeResolver.ToLocale(targetLcid), + total, + translated, + missing, + coveragePercent = Math.Round(coverage, 2), + }; + OutputFormatter.WriteData(data, d => + { + OutputWriter.WriteLine($"Localization status for {d.targetLocale} ({d.targetLanguage})"); + OutputWriter.WriteLine($" Total strings : {d.total}"); + OutputWriter.WriteLine($" Translated : {d.translated}"); + OutputWriter.WriteLine($" Missing : {d.missing}"); + OutputWriter.WriteLine($" Coverage : {d.coveragePercent:F2}%"); + }); + + return Task.FromResult(ExitSuccess); + } + + private static bool HasTranslation(string workspaceRoot, LocalizableSite site, string targetLcid) + { + var path = Path.Combine(workspaceRoot, site.FileRelativePath.Replace('/', Path.DirectorySeparatorChar)); + try + { + var doc = System.Xml.Linq.XDocument.Load(path, System.Xml.Linq.LoadOptions.PreserveWhitespace); + var source = LocalizationScanner.LocateByXPath(doc, site.XPath); + if (source?.Parent == null) return false; + foreach (var sibling in source.Parent.Elements(source.Name)) + { + if (ReferenceEquals(sibling, source)) continue; + var attr = sibling.Attribute(site.LanguageAttr); + if (attr != null && attr.Value == targetLcid) + return true; + } + } + catch + { + // ignore unreadable files + } + return false; + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationWriter.cs b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationWriter.cs new file mode 100644 index 0000000..eedb2ca --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/Localization/LocalizationWriter.cs @@ -0,0 +1,181 @@ +using System.Xml.Linq; + +namespace TALXIS.CLI.Features.Workspace.Localization; + +public static class LocalizationWriter +{ + public sealed record WriteResult(int Added, int Updated, int Skipped, List Errors); + + public static WriteResult Apply(string workspaceRoot, TranslationFile file) + { + int added = 0, updated = 0, skipped = 0; + var errors = new List(); + + var grouped = file.Strings + .Where(s => !string.IsNullOrEmpty(s.Target)) + .GroupBy(s => s.File); + + foreach (var group in grouped) + { + var path = Path.Combine(workspaceRoot, group.Key.Replace('/', Path.DirectorySeparatorChar)); + if (!System.IO.File.Exists(path)) + { + errors.Add($"File not found: {group.Key}"); + continue; + } + + XDocument doc; + try + { + doc = XDocument.Load(path, LoadOptions.PreserveWhitespace); + } + catch (Exception ex) + { + errors.Add($"Failed to load {group.Key}: {ex.Message}"); + continue; + } + + bool docChanged = false; + foreach (var unit in group) + { + var source = LocalizationScanner.LocateByXPath(doc, unit.XPath); + if (source == null) + { + errors.Add($"Could not locate element for id={unit.Id} at {unit.File}{unit.XPath}"); + skipped++; + continue; + } + + var existing = FindExistingTranslation(source, unit.LanguageAttr, file.TargetLanguage); + if (existing != null) + { + if (SetValue(existing, unit.ValueAttr, unit.Target!)) + { + updated++; + docChanged = true; + } + else + { + skipped++; + } + } + else + { + var clone = new XElement(source); + var langAttr = clone.Attribute(unit.LanguageAttr); + if (langAttr != null) + { + langAttr.Value = file.TargetLanguage; + } + SetValue(clone, unit.ValueAttr, unit.Target!); + InsertSiblingPreservingIndent(source, clone); + added++; + docChanged = true; + } + } + + if (docChanged) + { + try + { + doc.Save(path, SaveOptions.DisableFormatting); + } + catch (Exception ex) + { + errors.Add($"Failed to save {group.Key}: {ex.Message}"); + } + } + } + + return new WriteResult(added, updated, skipped, errors); + } + + /// + /// Inserts immediately after , + /// mirroring the whitespace prefix that decorates so + /// the new sibling sits on its own indented line. If + /// is not preceded by newline-containing whitespace (single-line layout), + /// falls back to a plain inline insert to preserve the existing style. + /// + private static void InsertSiblingPreservingIndent(XElement source, XElement clone) + { + var leading = (source.PreviousNode as XText)?.Value; + if (!string.IsNullOrEmpty(leading) && leading.IndexOfAny(new[] { '\n', '\r' }) >= 0) + { + // Replicate the exact whitespace (newline + indent) so the writer + // honours it under SaveOptions.DisableFormatting. + source.AddAfterSelf(new XText(leading), clone); + } + else + { + source.AddAfterSelf(clone); + } + } + + private static XElement? FindExistingTranslation(XElement source, string languageAttr, string targetLcid) + { + var parent = source.Parent; + if (parent == null) return null; + foreach (var sibling in parent.Elements(source.Name)) + { + if (ReferenceEquals(sibling, source)) continue; + var attr = sibling.Attribute(languageAttr); + if (attr != null && attr.Value == targetLcid) return sibling; + } + return null; + } + + private static bool SetValue(XElement el, string? valueAttr, string value) + { + if (valueAttr != null) + { + var attr = el.Attribute(valueAttr); + if (attr == null) + { + el.SetAttributeValue(valueAttr, value); + return true; + } + if (attr.Value == value) return false; + attr.Value = value; + return true; + } + + if (el.Value == value) return false; + el.Value = value; + return true; + } + + public sealed record AddLanguageResult(int FilesTouched, int Already); + + public static AddLanguageResult AddLanguageToCustomizations(string workspaceRoot, string lcid) + { + int touched = 0, already = 0; + foreach (var file in LocalizationScanner.EnumerateXmlFiles(workspaceRoot)) + { + if (!Path.GetFileName(file).Equals("Customizations.xml", StringComparison.OrdinalIgnoreCase)) + continue; + + XDocument doc; + try { doc = XDocument.Load(file, LoadOptions.PreserveWhitespace); } + catch { continue; } + if (doc.Root == null) continue; + + var languages = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "Languages"); + if (languages == null) continue; + + var existing = languages.Elements() + .FirstOrDefault(e => e.Name.LocalName == "Language" && e.Value == lcid); + if (existing != null) { already++; continue; } + + var template = languages.Elements().FirstOrDefault(e => e.Name.LocalName == "Language"); + var newLang = template != null + ? new XElement(template.Name, lcid) + : new XElement("Language", lcid); + languages.Add(newLang); + + doc.Save(file, SaveOptions.DisableFormatting); + touched++; + } + return new AddLanguageResult(touched, already); + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/Localization/SystemAttributesFilter.cs b/src/TALXIS.CLI.Features.Workspace/Localization/SystemAttributesFilter.cs new file mode 100644 index 0000000..859e618 --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/Localization/SystemAttributesFilter.cs @@ -0,0 +1,191 @@ +namespace TALXIS.CLI.Features.Workspace.Localization; + +/// +/// Built-in source-string filter for Power Platform system attributes that are +/// inherited by every entity (createdon, modifiedon, owner, statecode, activity- +/// related fields, etc.). Power Platform localizes these itself based on the +/// user's locale, so they should not appear in translation exports. +/// +/// The list is a snapshot of the localizable strings present in the DevKit +/// pp-entity Entity.xml template. It is hard-coded — no template files are read +/// at runtime. Refresh this list when the underlying platform / template +/// schema gains or removes system attributes. +/// +public static class SystemAttributesFilter +{ + public static readonly HashSet Strings = new(StringComparer.Ordinal) + { + "(Deprecated) Process Stage", + "(Deprecated) Traversed Path", + "Active", + "Activity", + "Activity Additional Parameters", + "Activity Status", + "Activity Type", + "Actual Duration", + "Actual End", + "Actual Start", + "Actual duration of the activity in minutes.", + "Actual end time of the activity.", + "Actual start time of the activity.", + "Additional information provided by the external application as JSON. For internal use only.", + "Appointment Type", + "BCC", + "Blind Carbon-copy (bcc) recipients of the activity.", + "CC", + "Canceled", + "Carbon-copy (cc) recipients of the activity.", + "Choose the service level agreement (SLA) that you want to apply to the case record.", + "Completed", + "Contains the date and time stamp of the last on hold time.", + "Created By", + "Created By (Delegate)", + "Created On", + "Customer with which the activity is associated.", + "Customers", + "Date Created", + "Date Delivery Last Attempted", + "Date Sent", + "Date and time that the record was migrated.", + "Date and time when activity was last modified.", + "Date and time when the activity was created.", + "Date and time when the activity was sent.", + "Date and time when the delivery of the activity was last attempted.", + "Date and time when the record was created.", + "Date and time when the record was modified.", + "Delay activity processing until", + "Delivery Priority", + "Description", + "Description of the activity.", + "Due Date", + "ExampleEntityDisplayName", + "ExampleEntityPluralDisplayName", + "Exchange Item ID", + "Exchange WebLink", + "For internal use only.", + "From", + "High", + "Import Sequence Number", + "Inactive", + "Information regarding whether the activity is a regular activity type or event type.", + "Information regarding whether the activity was billed as part of resolving a case.", + "Information regarding whether the activity was created from a workflow rule.", + "Is Billed", + "Is Private", + "Is Regular Activity", + "Is Workflow Created", + "Last On Hold Time", + "Last SLA applied", + "Last SLA that was applied to this case. This field is for internal use only.", + "Last Updated", + "Left Voice Mail", + "Left Voice Mail.", + "Left the voice mail", + "List of optional attendees for the activity.", + "List of required attendees for the activity.", + "Low", + "Modified By", + "Modified By (Delegate)", + "Modified On", + "Name", + "No", + "Normal", + "Not Recurring", + "On Hold Time (Minutes)", + "Open", + "Optional Attendees", + "Organization Id", + "Organizer", + "Outsource Vendors", + "Outsource vendor with which activity is associated.", + "Owner", + "Owner Id", + "Owning Business Unit", + "Owning Team", + "Owning User", + "Person who is the receiver of the activity.", + "Person who organized the activity.", + "Person who the activity is from.", + "Priority", + "Priority of delivery of the activity to the email server.", + "Priority of the activity.", + "Process", + "Reason for the status of the ExampleEntityDisplayName", + "Reason for the status of the activity.", + "Record Created On", + "Recurring Exception", + "Recurring Future Exception", + "Recurring Instance", + "Recurring Instance Type", + "Recurring Master", + "Regarding", + "Required Attendees", + "Resources", + "SLA", + "Scheduled", + "Scheduled Duration", + "Scheduled duration of the activity, specified in minutes.", + "Scheduled end time of the activity.", + "Scheduled start time of the activity.", + "Sender's Mailbox", + "Sequence number of the import that created this record.", + "Series Id", + "Shows how contact about the social activity originated, such as from Twitter or Facebook. This field is read-only.", + "Shows how long, in minutes, that the record was on hold.", + "Shows the date and time by which the activities are sorted.", + "Shows the web link of Activity of type email.", + "Social Channel", + "Sort Date", + "Start Date", + "Status", + "Status Reason", + "Status of the ExampleEntityDisplayName", + "Status of the activity.", + "Subject", + "Subject associated with the activity.", + "The message id of activity which is returned from Exchange Server.", + "Time Zone Rule Version Number", + "Time zone code that was in use when the record was created.", + "To", + "Type of activity.", + "Type of instance of a recurring series.", + "UTC Conversion Time Zone Code", + "Unique identifier for entity instances", + "Unique identifier for the business unit that owns the record", + "Unique identifier for the organization", + "Unique identifier for the team that owns the record.", + "Unique identifier for the user that owns the record.", + "Unique identifier of the Process.", + "Unique identifier of the Stage.", + "Unique identifier of the activity.", + "Unique identifier of the business unit that owns the activity.", + "Unique identifier of the delegate user who created the activitypointer.", + "Unique identifier of the delegate user who created the record.", + "Unique identifier of the delegate user who last modified the activitypointer.", + "Unique identifier of the delegate user who modified the record.", + "Unique identifier of the mailbox associated with the sender of the email message.", + "Unique identifier of the object with which the activity is associated.", + "Unique identifier of the team that owns the activity.", + "Unique identifier of the user or team who owns the activity.", + "Unique identifier of the user that owns the activity.", + "Unique identifier of the user who created the activity.", + "Unique identifier of the user who created the record.", + "Unique identifier of the user who modified the record.", + "Unique identifier of user who last modified the activity.", + "Uniqueidentifier specifying the id of recurring series of an instance.", + "Users or facility/equipment that are required for the activity.", + "Yes", + }; + + /// + /// Returns true when the given site should be excluded from translation + /// because its source matches a Power Platform system attribute string. + /// Currently scoped to Entity.xml files only — forms, ribbon, and saved + /// queries are not affected. + /// + public static bool ShouldExclude(LocalizableSite site) => + IsEntityXml(site.FileRelativePath) && Strings.Contains(site.Source); + + private static bool IsEntityXml(string fileRelPath) => + fileRelPath.EndsWith("/Entity.xml", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/TALXIS.CLI.Features.Workspace/Localization/TranslationFile.cs b/src/TALXIS.CLI.Features.Workspace/Localization/TranslationFile.cs new file mode 100644 index 0000000..e7e95b1 --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/Localization/TranslationFile.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Serialization; + +namespace TALXIS.CLI.Features.Workspace.Localization; + +public sealed class TranslationFile +{ + [JsonPropertyName("sourceLanguage")] + public string SourceLanguage { get; set; } = string.Empty; + + [JsonPropertyName("targetLanguage")] + public string TargetLanguage { get; set; } = string.Empty; + + [JsonPropertyName("generatedAt")] + public string? GeneratedAt { get; set; } + + [JsonPropertyName("workspace")] + public string? Workspace { get; set; } + + [JsonPropertyName("strings")] + public List Strings { get; set; } = new(); +} + +public sealed class TranslationUnit +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("file")] + public string File { get; set; } = string.Empty; + + [JsonPropertyName("xpath")] + public string XPath { get; set; } = string.Empty; + + [JsonPropertyName("languageAttr")] + public string LanguageAttr { get; set; } = "languagecode"; + + [JsonPropertyName("valueAttr")] + public string? ValueAttr { get; set; } + + [JsonPropertyName("source")] + public string Source { get; set; } = string.Empty; + + // Always serialize, even when null, so consumers see an explicit "target": null + // slot they need to fill in. Without this, the global JsonIgnoreCondition.WhenWritingNull + // would omit empty targets entirely and confuse the user / LLM. + [JsonPropertyName("target")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public string? Target { get; set; } +} diff --git a/src/TALXIS.CLI.Features.Workspace/Localization/TranslationIo.cs b/src/TALXIS.CLI.Features.Workspace/Localization/TranslationIo.cs new file mode 100644 index 0000000..f9a7258 --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/Localization/TranslationIo.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using TALXIS.CLI.Core.Storage; + +namespace TALXIS.CLI.Features.Workspace.Localization; + +public static class TranslationIo +{ + public static void Write(string path, TranslationFile file) + { + var dir = Path.GetDirectoryName(path); + + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + using var stream = System.IO.File.Create(path); + + JsonSerializer.Serialize(stream, file, TxcJsonOptions.Default); + } + + public static TranslationFile Read(string path) + { + using var stream = System.IO.File.OpenRead(path); + + return JsonSerializer.Deserialize(stream, TxcJsonOptions.Default) + ?? throw new InvalidOperationException($"Could not parse translation file: {path}"); + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs index 7d4d42c..345ba7e 100644 --- a/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs @@ -1,4 +1,5 @@ using DotMake.CommandLine; +using TALXIS.CLI.Features.Workspace.Localization; namespace TALXIS.CLI.Features.Workspace; @@ -10,7 +11,8 @@ namespace TALXIS.CLI.Features.Workspace; typeof(ComponentCliCommand), typeof(ProjectCliCommand), typeof(WorkspaceExplainCliCommand), - typeof(WorkspaceValidateCliCommand) + typeof(WorkspaceValidateCliCommand), + typeof(LocalizationCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None)] public class WorkspaceCliCommand diff --git a/src/TALXIS.CLI.MCP/CliSubprocessRunner.cs b/src/TALXIS.CLI.MCP/CliSubprocessRunner.cs index 9225755..eb50ff6 100644 --- a/src/TALXIS.CLI.MCP/CliSubprocessRunner.cs +++ b/src/TALXIS.CLI.MCP/CliSubprocessRunner.cs @@ -103,7 +103,9 @@ private static Process StartProcess(IReadOnlyList cliArgs, string? worki RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, - WorkingDirectory = workingDirectory ?? System.Environment.CurrentDirectory + WorkingDirectory = (!string.IsNullOrWhiteSpace(workingDirectory) && Directory.Exists(workingDirectory)) + ? workingDirectory + : System.Environment.CurrentDirectory }; // Enable structured JSON logging for MCP consumption diff --git a/src/TALXIS.CLI.MCP/CopilotInstructionsManager.cs b/src/TALXIS.CLI.MCP/CopilotInstructionsManager.cs index d1f8177..a5e21c3 100644 --- a/src/TALXIS.CLI.MCP/CopilotInstructionsManager.cs +++ b/src/TALXIS.CLI.MCP/CopilotInstructionsManager.cs @@ -19,6 +19,32 @@ public class CopilotInstructionsManager # Instructions for performing tasks over the repository Use the TALXIS CLI MCP Server (txc-mcp) for all development tasks. Call `guide_workspace`, `guide_environment`, `guide_deployment`, `guide_data`, `guide_config`, or `get_skill_details` to discover available tools and guidance. +## Tool calls go through MCP, not the shell + +When these instructions mention a tool by name like `workspace_localization_add`, `environment_solution_import`, or `data_package_export`, that is the **MCP tool name**. Call it via the MCP tool-use mechanism. Do **not** rewrite it as a shell command (e.g. `txc workspace localization add --language cs-CZ` in pwsh/bash) and run it through a terminal. Reasons: + +- The MCP server already wraps the CLI. Going via shell bypasses MCP, defeats progress notifications, and may hit a different (globally-installed, possibly older) `txc` binary on `PATH` that does not have the same commands. +- Many tools are not visible in your initial tools list — they are revealed only after a `guide_*` call (progressive disclosure). If you don't see a tool, call the matching guide first (`guide_workspace` for local development, `guide_environment` for live environment, etc.) — the matched tools become directly callable on subsequent turns or via `execute_operation` in the same turn. +- Never run `txc ` through `Run pwsh/bash/cmd command`. If a needed operation seems unavailable, the answer is a guide call, not a shell invocation. + +## LLM-driven content workflows + +Some `txc` tools follow an export → fill → import pattern: an export tool produces JSON files with empty `target` fields (typically a **directory of small per-component JSONs**, mirroring the workspace structure), you fill those fields in-context using your own translation or transformation knowledge, and an import tool applies them back. The current example is `workspace_localization_*` for adding a language to a Power Platform project — it produces ~30 JSONs of 3-50 entries each, not one giant file. + +Rules for this pattern: + +- **Read the JSON only with the Read tool.** Do not invoke `Get-Content`, `cat`, `jq`, `ConvertFrom-Json`, or any other shell or scripted parser to inspect, deduplicate, filter, or summarize it. If a file has duplicate sources, you handle that mentally as you translate. +- **Write the JSON back only with Write or Edit.** Do not invoke `Set-Content`, `Out-File`, `sed`, or any pipeline that mutates the file. No ""one patch"" scripts that apply translations from a lookup table. +- **Use exactly one Write per JSON file**, with the full updated file contents as the only argument. Do not call Write twice on the same path — the second call must overwrite the first, never append. Do not call Write on a path that already received Edit calls in the same session unless you are passing the entire current file content. Two consecutive partial writes on the same file produce concatenated JSON objects (`{...}{...}`) that the import tool cannot parse. +- Translate or transform every JSON entry yourself, in-context. Do not generate a PowerShell, Python, Bash, or other script for it; the entire value of the workflow is the LLM's in-context capability. A file with hundreds of entries is normal — process them all via repeated Edit calls or one-shot Write of the whole file. +- Do not call external translation services (Google Translate, DeepL, Azure Translator, etc.). The CLI applies your output, you produce it. +- Do not edit the underlying XML or source files directly when a `txc` tool exists for the workflow. +- Leave technical strings (identifiers, slugs, schema names, brand names, GUIDs) untranslated. Only translate human-facing display text. + +If you find yourself reaching for a shell command that touches the translation JSON for any reason — reading, parsing, filtering, deduplicating, writing — that is a signal to stop and use Read/Edit/Write instead. + +For localization specifically, call `get_skill_details` with `skill_id: ""localization""` for the full workflow. By default `export` and `show` skip Power Platform system attributes (createdon, owner, statecode, etc.); pass `--add-system-attributes` only if you explicitly want them included. + ## Project Structure and Naming Conventions **Note**: These are recommended naming conventions. Users may choose different naming styles based on their preferences or organizational standards. diff --git a/src/TALXIS.CLI.MCP/Program.cs b/src/TALXIS.CLI.MCP/Program.cs index c7be0aa..1b1bc81 100644 --- a/src/TALXIS.CLI.MCP/Program.cs +++ b/src/TALXIS.CLI.MCP/Program.cs @@ -246,9 +246,12 @@ async ValueTask HandleExecuteOperationAsync( if (IsMcpSpecificTool(operationName)) throw new McpException($"'{operationName}' is an in-process tool — call it directly instead of through execute_operation."); - // Parse arguments — accept either a JSON string or a JSON object + // Parse arguments — accept either a JSON string or a JSON object. + // Tolerate the common `args` alias because LLMs frequently emit that form + // despite the schema specifying `arguments`. Dictionary? opArguments = null; - if (arguments.TryGetValue("arguments", out var argsEl)) + if (arguments.TryGetValue("arguments", out var argsEl) + || arguments.TryGetValue("args", out argsEl)) { if (argsEl.ValueKind == JsonValueKind.String) { @@ -642,7 +645,7 @@ JsonElement BuildExecuteOperationInputSchema() }, ["arguments"] = new Dictionary { - ["description"] = "Arguments matching the tool's schema. Pass as a JSON object or a JSON-encoded string." + ["description"] = "Arguments matching the tool's schema. Pass as a JSON object or a JSON-encoded string. The parameter name is `arguments` (not `args`)." } }, ["required"] = new List { "operation" } diff --git a/src/TALXIS.CLI.MCP/RootsService.cs b/src/TALXIS.CLI.MCP/RootsService.cs index 000678a..e4d3985 100644 --- a/src/TALXIS.CLI.MCP/RootsService.cs +++ b/src/TALXIS.CLI.MCP/RootsService.cs @@ -81,6 +81,17 @@ public RootsService(McpServer server, ILogger? logger = null) if (!string.Equals(parsed.Scheme, "file", StringComparison.OrdinalIgnoreCase)) return null; - return parsed.LocalPath; + var path = parsed.LocalPath; + + if (OperatingSystem.IsWindows() + && path.Length >= 3 + && path[0] == '/' + && char.IsLetter(path[1]) + && path[2] == ':') + { + path = path.Substring(1).Replace('/', '\\'); + } + + return path; } }