diff --git a/.github/workflows/account.yml b/.github/workflows/account.yml index db9a542b02..bc2a2ee8f9 100644 --- a/.github/workflows/account.yml +++ b/.github/workflows/account.yml @@ -103,7 +103,7 @@ jobs: working-directory: application run: npx turbo run build --filter=@repo/emails - - name: Run Tests with dotCover and SonarScanner Reporting + - name: Run Tests with SonarScanner Analysis working-directory: application env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -114,9 +114,9 @@ jobs: dotnet build account/Account.slnf --no-restore /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} && dotnet test account/Account.slnf --no-build else - dotnet sonarscanner begin /k:"${{ vars.SONAR_PROJECT_KEY }}" /o:"${{ vars.SONAR_ORGANIZATION }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.dotcover.reportsPaths="coverage/dotCover.html" && + dotnet sonarscanner begin /k:"${{ vars.SONAR_PROJECT_KEY }}" /o:"${{ vars.SONAR_ORGANIZATION }}" /d:sonar.host.url="https://sonarcloud.io" && dotnet build account/Account.slnf --no-restore /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} && - dotnet dotcover test account/Account.slnf --no-build --dcOutput=coverage/dotCover.html --dcReportType=HTML --dcFilters="+:Account*;+:SharedKernel;-:*.Tests;-:type=*.AppHost.*" && + dotnet test account/Account.slnf --no-build && dotnet sonarscanner end fi @@ -223,10 +223,32 @@ jobs: working-directory: application/account/WebApp run: npm run lint + - name: Check for Frontend Formatting Issues + working-directory: application/account/WebApp + run: | + npm run format + + # Check for any changes made by the code formatter + git diff --exit-code || { + echo "Formatting issues detected. Please run 'npm run format' from /application/account/WebApp folder locally and commit the formatted code." + exit 1 + } + - name: Run Back Office Lint working-directory: application/account/BackOffice run: npm run lint + - name: Check for Back Office Formatting Issues + working-directory: application/account/BackOffice + run: | + npm run format + + # Check for any changes made by the code formatter + git diff --exit-code || { + echo "Formatting issues detected. Please run 'npm run format' from /application/account/BackOffice folder locally and commit the formatted code." + exit 1 + } + database-migrations-stage: name: Database Staging if: ${{ vars.STAGING_CLUSTER_ENABLED == 'true' }} diff --git a/.github/workflows/developer-cli.yml b/.github/workflows/developer-cli.yml new file mode 100644 index 0000000000..ab4775a3dc --- /dev/null +++ b/.github/workflows/developer-cli.yml @@ -0,0 +1,70 @@ +name: Developer CLI + +on: + push: + branches: + - main + paths: + - "developer-cli/**" + - ".github/workflows/developer-cli.yml" + - "!**.md" + pull_request: + paths: + - "developer-cli/**" + - ".github/workflows/developer-cli.yml" + - "!**.md" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-and-verify: + name: Build, Lint, and Format + runs-on: ubuntu-24.04 + + env: + # Skip the CLI's self-rebuild on every invocation; we build explicitly below. + DEVELOPERCLI_SKIP_CHANGE_DETECTION: "1" + + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: developer-cli/global.json + + - name: Restore .NET Tools + working-directory: developer-cli + run: dotnet tool restore + + - name: Restore .NET Dependencies + working-directory: developer-cli + run: dotnet restore + + - name: Build Developer CLI + working-directory: developer-cli + run: dotnet build DeveloperCli.slnx --no-restore + + - name: Run Code Linting + working-directory: developer-cli + run: | + dotnet run --no-build -- lint --cli --no-build | tee lint-output.log + + if ! grep -q "No developer-cli issues found!" lint-output.log; then + echo "Code linting issues found." + exit 1 + fi + + - name: Check for Code Formatting Issues + working-directory: developer-cli + run: | + dotnet run --no-build -- format --cli --no-build + + # Check for any changes made by the code formatter + git diff --exit-code || { + echo "Formatting issues detected. Please run 'dotnet run -- format --cli' from /developer-cli folder locally and commit the formatted code." + exit 1 + } diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d045618379..24aa50d4c2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -103,7 +103,7 @@ jobs: working-directory: application run: npx turbo run build --filter=@repo/emails - - name: Run Tests with dotCover and SonarScanner Reporting + - name: Run Tests with SonarScanner Analysis working-directory: application env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -114,9 +114,9 @@ jobs: dotnet build main/Main.slnf --no-restore /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} && dotnet test main/Main.slnf --no-build else - dotnet sonarscanner begin /k:"${{ vars.SONAR_PROJECT_KEY }}" /o:"${{ vars.SONAR_ORGANIZATION }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.dotcover.reportsPaths="coverage/dotCover.html" && + dotnet sonarscanner begin /k:"${{ vars.SONAR_PROJECT_KEY }}" /o:"${{ vars.SONAR_ORGANIZATION }}" /d:sonar.host.url="https://sonarcloud.io" && dotnet build main/Main.slnf --no-restore /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} && - dotnet dotcover test main/Main.slnf --no-build --dcOutput=coverage/dotCover.html --dcReportType=HTML --dcFilters="+:Main*;+:SharedKernel;-:*.Tests;-:type=*.AppHost.*" && + dotnet test main/Main.slnf --no-build && dotnet sonarscanner end fi @@ -218,6 +218,17 @@ jobs: working-directory: application/main/WebApp run: npm run lint + - name: Check for Frontend Formatting Issues + working-directory: application/main/WebApp + run: | + npm run format + + # Check for any changes made by the code formatter + git diff --exit-code || { + echo "Formatting issues detected. Please run 'npm run format' from /application/main/WebApp folder locally and commit the formatted code." + exit 1 + } + database-migrations-stage: name: Database Staging if: ${{ vars.STAGING_CLUSTER_ENABLED == 'true' }} diff --git a/README.md b/README.md index 60f6b2711a..fb33690a12 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ [![GitHub issues with roadmap label](https://img.shields.io/github/issues-raw/platformplatform/PlatformPlatform/roadmap?label=roadmap&logo=github&color=%23006B75)](https://github.com/orgs/PlatformPlatform/projects/2/views/2?filterQuery=is%3Aopen+label%3Aroadmap) [![GitHub issues with bug label](https://img.shields.io/github/issues-raw/platformplatform/PlatformPlatform/bug?label=bugs&logo=github&color=red)](https://github.com/platformplatform/PlatformPlatform/issues?q=is%3Aissue+is%3Aopen+label%3Abug) -Coverage Quality Gate Status Security Rating Reliability Rating diff --git a/application/account/WebApp/shared/translations/locale/da-DK.po b/application/account/WebApp/shared/translations/locale/da-DK.po index de63b92749..ce01562218 100644 --- a/application/account/WebApp/shared/translations/locale/da-DK.po +++ b/application/account/WebApp/shared/translations/locale/da-DK.po @@ -186,7 +186,7 @@ msgid "Achieved in Microsoft Defender for Cloud following Azure best practices" msgstr "Opnået i Microsoft Defender for Cloud ved at følge Azures bedste praksis" msgid "Action" -msgstr "" +msgstr "Handling" msgid "Activating subscription" msgstr "Aktiverer abonnement" @@ -204,7 +204,7 @@ msgid "Active users in the past 30 days" msgstr "Aktive brugere i de sidste 30 dage" msgid "Add an extra layer of security to your account." -msgstr "" +msgstr "Tilføj et ekstra sikkerhedslag til din konto." msgid "Add billing information" msgstr "Tilføj faktureringsoplysninger" @@ -461,7 +461,7 @@ msgid "Bold" msgstr "Fed" msgid "Bordered row for stand-alone settings or list items." -msgstr "" +msgstr "Række med kant til selvstændige indstillinger eller listeelementer." msgid "Bottom" msgstr "Bund" @@ -1059,7 +1059,7 @@ msgid "Email address" msgstr "E-mailadresse" msgid "Email me when a new device signs in." -msgstr "" +msgstr "Send mig en e-mail, når en ny enhed logger ind." msgid "Email support" msgstr "E-mail-support" @@ -1137,7 +1137,7 @@ msgid "Export as PDF" msgstr "Eksporter som PDF" msgid "External" -msgstr "" +msgstr "Ekstern" msgid "Extra large" msgstr "Ekstra stor" @@ -1290,7 +1290,7 @@ msgid "Indeterminate" msgstr "Ubestemt" msgid "Indeterminate loading indicator. Use Progress when you can show how much work remains; reach for Spinner when you can't." -msgstr "" +msgstr "Ubestemt indlæsningsindikator. Brug Progress, når du kan vise, hvor meget arbejde der er tilbage; vælg Spinner, når du ikke kan." msgid "Indian" msgstr "Indisk" @@ -1344,13 +1344,13 @@ msgid "Italic" msgstr "Kursiv" msgid "Item — clickable row" -msgstr "" +msgstr "Element — klikbar række" msgid "Item — image media" -msgstr "" +msgstr "Element — billedmedie" msgid "Item — variants" -msgstr "" +msgstr "Element — varianter" msgid "Item archived" msgstr "Element arkiveret" @@ -1359,7 +1359,7 @@ msgid "Item deleted" msgstr "Element slettet" msgid "ItemGroup — settings list" -msgstr "" +msgstr "ItemGroup — indstillingsliste" msgid "January - June 2024" msgstr "Januar - juni 2024" @@ -1506,7 +1506,7 @@ msgid "Logged out" msgstr "Logget ud" msgid "Login alerts" -msgstr "" +msgstr "Login-advarsler" msgid "Login method" msgstr "Login-metode" @@ -1533,7 +1533,7 @@ msgid "Main navigation" msgstr "Hovednavigation" msgid "Manage" -msgstr "" +msgstr "Administrer" msgid "Manage subscription" msgstr "Administrer abonnement" @@ -1641,7 +1641,7 @@ msgid "Multi-select summary side pane" msgstr "Oversigtspanel for flervalg" msgid "Muted" -msgstr "" +msgstr "Dæmpet" msgid "Name" msgstr "Navn" @@ -1740,7 +1740,7 @@ msgid "Number (integer)" msgstr "Tal (heltal)" msgid "Off" -msgstr "" +msgstr "Fra" msgid "OK" msgstr "OK" @@ -1749,7 +1749,7 @@ msgid "Okonomiyaki" msgstr "Okonomiyaki" msgid "On" -msgstr "" +msgstr "Til" msgid "One-time code" msgstr "Engangskode" @@ -1824,7 +1824,7 @@ msgid "Palak paneer" msgstr "Palak paneer" msgid "Passkeys" -msgstr "" +msgstr "Passkeys" msgid "Passwordless deployments" msgstr "Adgangskodeløse implementeringer" @@ -2388,7 +2388,7 @@ msgid "Sidebar footer" msgstr "Sidemenu-sidefod" msgid "Sign in without a password using your device." -msgstr "" +msgstr "Log ind uden adgangskode med din enhed." msgid "Sign up" msgstr "Tilmeld dig" @@ -2421,10 +2421,10 @@ msgid "SLA" msgstr "SLA" msgid "Slider (range)" -msgstr "" +msgstr "Slider (interval)" msgid "Slider with steps" -msgstr "" +msgstr "Slider med trin" msgid "Slow-cooked classics" msgstr "Langtidsstegte klassikere" @@ -2463,7 +2463,7 @@ msgid "Spaghetti carbonara" msgstr "Spaghetti carbonara" msgid "Spinner" -msgstr "" +msgstr "Spinner" msgid "Split buttons" msgstr "Delte knapper" @@ -2512,7 +2512,7 @@ msgid "Subscription" msgstr "Abonnement" msgid "Subtle background for grouped rows inside a panel." -msgstr "" +msgstr "Diskret baggrund til grupperede rækker i et panel." msgid "Succeeded" msgstr "Gennemført" @@ -2542,7 +2542,7 @@ msgid "Tablet" msgstr "Tablet" msgid "Tabs" -msgstr "" +msgstr "Faner" msgid "Tacos al pastor" msgstr "Tacos al pastor" @@ -2731,7 +2731,7 @@ msgid "Try again" msgstr "Prøv igen" msgid "Two-factor authentication" -msgstr "" +msgstr "Tofaktor-godkendelse" msgid "Type a command or search..." msgstr "Skriv en kommando eller søg..." @@ -2921,7 +2921,7 @@ msgid "View profile" msgstr "Se profil" msgid "View profile verification" -msgstr "" +msgstr "Vis profilbekræftelse" msgid "View users" msgstr "Se brugere" @@ -3038,7 +3038,7 @@ msgid "Your plan will be downgraded to {planName} at the end of your current bil msgstr "Din plan vil blive nedgraderet til {planName} ved udgangen af din nuværende faktureringsperiode. Du beholder dine nuværende planfunktioner indtil da." msgid "Your profile has been verified" -msgstr "" +msgstr "Din profil er blevet bekræftet" msgid "Your scheduled downgrade has been cancelled." msgstr "Din planlagte nedgradering er blevet afbrudt." diff --git a/developer-cli/Commands/InstallCommand.cs b/developer-cli/Commands/InstallCommand.cs index 80774a785d..c7c24563fe 100644 --- a/developer-cli/Commands/InstallCommand.cs +++ b/developer-cli/Commands/InstallCommand.cs @@ -68,7 +68,7 @@ private static void Execute(bool force) { AnsiConsole.WriteLine(); RegisterAlias(); - GitHooksSync.Sync(forcePrompt: true); + GitHooksSync.Sync(true); } AnsiConsole.MarkupLine( diff --git a/developer-cli/Commands/LintCommand.cs b/developer-cli/Commands/LintCommand.cs index be2526d3a7..1d8e0195d5 100644 --- a/developer-cli/Commands/LintCommand.cs +++ b/developer-cli/Commands/LintCommand.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using DeveloperCli.Installation; using DeveloperCli.Utilities; +using Karambolo.PO; using Spectre.Console; namespace DeveloperCli.Commands; @@ -60,7 +61,8 @@ private static void Execute(bool backend, bool frontend, bool developerCli, stri if (lintFrontend) { Prerequisite.Ensure(Prerequisite.Node); - RunFrontendLinting(quiet); + var frontendHasIssues = RunFrontendLinting(quiet); + hasIssues = hasIssues || frontendHasIssues; frontendTime = Stopwatch.GetElapsedTime(startTime) - backendTime; } @@ -165,10 +167,66 @@ private static bool RunBackendLinting(string? selfContainedSystem, bool noBuild, return hasIssues; } - private static void RunFrontendLinting(bool quiet) + private static bool RunFrontendLinting(bool quiet) { if (!quiet) AnsiConsole.MarkupLine("[blue]Running frontend linting...[/]"); ProcessHelper.Run("npm run lint", Configuration.ApplicationFolder, "Frontend linting", quiet); + + return CheckMissingTranslations(quiet); + } + + private static bool CheckMissingTranslations(bool quiet) + { + if (!quiet) AnsiConsole.MarkupLine("[blue]Checking for missing translations...[/]"); + + var translationFiles = Directory.GetFiles(Configuration.ApplicationFolder, "*.po", SearchOption.AllDirectories) + .Where(f => !f.Contains("node_modules") && !f.EndsWith("en-US.po")) + .ToArray(); + + var filesWithMissing = new List<(string RelativePath, int MissingCount)>(); + foreach (var translationFile in translationFiles) + { + var content = File.ReadAllText(translationFile); + var parseResult = new POParser().Parse(new StringReader(content)); + if (!parseResult.Success) continue; + + var missingCount = parseResult.Catalog.Values + .OfType() + .Count(entry => string.IsNullOrWhiteSpace(entry.Translation)); + + if (missingCount > 0) + { + var relativePath = translationFile.Replace(Configuration.ApplicationFolder, "").TrimStart(Path.DirectorySeparatorChar); + filesWithMissing.Add((relativePath, missingCount)); + } + } + + if (filesWithMissing.Count == 0) + { + if (!quiet) AnsiConsole.MarkupLine("[green]No missing translations![/]"); + return false; + } + + // Translation issues do not land in result.json, so always print which files are affected + // even in quiet mode. Otherwise the user only sees the generic "Issues found" message. + if (quiet) + { + Console.WriteLine($"Missing translations found in {filesWithMissing.Count} file(s):"); + foreach (var (relativePath, missingCount) in filesWithMissing) + { + Console.WriteLine($" {missingCount} missing in {relativePath}"); + } + } + else + { + AnsiConsole.MarkupLine($"[yellow]Missing translations found in {filesWithMissing.Count} file(s):[/]"); + foreach (var (relativePath, missingCount) in filesWithMissing) + { + AnsiConsole.MarkupLine($" [red]{missingCount}[/] missing in [cyan]{relativePath}[/]"); + } + } + + return true; } private static bool RunDeveloperCliLinting(bool noBuild, bool quiet) diff --git a/developer-cli/Commands/TranslateCommand.cs b/developer-cli/Commands/TranslateCommand.cs deleted file mode 100644 index 4438c9b205..0000000000 --- a/developer-cli/Commands/TranslateCommand.cs +++ /dev/null @@ -1,505 +0,0 @@ -using System.ClientModel; -using System.CommandLine; -using System.Text; -using Azure.AI.OpenAI; -using DeveloperCli.Installation; -using DeveloperCli.Utilities; -using Karambolo.PO; -using Microsoft.Extensions.AI; -using OpenAI.Chat; -using Spectre.Console; -using ChatMessage = Microsoft.Extensions.AI.ChatMessage; - -namespace DeveloperCli.Commands; - -public class TranslateCommand : Command -{ - public TranslateCommand() : base( - "translate", - $"Update language files with missing translations powered by {OpenAiTranslationService.ModelName}" - ) - { - var selfContainedSystemOption = new Option("--self-contained-system", "-s") { Description = "Translate only files in a specific self-contained system" }; - var languageOption = new Option("--language", "-l") { Description = "Translate only files for a specific language (e.g. da-DK)" }; - - Options.Add(selfContainedSystemOption); - Options.Add(languageOption); - - SetAction(async parseResult => await Execute( - parseResult.GetValue(selfContainedSystemOption), - parseResult.GetValue(languageOption) - ) - ); - } - - private static async Task Execute(string? selfContainedSystem, string? language) - { - Prerequisite.Ensure(Prerequisite.Dotnet); - - try - { - var translationFiles = GetTranslationFiles(selfContainedSystem, language); - await RunTranslation(translationFiles); - } - catch (Exception e) - { - AnsiConsole.MarkupLine($"[red]Translation failed. {e.Message}[/]"); - Environment.Exit(1); - } - } - - private static string[] GetTranslationFiles(string? selfContainedSystem, string? language) - { - var translationFiles = Directory.GetFiles(Configuration.ApplicationFolder, "*.po", SearchOption.AllDirectories) - .Where(f => !f.Contains("node_modules") && - !f.EndsWith("en-US.po") && - !f.EndsWith("pseudo.po") - ) - .ToDictionary(s => s.Replace(Configuration.ApplicationFolder, ""), f => f); - - if (selfContainedSystem is not null) - { - var availableSystems = Directory.GetDirectories(Configuration.ApplicationFolder) - .Select(Path.GetFileName) - .ToArray(); - - if (!availableSystems.Contains(selfContainedSystem)) - { - AnsiConsole.MarkupLine($"[red]ERROR:[/] Invalid self-contained system. Available systems are: {string.Join(", ", availableSystems)}"); - Environment.Exit(1); - } - - translationFiles = translationFiles - .Where(f => f.Key.StartsWith($"{Path.DirectorySeparatorChar}{selfContainedSystem}{Path.DirectorySeparatorChar}")) - .ToDictionary(s => s.Key, f => f.Value); - - if (!translationFiles.Any()) - { - AnsiConsole.MarkupLine($"[red]ERROR:[/] No translation files found in {selfContainedSystem}"); - Environment.Exit(1); - } - } - - if (language is not null) - { - translationFiles = translationFiles - .Where(f => f.Key.EndsWith($"{language}.po")) - .ToDictionary(s => s.Key, f => f.Value); - - if (!translationFiles.Any()) - { - var systemInfo = selfContainedSystem != null ? $" in {selfContainedSystem}" : ""; - AnsiConsole.MarkupLine($"[red]ERROR:[/] No translation files found for language {language}{systemInfo}"); - Environment.Exit(1); - } - } - - return translationFiles.Values.ToArray(); - } - - private static async Task RunTranslation(string[] translationFiles) - { - var isAnyFileTranslated = false; - var translationService = OpenAiTranslationService.Create(); - foreach (var translationFile in translationFiles) - { - var isTranslated = await RunTranslation(translationService, translationFile); - isAnyFileTranslated = isAnyFileTranslated || isTranslated; - } - - if (isAnyFileTranslated) - { - AnsiConsole.MarkupLine($"Total tokens used: [yellow]{translationService.UsageStatistics.TotalTokens}[/]. Translation cost: [yellow]${translationService.UsageStatistics.TotalCost:F4}[/]"); - } - } - - private static async Task RunTranslation(OpenAiTranslationService translationService, string translationFile) - { - var poCatalog = await ReadTranslationFile(translationFile); - var entries = poCatalog.EnsureOnlySingularEntries(); - - AnsiConsole.MarkupLine($"Language detected: {poCatalog.Language}"); - var translator = new Translator(translationService, poCatalog.Language); - var translated = await translator.Translate(entries); - - if (!translated.Any()) - { - return false; - } - - foreach (var translatedEntry in translated) - { - poCatalog.UpdateEntry(translatedEntry); - } - - await WriteTranslationFile(translationFile, poCatalog); - - return true; - } - - private static async Task ReadTranslationFile(string translationFile) - { - var translationContent = await File.ReadAllTextAsync(translationFile); - var poParser = new POParser(); - var poParseResult = poParser.Parse(new StringReader(translationContent)); - if (!poParseResult.Success) - { - AnsiConsole.MarkupLine($"[red]ERROR:[/] Failed to parse PO file. {poParseResult.Diagnostics}"); - Environment.Exit(1); - } - - if (poParseResult.Catalog.Language is null) - { - AnsiConsole.MarkupLine($"[red]ERROR:[/] Failed to parse PO file {translationFile}. Language not found."); - Environment.Exit(1); - } - - return poParseResult.Catalog; - } - - private static async Task WriteTranslationFile(string translationFile, POCatalog poCatalog) - { - var poGenerator = new POGenerator(new POGeneratorSettings { IgnoreEncoding = true, IgnoreLongLines = true }); - var fileStream = File.OpenWrite(translationFile); - poGenerator.Generate(fileStream, poCatalog); - await fileStream.FlushAsync(); - fileStream.Close(); - - AnsiConsole.MarkupLine($"[green]Translated file saved to {translationFile}[/]"); - AnsiConsole.MarkupLine("[yellow]WARNING: Please proofread to make sure the language is inclusive.[/]"); - } - - private sealed class Translator(OpenAiTranslationService translationService, string targetLanguage) - { - private readonly string _englishToTargetLanguagePrompt = CreatePrompt("English", targetLanguage); - private readonly string _targetLanguageToEnglishPrompt = CreatePrompt(targetLanguage, "English"); - - public async Task> Translate(IReadOnlyCollection translationEntries) - { - var translatedEntries = translationEntries.Where(x => x.HasTranslation()).ToList(); - var nonTranslatedEntries = translationEntries.Where(x => !x.HasTranslation()).ToList(); - AnsiConsole.MarkupLine($"Keys missing translation: {nonTranslatedEntries.Count}"); - if (nonTranslatedEntries.Count == 0) - { - AnsiConsole.MarkupLine("[green]Translation completed, nothing to translate.[/]"); - return []; - } - - var toReturn = new List(); - foreach (var nonTranslatedEntry in nonTranslatedEntries) - { - var translated = await TranslateSingleEntry(translatedEntries.AsReadOnly(), nonTranslatedEntry); - if (translated == null) // User chose to stop - { - AnsiConsole.MarkupLine("[yellow]Translation process stopped. Saving changes collected so far.[/]"); - break; - } - - if (!translated.HasTranslation()) continue; - translatedEntries.Add(translated); - toReturn.Add(translated); - } - - AnsiConsole.MarkupLine(toReturn.Count > 0 - ? $"[green]{toReturn.Count} entries have been translated.[/]" - : "[yellow]No entries were translated.[/]" - ); - - return toReturn; - } - - private async Task TranslateSingleEntry( - IReadOnlyCollection translatedEntries, - POSingularEntry nonTranslatedEntry - ) - { - var currentPrompt = _englishToTargetLanguagePrompt; - - while (true) - { - AnsiConsole.MarkupLine($"Translating: [cyan]{nonTranslatedEntry.Key.Id}[/]"); - POSingularEntry translated = null!; - POSingularEntry reverseTranslated = null!; - await AnsiConsole.Status().StartAsync("Initialize translation...", async context => - { - translated = await translationService.Translate( - currentPrompt, translatedEntries, nonTranslatedEntry, context - ); - - // Translate back into the original language and check if translation matches - AnsiConsole.MarkupLine($"Translated to: [cyan]{translated.GetTranslation()}[/]"); - - AnsiConsole.MarkupLine("Checking translation..."); - var reverseTranslations = translatedEntries.Select(x => x.ReverseKeyAndTranslation()).ToArray(); - reverseTranslated = await translationService.Translate( - _targetLanguageToEnglishPrompt, - reverseTranslations, - translated.ReverseKeyAndTranslation(), - context - ); - } - ); - - if (string.Equals(reverseTranslated.GetTranslation(), translated.Key.Id, StringComparison.OrdinalIgnoreCase)) - { - AnsiConsole.MarkupLine("[green]Reverse translation is matching.[/]"); - return translated; - } - - AnsiConsole.MarkupLine($"[yellow]Reverse translation is not matching. Reverse translation is[/] [cyan]{reverseTranslated.GetTranslation()}[/]"); - - var choice = AnsiConsole.Prompt( - new SelectionPrompt() - .Title("What would you like to do?") - .AddChoices("Accept translation", "Try again", "Provide context for retranslation", "Input own translation", "Skip", "Stop and save") - ); - - switch (choice) - { - case "Accept translation": - AnsiConsole.MarkupLine("[green]Translation accepted.[/]"); - return translated; - case "Try again": - AnsiConsole.MarkupLine("[green]Trying translation again...[/]"); - continue; - case "Provide context for retranslation": - var context = AnsiConsole.Ask("Please provide context for the translation:"); - currentPrompt = _englishToTargetLanguagePrompt + $"\nAdditional context for translation: {context}"; - AnsiConsole.MarkupLine("[green]Context added. Retranslating...[/]"); - continue; - case "Input own translation": - var userTranslation = AnsiConsole.Ask("Please input your own translation:"); - if (string.IsNullOrWhiteSpace(userTranslation)) - { - AnsiConsole.MarkupLine("[red]Invalid translation. Please try again.[/]"); - continue; - } - - return translated.ApplyTranslation(userTranslation); - case "Skip": - AnsiConsole.MarkupLine("[yellow]Translation skipped.[/]"); - return translated.ApplyTranslation(string.Empty); - case "Stop and save": - AnsiConsole.MarkupLine("[yellow]Stopping translation process.[/]"); - return null; - } - } - } - - private static string CreatePrompt(string sourceLanguage, string targetLanguage) - { - return $""" - You are a translation service translating from {sourceLanguage} to {targetLanguage}. - Return only the translation, not the original text or any other information. - If the original text contains punctuation or special characters, it is very important to replicate them. Do not try to correct bad grammar in the original text. - - E.g., if the original text is "enter **Your-Name* with no-more-than/#five%characters..!" - Then a translation to, e.g., Danish should be "Indtast **Dit-Navn* med maksimalt/#fem%bogstaver..!" - """; - } - } - - private sealed class OpenAiTranslationService(IChatClient chatClient) - { - public const string ModelName = "gpt-5-mini"; - public readonly Gpt5MiniUsageStatistics UsageStatistics = new(); - - public static OpenAiTranslationService Create() - { - var (apiKey, endpoint) = GetApiKeyAndEndpoint(); - - IChatClient chatClient; - if (endpoint is null) - { - // Use standard OpenAI client for default endpoint - chatClient = new ChatClient(ModelName, apiKey).AsIChatClient(); - } - else - { - // Use Azure OpenAI client for custom endpoints - var azureClient = new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey)); - chatClient = azureClient.GetChatClient(ModelName).AsIChatClient(); - } - - return new OpenAiTranslationService(chatClient); - } - - private static (string apiKey, string? endpoint) GetApiKeyAndEndpoint() - { - const string apiKeySecretName = "OpenAIApiKey"; - const string endpointSecretName = "OpenAIEndpoint"; - - var apiKey = SecretHelper.GetSecret(apiKeySecretName); - var endpoint = SecretHelper.GetSecret(endpointSecretName); - - if (apiKey is not null) - { - return (apiKey, endpoint); - } - - AnsiConsole.MarkupLine("OpenAI Key is missing."); - apiKey = AnsiConsole.Prompt( - new TextPrompt("[yellow]Enter your OpenAI Key. Use a standard OpenAI key (sk-...) or an Azure OpenAI key[/]") - .Validate(key => key.Length >= 32 ? ValidationResult.Success() : ValidationResult.Error("Open AI Keys starts with 'sk-' and must be at least 51 characters long and Azure Open AI key must be 32 characters long.")) - ); - - if (!apiKey.StartsWith("sk-")) - { - AnsiConsole.MarkupLine("[green]API Key is not a standard OpenAI key. Azure OpenAI key detected.[/]"); - endpoint = AnsiConsole.Prompt( - new TextPrompt("[yellow]Please enter the Azure OpenAI endpoint URL (e.g. https://.openai.azure.com)[/]") - .Validate(url => Uri.TryCreate(url, UriKind.Absolute, out _)) - ); - SecretHelper.SetSecret(endpointSecretName, endpoint); - } - - SecretHelper.SetSecret(apiKeySecretName, apiKey); - - return (apiKey, endpoint); - } - - public async Task Translate( - string systemPrompt, - IReadOnlyCollection existingTranslations, - POSingularEntry nonTranslatedEntry, - StatusContext context - ) - { - var messages = new List - { - new(ChatRole.System, systemPrompt) - }; - - foreach (var translation in existingTranslations) - { - messages.Add(new ChatMessage(ChatRole.User, translation.Key.Id)); - messages.Add(new ChatMessage(ChatRole.Assistant, translation.GetTranslation())); - } - - messages.Add(new ChatMessage(ChatRole.User, nonTranslatedEntry.Key.Id)); - context.Status("Translating (thinking...)"); - - StringBuilder content = new(); - var streamingUpdates = new List(); - await foreach (var update in chatClient.GetStreamingResponseAsync(messages)) - { - streamingUpdates.Add(update); - content.Append(update.Text); - var percent = Math.Round(content.Length / (nonTranslatedEntry.Key.Id.Length * 1.2) * 100); // +20% is a guess - context.Status($"Translating {Math.Min(100, percent)}%"); - } - - // Get usage from the aggregated response - var completedResponse = streamingUpdates.ToChatResponse(); - if (completedResponse.Usage is not null) - { - UsageStatistics.Update(completedResponse.Usage); - } - - context.Status("Translating 100%"); - - var translated = content.ToString(); - return nonTranslatedEntry.ApplyTranslation(translated); - } - - public record Gpt5MiniUsageStatistics - { - private const decimal InputPricePerThousandTokens = 0.00025m; - private const decimal OutputPricePerThousandTokens = 0.002m; - - private long _inputTokenCount; - private long _outputTokenCount; - - public decimal TotalCost - { - get - { - var inputCost = _inputTokenCount / 1000m * InputPricePerThousandTokens; - var outputCost = _outputTokenCount / 1000m * OutputPricePerThousandTokens; - - return inputCost + outputCost; - } - } - - public long TotalTokens => _inputTokenCount + _outputTokenCount; - - public void Update(UsageDetails usage) - { - _inputTokenCount += usage.InputTokenCount ?? 0; - _outputTokenCount += usage.OutputTokenCount ?? 0; - } - } - } -} - -public static class Extensions -{ - extension(POSingularEntry poEntry) - { - public string GetTranslation() - { - var translation = poEntry.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(translation)) - { - throw new InvalidOperationException("No translation was found."); - } - - return translation; - } - - public bool HasTranslation() - { - return !string.IsNullOrWhiteSpace(poEntry.Translation); - } - - public POSingularEntry ReverseKeyAndTranslation() - { - var key = new POKey(poEntry.GetTranslation(), null, poEntry.Key.ContextId); - var entry = new POSingularEntry(key) - { - Translation = poEntry.Key.Id, - Comments = poEntry.Comments - }; - - return entry; - } - - public POSingularEntry ApplyTranslation(string translation) - { - return new POSingularEntry(poEntry.Key) - { - Translation = translation, - Comments = poEntry.Comments - }; - } - } - - extension(POCatalog catalog) - { - public IReadOnlyCollection EnsureOnlySingularEntries() - { - if (catalog.Values.Any(x => x is not POSingularEntry)) - { - throw new NotSupportedException("Only single translations are supported."); - } - - return catalog.Values.OfType().ToArray(); - } - - public void UpdateEntry(POSingularEntry translatedEntry) - { - var key = translatedEntry.Key; - var poEntry = catalog[key]; - if (poEntry is POSingularEntry) - { - var index = catalog.IndexOf(poEntry); - catalog.Remove(key); - catalog.Insert(index, translatedEntry); - } - else - { - throw new InvalidOperationException($"Plural is currently not supported. Key: '{key.Id}'"); - } - } - } -} diff --git a/developer-cli/DeveloperCli.csproj b/developer-cli/DeveloperCli.csproj index 820963825f..d5e9d28e0c 100644 --- a/developer-cli/DeveloperCli.csproj +++ b/developer-cli/DeveloperCli.csproj @@ -10,17 +10,11 @@ platformplatform-f817f2a1-ac57-4756-aef2-a57ca864bbd3 - - - - - -