Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,31 @@ function New-OrderedStringMap {
return [ordered]@{}
}

function Assert-NoDuplicateJsonKeys {
param(
[Parameter(Mandatory = $true)]
[string]$Content,

[Parameter(Mandatory = $true)]
[string]$Path
)

$pattern = [regex]'(?m)^\s*"((?:\\.|[^"])*)"\s*:'
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
$duplicates = New-Object System.Collections.Generic.List[string]

foreach ($match in $pattern.Matches($Content)) {
$key = [regex]::Unescape($match.Groups[1].Value)
if (-not $seen.Add($key) -and -not $duplicates.Contains($key)) {
$duplicates.Add($key)
}
}

if ($duplicates.Count -gt 0) {
throw "JSON file contains duplicate key(s): $($duplicates -join ', '). Path: $Path"
}
}

function Read-OrderedJsonMap {
param(
[Parameter(Mandatory = $true)]
Expand All @@ -59,6 +84,8 @@ function Read-OrderedJsonMap {
return $result
}

Assert-NoDuplicateJsonKeys -Content $content -Path $Path

$parsed = $content | ConvertFrom-Json -AsHashtable
if ($null -eq $parsed) {
return $result
Expand Down Expand Up @@ -323,4 +350,4 @@ function Assert-TranslationStructure {
if ((Get-NewlineCount -Value $SourceValue) -ne (Get-NewlineCount -Value $TranslatedValue)) {
throw "Line-break mismatch for key '$Key'."
}
}
}
29 changes: 28 additions & 1 deletion scripts/verify_translations.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ function Read-JsonObject {
return [ordered]@{}
}

Assert-NoDuplicateJsonKeys -Content $content -Path $Path

$parsed = $content | ConvertFrom-Json -AsHashtable
if ($null -eq $parsed) {
return [ordered]@{}
Expand All @@ -44,6 +46,31 @@ function Read-JsonObject {
return $parsed
}

function Assert-NoDuplicateJsonKeys {
param(
[Parameter(Mandatory = $true)]
[string]$Content,

[Parameter(Mandatory = $true)]
[string]$Path
)

$pattern = [regex]'(?m)^\s*"((?:\\.|[^"])*)"\s*:'
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
$duplicates = New-Object System.Collections.Generic.List[string]

foreach ($match in $pattern.Matches($Content)) {
$key = [regex]::Unescape($match.Groups[1].Value)
if (-not $seen.Add($key) -and -not $duplicates.Contains($key)) {
$duplicates.Add($key)
}
}

if ($duplicates.Count -gt 0) {
throw "JSON file contains duplicate key(s): $($duplicates -join ', '). Path: $Path"
}
}

function Get-BraceBlock {
param(
[Parameter(Mandatory = $true)]
Expand Down Expand Up @@ -292,4 +319,4 @@ try {
catch {
Write-Error $_.Exception.Message
exit 1
}
}
46 changes: 46 additions & 0 deletions src/UniGetUI.Core.Language.Tests/LanguageEngineTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using UniGetUI.Core.Data;
using UniGetUI.PackageEngine.Enums;

namespace UniGetUI.Core.Language.Tests
Expand All @@ -7,6 +8,7 @@ public class LanguageEngineTests
[Theory]
[InlineData("ca", "Fes una còpia de seguretat dels paquets instal·lats")]
[InlineData("es", "Respaldar paquetes instalados")]
[InlineData("ua", "Резервне копіювання встановлених пакетів")]
public void TestLoadingLanguage(string language, string translation)
{
LanguageEngine engine = new();
Expand Down Expand Up @@ -51,6 +53,50 @@ public void LocalFallbackTest()
Assert.Equal("en", engine.Locale);
}

[Fact]
public void TestLoadingUkrainianSpecificTranslation()
{
LanguageEngine engine = new();

engine.LoadLanguage("ua");
Assert.Equal("Підсистема Android", engine.Translate("Android Subsystem"));
}

[Fact]
public void TestLoadingCachedLanguageWithDuplicateKeysKeepsLastValue()
{
string cachedLangFile = Path.Join(
CoreData.UniGetUICacheDirectory_Lang,
"lang_duplicate-test.json"
);
File.WriteAllText(
cachedLangFile,
"""
{
"Android Subsystem": "Android Subsystem",
"Android Subsystem": "Підсистема Android",
"Backup installed packages": "Cached duplicate test"
}
"""
);

try
{
LanguageEngine engine = new();

Dictionary<string, string> langFile = engine.LoadLanguageFile("duplicate-test");
Assert.Equal("Підсистема Android", langFile["Android Subsystem"]);
Assert.Equal("Cached duplicate test", langFile["Backup installed packages"]);
}
finally
{
if (File.Exists(cachedLangFile))
{
File.Delete(cachedLangFile);
}
}
}

[Fact]
public void TestStaticallyLoadedLanguages()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@
"An unexpected error occurred:": "Сталася неочікувана помилка:",
"An unexpected issue occurred while attempting to repair WinGet. Please try again later": "Виникла неочікувана проблема при намаганні відновити WinGet. Будь ласка, спробуйте пізніше.",
"An update was found!": "Знайдено оновлення!",
"Android Subsystem": "Android Subsystem",
"Another source": "Інше джерело",
"Any new shorcuts created during an install or an update operation will be deleted automatically, instead of showing a confirmation prompt the first time they are detected.": "Для новий ярликів, створених під час операцій встановлення та оновлення, не буде показано діалогу підтвердження: замість цього вони будуть видалені автоматично.",
"Any shorcuts created or modified outside of UniGetUI will be ignored. You will be able to add them via the {0} button.": "Всі ярлики, створені або змінені не через UniGetUI, будуть проігноровані. Ви зможете додати їх за допомогою кнопки \"{0}\".",
Expand Down Expand Up @@ -1072,4 +1071,4 @@
"{pm} preferences": "Налаштування {pm}",
"{pm} version:": "Версія {pm}:",
"{pm} was not found!": "{pm} не знайдено!"
}
}
109 changes: 79 additions & 30 deletions src/UniGetUI.Core.LanguageEngine/LanguageEngine.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.Json.Nodes;
using System.Text;
using System.Text.Json;
using Jeffijoe.MessageFormat;
using UniGetUI.Core.Data;
using UniGetUI.Core.Logging;
Expand Down Expand Up @@ -88,7 +89,7 @@ public Dictionary<string, string> LoadLanguageFile(string LangKey)
"Languages",
"lang_" + LangKey + ".json"
);
JsonObject BundledContents = [];
Dictionary<string, string> LangDict = [];

if (!File.Exists(BundledLangFileToLoad))
{
Expand All @@ -100,15 +101,10 @@ public Dictionary<string, string> LoadLanguageFile(string LangKey)
{
try
{
if (
JsonNode.Parse(File.ReadAllText(BundledLangFileToLoad))
is JsonObject parsedObject
)
BundledContents = parsedObject;
else
throw new ArgumentException(
$"parsedObject was null for lang file {BundledLangFileToLoad}"
);
LangDict = ParseLanguageEntries(
File.ReadAllText(BundledLangFileToLoad),
BundledLangFileToLoad
);
}
catch (Exception ex)
{
Expand All @@ -119,11 +115,6 @@ is JsonObject parsedObject
}
}

Dictionary<string, string> LangDict = BundledContents.ToDictionary(
x => x.Key,
x => x.Value?.ToString() ?? ""
);

string CachedLangFileToLoad = Path.Join(
CoreData.UniGetUICacheDirectory_Lang,
"lang_" + LangKey + ".json"
Expand All @@ -143,25 +134,20 @@ is JsonObject parsedObject
{
try
{
if (
JsonNode.Parse(File.ReadAllText(CachedLangFileToLoad))
is JsonObject parsedObject
)
foreach (
var keyval in parsedObject.ToDictionary(x => x.Key, x => x.Value)
foreach (
var keyval in ParseLanguageEntries(
File.ReadAllText(CachedLangFileToLoad),
CachedLangFileToLoad
)
{
LangDict[keyval.Key] = keyval.Value?.ToString() ?? "";
}
else
throw new ArgumentException(
$"parsedObject was null for lang file {CachedLangFileToLoad}"
);
)
{
LangDict[keyval.Key] = keyval.Value;
}
}
catch (Exception ex)
{
Logger.Warn(
$"Something went wrong when parsing language file {BundledLangFileToLoad}"
$"Something went wrong when parsing language file {CachedLangFileToLoad}"
);
Logger.Warn(ex);
}
Expand All @@ -180,6 +166,69 @@ var keyval in parsedObject.ToDictionary(x => x.Key, x => x.Value)
}
}

private static Dictionary<string, string> ParseLanguageEntries(
string fileContents,
string filePath
)
{
Dictionary<string, string> entries = [];
HashSet<string> duplicateKeys = [];
Utf8JsonReader reader = new(
Encoding.UTF8.GetBytes(fileContents),
new JsonReaderOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip,
}
);

if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException($"Language file {filePath} does not contain a JSON object");
}

while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
{
break;
}

if (reader.TokenType != JsonTokenType.PropertyName)
{
throw new JsonException(
$"Unexpected token {reader.TokenType} in language file {filePath}"
);
}

string key = reader.GetString() ?? throw new JsonException("Translation key is null");
if (!reader.Read())
{
throw new JsonException($"Missing translation value for key {key}");
}

using JsonDocument value = JsonDocument.ParseValue(ref reader);
string parsedValue = value.RootElement.ValueKind == JsonValueKind.Null
? ""
: value.RootElement.ToString();

if (!entries.TryAdd(key, parsedValue))
{
duplicateKeys.Add(key);
entries[key] = parsedValue;
}
}

if (duplicateKeys.Count > 0)
{
Logger.Warn(
$"Language file {filePath} contains duplicate keys. Keeping the last value for: {string.Join(", ", duplicateKeys)}"
);
}

return entries;
}

/// <summary>
/// Downloads and saves an updated version of the translations for the specified language.
/// </summary>
Expand Down
Loading