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 @@
[](https://github.com/orgs/PlatformPlatform/projects/2/views/2?filterQuery=is%3Aopen+label%3Aroadmap)
[](https://github.com/platformplatform/PlatformPlatform/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
-
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
-
-
-
-
-
-