Server-side translation manager: install, update, and remove language packs from the BTCPay UI (#7347)
What changed, and why it matters
This commit adds a server-side translation manager to BTCPay Server and, in the process, fixes several security-relevant bugs. The most important fix is that downloaded language packs are now verified against a SHA-256 hash from a manifest, preventing a corrupted or tampered translation file from being accepted. The commit also adds server-side guards so built-in or currently-selected translations cannot be uninstalled by a crafted request, restores a typed 'Delete' confirmation before removing custom translations, and redirects old URL paths to new ones so existing integrations keep working. Most of the change is a feature refactor (renaming 'dictionaries' to 'translations' and moving to a manifest-based system), but the security hardening is explicitly described in the commit message and diff.
Reviewers should verify that the SHA-256 comparison is truly case-insensitive and that the manifest URL and raw file base URL are pinned to the expected upstream repository. Confirm that the UninstallLanguagePack guards cannot be bypassed by case variation or alternate Source values, and that the IMemoryCache dependency is correctly registered in production DI. Consider whether manifest fetch failures should degrade silently or alert administrators, and ensure the new route redirects do not create open-redirect opportunities.
Security signals we found
SHA-256 verification of downloaded language packs against manifest
Server-side authorization guards on uninstall (source check + selected-language check)
Typed confirmation modal restored for destructive uninstall action
Catchall backward-compatible redirect for old /server/dictionaries routes
TryAdd used for manifest name indexing to avoid DoS/crash from duplicate names
Case-insensitive SHA comparison in update check and download paths
Broad exception handler narrowed to expected network/JSON/manifest exception types
Evidence from the diff
The PR refactors the BTCPay Server translations subsystem from a hardcoded list of downloadable languages to a manifest fetched from the btcpayserver-translator GitHub repository. Security-relevant changes include: (1) LanguagePackUpdateService.FetchLanguagePackFromRepository now downloads raw bytes, computes SHA-256, and compares it case-insensitively to the manifest’s Sha field, throwing InvalidOperationException on mismatch; (2) UninstallLanguagePack now validates server-side that the translation exists, has Source ‘LanguagePack’ or ‘Custom’, and is not the currently selected display language; (3) ListTranslations uses TryAdd for manifest entries to avoid crashing on duplicate names; (4) old /server/dictionaries routes are preserved via a catchall 308 redirect; (5) the destructive uninstall action requires typing ‘Delete’ in a confirmation modal. The manifest and per-language update checks are cached in IMemoryCache for one hour. Unit and Playwright tests cover the new verification and guard paths.
Changed components
BTCPayServer/Plugins/Translations/LanguagePackUpdateService.csBTCPayServer/Plugins/Translations/Controllers/UITranslationController.csBTCPayServer/Plugins/Translations/LocalizerService.csBTCPayServer/Plugins/Translations/Views/ListTranslations.cshtmlBTCPayServer/Services/PoliciesSettings.csBTCPayServer/Controllers/UIServerController.csBTCPayServer.Tests/LanguageServiceTests.csInspect captured patch +969 / −490
diff --git a/BTCPayServer.Tests/LanguageServiceTests.cs b/BTCPayServer.Tests/LanguageServiceTests.cs
index 25ce23e..42e5712 100644
--- a/BTCPayServer.Tests/LanguageServiceTests.cs
+++ b/BTCPayServer.Tests/LanguageServiceTests.cs
@@ -1,6 +1,11 @@
+using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Plugins.Translations;
@@ -8,6 +13,7 @@ using BTCPayServer.Services;
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Playwright;
+using Newtonsoft.Json.Linq;
using static Microsoft.Playwright.Assertions;
using Xunit;
using Xunit.Abstractions;
@@ -17,11 +23,277 @@ namespace BTCPayServer.Tests
[Collection(nameof(NonParallelizableCollectionDefinition))]
public class LanguageServiceTests : UnitTestBase
{
+ private class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
+ {
+ public HttpClient CreateClient(string name) => new(handler, false);
+ }
+
+ private class StubHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly Dictionary<string, Func<HttpResponseMessage>> _responses = new();
+ public Dictionary<string, int> Calls { get; } = new();
+
+ public void Register(string url, Func<HttpResponseMessage> responseFactory)
+ {
+ _responses[url] = responseFactory;
+ }
+
+ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var url = request.RequestUri!.ToString();
+ Calls[url] = Calls.TryGetValue(url, out var existing) ? existing + 1 : 1;
+
+ if (_responses.TryGetValue(url, out var responseFactory))
+ return Task.FromResult(responseFactory());
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
+ {
+ Content = new StringContent("Not Found", Encoding.UTF8, "text/plain")
+ });
+ }
+ }
+
public const int TestTimeout = TestUtils.TestTimeout;
public LanguageServiceTests(ITestOutputHelper helper) : base(helper)
{
}
+ [Fact(Timeout = TestTimeout)]
+ [Trait("Unit", "Unit")]
+ public async Task LanguagePackUpdateService_ParsesManifestEntries()
+ {
+ var handler = new StubHttpMessageHandler();
+ var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
+ handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ """
+ {
+ "Languages": [
+ {
+ "Name": "French",
+ "Native": "Francais",
+ "File": "translations/french.json",
+ "Sha": "sha-fr",
+ "Maintainer": "alice|https://github.com/alice",
+ "Updated": "2026-05-01T10:00:00Z"
+ },
+ {
+ "Name": "German",
+ "Native": "Deutsch",
+ "File": "translations/german.json",
+ "Sha": "sha-de",
+ "Maintainer": null,
+ "Updated": "invalid"
+ }
+ ]
+ }
+ """,
+ Encoding.UTF8,
+ "application/json")
+ });
+
+ var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ var languages = await service.GetManifestLanguages();
+
+ Assert.Equal(2, languages.Length);
+
+ var french = languages.Single(l => l.Name == "French");
+ Assert.Equal("Francais", french.Native);
+ Assert.Equal("alice", french.MaintainerHandle);
+ Assert.Equal("https://github.com/alice", french.MaintainerUrl);
+ Assert.NotNull(french.Updated);
+ Assert.Equal("translations/french.json", french.File);
+ Assert.Equal("sha-fr", french.Sha);
+
+ var german = languages.Single(l => l.Name == "German");
+ Assert.Equal("Deutsch", german.Native);
+ Assert.Null(german.MaintainerHandle);
+ Assert.Null(german.MaintainerUrl);
+ Assert.Null(german.Updated);
+ }
+
+ [Fact(Timeout = TestTimeout)]
+ [Trait("Unit", "Unit")]
+ public async Task LanguagePackUpdateService_PropagatesExceptionOnMalformedManifest()
+ {
+ var handler = new StubHttpMessageHandler();
+ var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
+ handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{ this is not: valid json", Encoding.UTF8, "application/json")
+ });
+
+ var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ await Assert.ThrowsAnyAsync<Exception>(() => service.GetManifestLanguages());
+ }
+
+ [Fact(Timeout = TestTimeout)]
+ [Trait("Unit", "Unit")]
+ public async Task LanguagePackUpdateService_ThrowsOnMissingLanguagesKey()
+ {
+ var handler = new StubHttpMessageHandler();
+ var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
+ handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{ \"OtherField\": [] }", Encoding.UTF8, "application/json")
+ });
+
+ var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ await Assert.ThrowsAsync<InvalidOperationException>(() => service.GetManifestLanguages());
+ }
+
+ [Fact(Timeout = TestTimeout)]
+ [Trait("Unit", "Unit")]
+ public async Task LanguagePackUpdateService_ThrowsArgumentExceptionForUnknownLanguage()
+ {
+ var handler = new StubHttpMessageHandler();
+ var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
+ handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ """{ "Languages": [ { "Name": "French", "File": "translations/french.json", "Sha": "deadbeef" } ] }""",
+ Encoding.UTF8, "application/json")
+ });
+
+ var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ await Assert.ThrowsAsync<ArgumentException>(
+ () => service.FetchLanguagePackFromRepository("Klingon"));
+ }
+
+ [Fact(Timeout = TestTimeout)]
+ [Trait("Unit", "Unit")]
+ public async Task LanguagePackUpdateService_ThrowsWhenManifestFails()
+ {
+ var handler = new StubHttpMessageHandler();
+ var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
+ handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.InternalServerError)
+ {
+ Content = new StringContent("error", Encoding.UTF8, "text/plain")
+ });
+
+ var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ await Assert.ThrowsAnyAsync<HttpRequestException>(() => service.GetManifestLanguages());
+ }
+
+ [Fact(Timeout = TestTimeout)]
+ [Trait("Unit", "Unit")]
+ public async Task LanguagePackUpdateService_FetchesLanguagePackFromManifest()
+ {
+ const string body = "{\"Hello\":\"Bonjour\"}";
+ var expectedSha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(body)));
+
+ var handler = new StubHttpMessageHandler();
+ var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
+ var translationUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/translations/french.json";
+
+ handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ $$"""
+ {
+ "Languages": [
+ {
+ "Name": "French",
+ "File": "translations/french.json",
+ "Sha": "{{expectedSha}}"
+ }
+ ]
+ }
+ """,
+ Encoding.UTF8,
+ "application/json")
+ });
+ handler.Register(translationUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json")
+ });
+
+ var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ var (translationsJson, version) = await service.FetchLanguagePackFromRepository("French");
+
+ Assert.Equal(expectedSha, version, ignoreCase: true);
+ Assert.Equal("Bonjour", JObject.Parse(translationsJson)["Hello"]?.ToString());
+ }
+
+ [Fact(Timeout = TestTimeout)]
+ [Trait("Unit", "Unit")]
+ public async Task LanguagePackUpdateService_RejectsLanguagePackOnShaMismatch()
+ {
+ const string body = "{\"Hello\":\"Bonjour\"}";
+
+ var handler = new StubHttpMessageHandler();
+ var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
+ var translationUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/translations/french.json";
+
+ handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ """
+ {
+ "Languages": [
+ {
+ "Name": "French",
+ "File": "translations/french.json",
+ "Sha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
+ }
+ ]
+ }
+ """,
+ Encoding.UTF8,
+ "application/json")
+ });
+ handler.Register(translationUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json")
+ });
+
+ var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ var ex = await Assert.ThrowsAsync<InvalidOperationException>(
+ () => service.FetchLanguagePackFromRepository("French"));
+ Assert.Contains("SHA-256 mismatch", ex.Message);
+ }
+
+ [Fact(Timeout = TestTimeout)]
+ [Trait("Unit", "Unit")]
+ public async Task LanguagePackUpdateService_UsesUpdateCacheUntilInvalidated()
+ {
+ var handler = new StubHttpMessageHandler();
+ var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
+
+ handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ """
+ {
+ "Languages": [
+ {
+ "Name": "French",
+ "File": "translations/french.json",
+ "Sha": "sha-1"
+ }
+ ]
+ }
+ """,
+ Encoding.UTF8,
+ "application/json")
+ });
+
+ var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ var outdatedMetadata = JObject.Parse("{ \"version\": \"sha-0\" }");
+ var upToDateMetadata = JObject.Parse("{ \"version\": \"sha-1\" }");
+
+ var first = await service.CheckForLanguagePackUpdateCached("French", outdatedMetadata);
+ Assert.True(first);
+
+ var cached = await service.CheckForLanguagePackUpdateCached("French", upToDateMetadata);
+ Assert.True(cached);
+
+ service.InvalidateCache("French");
+ var refreshed = await service.CheckForLanguagePackUpdateCached("French", upToDateMetadata);
+ Assert.False(refreshed);
+ }
+
void ActivateLangs(ServerTester s)
{
TestLogs.LogInformation("Activating Langs...");
@@ -80,11 +352,11 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain("Delete-English (Custom)", text);
await tester.Page.Locator("#Select-Cypherpunk").ClickAsync();
await tester.Page.Locator("#Delete-English\\ \\(Custom\\)").ClickAsync();
- await tester.Page.Locator("#ConfirmInput").FillAsync("DELETE");
+ await tester.Page.Locator("#ConfirmInput").FillAsync("Delete");
await tester.Page.Locator("#ConfirmContinue").ClickAsync();
var alertMessage = await tester.FindAlertMessage();
- Assert.Contains("Dictionary English (Custom) deleted", await alertMessage.TextContentAsync());
+ Assert.Contains("Translation English (Custom) deleted", await alertMessage.TextContentAsync());
var pageContent = await tester.Page.ContentAsync();
Assert.DoesNotContain("Select-English (Custom)", pageContent);
}
@@ -102,15 +374,16 @@ namespace BTCPayServer.Tests
TestLogs.LogInformation("French fallback to english");
await db.ExecuteAsync("INSERT INTO lang_dictionaries VALUES ('French', 'English', NULL)");
- async Task SetDictionary(string dictId, (string Sentence, string Translation)[] translations)
+ async Task SetTranslation(string translationId, (string Sentence, string Translation)[] translations)
{
- var dict = await localizer.GetDictionary(dictId);
+ var translation = await localizer.GetTranslation(translationId);
+ Assert.NotNull(translation);
var t = new Translations(translations.Select(t => KeyValuePair.Create(t.Sentence, t.Translation)));
- await localizer.Save(dict, t);
+ await localizer.Save(translation, t);
}
- async Task AssertTranslations(string dictionary, (string Sentence, string Expected)[] expectations)
+ async Task AssertTranslations(string translationId, (string Sentence, string Expected)[] expectations)
{
- var all = await db.QueryAsync<(string sentence, string translation)>($"SELECT sentence, translation from translations WHERE dict_id='{dictionary}'");
+ var all = await db.QueryAsync<(string sentence, string translation)>("SELECT sentence, translation from translations WHERE dict_id=@dictId", new { dictId = translationId });
foreach (var expectation in expectations)
{
if (expectation.Expected is not null)
@@ -120,13 +393,13 @@ namespace BTCPayServer.Tests
}
}
- await SetDictionary("English",
+ await SetTranslation("English",
[
("Hello", "Hello"),
("Goodbye", "Goodbye"),
("Good afternoon", "Good afternoon")
]);
- await SetDictionary("French",
+ await SetTranslation("French",
[
("Hello", "Salut"),
("Good afternoon", "Bonne aprem")
@@ -145,7 +418,7 @@ namespace BTCPayServer.Tests
("lol", null)]);
TestLogs.LogInformation("Can use fallback by setting null to a sentence");
- await SetDictionary("French",
+ await SetTranslation("French",
[
("Good afternoon", "Bonne aprem"),
("Goodbye", "Goodbye"),
@@ -158,7 +431,7 @@ namespace BTCPayServer.Tests
("lol", null)]);
TestLogs.LogInformation("Can use fallback by setting same as fallback to a sentence");
- await SetDictionary("French",
+ await SetTranslation("French",
[
("Good afternoon", "Good afternoon")
]);
@@ -168,7 +441,7 @@ namespace BTCPayServer.Tests
("Goodbye", "Goodbye"),
("lol", null)]);
- await SetDictionary("English",
+ await SetTranslation("English",
[
("Hello", null as string),
("Good afternoon", "Good afternoon"),
diff --git a/BTCPayServer.Tests/UtilitiesTests.cs b/BTCPayServer.Tests/UtilitiesTests.cs
index 027dc9a..4a03e08 100644
--- a/BTCPayServer.Tests/UtilitiesTests.cs
+++ b/BTCPayServer.Tests/UtilitiesTests.cs
@@ -185,33 +185,26 @@ namespace BTCPayServer.Tests
}
/// <summary>
- /// Pre-release check to ensure language packs list in ListDictionaries.cshtml is up to date
+ /// Pre-release check to ensure the manifest is accessible and well-formed
/// </summary>
[Trait("PreReleaseCheck", "PreReleaseCheck")]
[Fact]
- public async Task CheckLanguagePacksListUpToDate()
+ public async Task CheckLanguagePackManifest()
{
- using var httpClient = new HttpClient() { Timeout = TimeSpan.FromSeconds(30) };
- httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("BTCPayServer-Tests");
- var response = await httpClient.GetAsync("https://api.github.com/repos/btcpayserver/btcpayserver-translator/contents/translations");
- response.EnsureSuccessStatusCode();
- var files = JArray.Parse(await response.Content.ReadAsStringAsync());
-
- var availableLanguages = files
- .Where(f => f["name"].Value<string>().EndsWith(".json"))
- .Select(f => System.Globalization.CultureInfo.InvariantCulture.TextInfo.ToTitleCase(f["name"].Value<string>().Replace(".json", "")))
- .OrderBy(l => l)
- .ToList();
-
- var hardcodedLanguages = LanguagePackUpdateService.GetDownloadableLanguages();
- var missingLanguages = availableLanguages.Except(hardcodedLanguages).ToList();
- var extraLanguages = hardcodedLanguages.Except(availableLanguages).ToList();
-
- Assert.True(!missingLanguages.Any() && !extraLanguages.Any(),
- $"Language packs list is out of date.\n" +
- (missingLanguages.Any() ? $"Missing: {string.Join(", ", missingLanguages)}\n" : "") +
- (extraLanguages.Any() ? $"Extra: {string.Join(", ", extraLanguages)}\n" : "") +
- "Update BTCPayServer/Services/LanguagePackUpdateService.cs");
+ using var httpClient = new HttpClient();
+ httpClient.Timeout = TimeSpan.FromSeconds(30);
+ const string manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
+ var json = await httpClient.GetStringAsync(manifestUrl);
+ var manifest = JObject.Parse(json)["Languages"] as JArray;
+ Assert.NotNull(manifest);
+ Assert.NotEmpty(manifest);
+ foreach (var token in manifest)
+ {
+ var entry = Assert.IsType<JObject>(token);
+ Assert.False(string.IsNullOrEmpty(entry["Name"]?.ToString()), "Manifest entry missing 'Name'");
+ Assert.False(string.IsNullOrEmpty(entry["File"]?.ToString()), "Manifest entry missing 'File'");
+ Assert.False(string.IsNullOrEmpty(entry["Sha"]?.ToString()), "Manifest entry missing 'Sha'");
+ }
}
/// <summary>
diff --git a/BTCPayServer/Components/GlobalNav/Default.cshtml b/BTCPayServer/Components/GlobalNav/Default.cshtml
index b0560fe..fe5b9d6 100644
--- a/BTCPayServer/Components/GlobalNav/Default.cshtml
+++ b/BTCPayServer/Components/GlobalNav/Default.cshtml
@@ -91,7 +91,7 @@
<a layout-menu-item="@nameof(ServerNavPages.Branding)" asp-controller="UIServer" asp-action="Branding" text-translate="true">Branding</a>
</li>
<li class="py-1 px-3">
- <a layout-menu-item="@nameof(ServerNavPages.Translations)" asp-area="@TranslationsPlugin.Area" asp-controller="UITranslation" asp-action="ListDictionaries" text-translate="true">Translations</a>
+ <a layout-menu-item="@nameof(ServerNavPages.Translations)" asp-area="@TranslationsPlugin.Area" asp-controller="UITranslation" asp-action="ListTranslations" text-translate="true">Translations</a>
</li>
@if (Model.DockerDeployment)
{
diff --git a/BTCPayServer/Controllers/UIServerController.cs b/BTCPayServer/Controllers/UIServerController.cs
index 831f176..3e00383 100644
--- a/BTCPayServer/Controllers/UIServerController.cs
+++ b/BTCPayServer/Controllers/UIServerController.cs
@@ -348,7 +348,7 @@ namespace BTCPayServer.Controllers
{
ViewBag.UpdateUrlPresent = _Options.UpdateUrl != null;
ViewBag.AppsList = await GetAppSelectList();
- ViewBag.LangDictionaries = await GetLangDictionariesSelectList();
+ ViewBag.LangTranslations = await GetLangTranslationsSelectList();
}
[HttpPost("server/policies")]
@@ -435,7 +435,7 @@ namespace BTCPayServer.Controllers
await _SettingsRepository.UpdateSetting(settings);
_ = _transactionLinkProviders.RefreshTransactionLinkTemplates();
- if (_policiesSettings.LangDictionary != settings.LangDictionary)
+ if (_policiesSettings.LangTranslation != settings.LangTranslation)
await _localizer.Load();
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Policies updated successfully"].Value;
return RedirectToAction(nameof(Policies));
@@ -505,10 +505,10 @@ namespace BTCPayServer.Controllers
return apps;
}
- private async Task<List<SelectListItem>> GetLangDictionariesSelectList()
+ private async Task<List<SelectListItem>> GetLangTranslationsSelectList()
{
- var dictionaries = await this._localizer.GetDictionaries();
- return dictionaries.Select(d => new SelectListItem(d.DictionaryName, d.DictionaryName)).OrderBy(d => d.Value).ToList();
+ var translations = await this._localizer.GetTranslations();
+ return translations.Select(t => new SelectListItem(t.TranslationName, t.TranslationName)).OrderBy(t => t.Value).ToList();
}
private static bool TryParseAsExternalService(TorService torService, [MaybeNullWhen(false)] out ExternalService externalService)
diff --git a/BTCPayServer/Plugins/Translations/Controllers/UITranslationController.cs b/BTCPayServer/Plugins/Translations/Controllers/UITranslationController.cs
index 4e3e760..3b0c785 100644
--- a/BTCPayServer/Plugins/Translations/Controllers/UITranslationController.cs
+++ b/BTCPayServer/Plugins/Translations/Controllers/UITranslationController.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Data.Common;
using System.Linq;
using System.Threading.Tasks;
@@ -25,63 +26,99 @@ public class UITranslationController(
{
public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
- [HttpGet("server/dictionaries")]
- public async Task<IActionResult> ListDictionaries()
+ [HttpGet("server/translations")]
+ public async Task<IActionResult> ListTranslations()
{
- var dictionaries = await localizer.GetDictionaries();
- var vm = new ListDictionariesViewModel();
- var downloadableLanguages = LanguagePackUpdateService.GetDownloadableLanguages();
+ var translations = await localizer.GetTranslations();
+ LanguagePackUpdateService.LanguageManifestEntry[] manifestLanguages;
+ var degradedMode = false;
+ try
+ {
+ manifestLanguages = await languagePackUpdateService.GetManifestLanguages();
+ }
+ catch (Exception)
+ {
+ manifestLanguages = [];
+ degradedMode = true;
+ }
+ var manifestByName = new Dictionary<string, LanguagePackUpdateService.LanguageManifestEntry>(StringComparer.OrdinalIgnoreCase);
+ foreach (var entry in manifestLanguages)
+ manifestByName.TryAdd(entry.Name, entry);
+ var installedNames = new HashSet<string>(translations.Select(t => t.TranslationName), StringComparer.OrdinalIgnoreCase);
+ var vm = new ListTranslationsViewModel
+ {
+ ManifestFetchFailed = degradedMode
+ };
- foreach (var dictionary in dictionaries)
+ foreach (var translation in translations)
{
- var isSelected = policiesSettings.LangDictionary == dictionary.DictionaryName ||
- (policiesSettings.LangDictionary is null && dictionary.Source == "Default");
- var isDownloadedPack = downloadableLanguages.Contains(dictionary.DictionaryName);
+ var isSelected = policiesSettings.LangTranslation == translation.TranslationName ||
+ (policiesSettings.LangTranslation is null && translation.Source == "Default");
+ var isDownloadedPack = translation.Source == "LanguagePack";
var updateAvailable = false;
+ manifestByName.TryGetValue(translation.TranslationName, out var manifestEntry);
- if (isDownloadedPack && dictionary.Source == "Custom")
- {
- updateAvailable = await languagePackUpdateService.CheckForLanguagePackUpdateCached(dictionary.DictionaryName, dictionary.Metadata);
- }
+ if (!degradedMode && isDownloadedPack)
+ updateAvailable = await languagePackUpdateService.CheckForLanguagePackUpdateCached(translation.TranslationName, translation.Metadata);
- var dict = new ListDictionariesViewModel.DictionaryViewModel
+ var translationVm = new ListTranslationsViewModel.TranslationViewModel
{
- Editable = dictionary.Source == "Custom",
- Source = dictionary.Source,
- DictionaryName = dictionary.DictionaryName,
- Fallback = dictionary.Fallback,
+ Installed = true,
+ Editable = translation.Source == "Custom",
+ Source = translation.Source,
+ TranslationName = translation.TranslationName,
+ NativeName = manifestEntry?.Native ?? translation.TranslationName,
+ MaintainerHandle = manifestEntry?.MaintainerHandle,
+ MaintainerUrl = manifestEntry?.MaintainerUrl,
+ LastUpdated = manifestEntry?.Updated,
+ Fallback = translation.Fallback,
IsSelected = isSelected,
- IsDownloadedLanguagePack = isDownloadedPack && dictionary.Source == "Custom",
+ IsDownloadedLanguagePack = isDownloadedPack,
UpdateAvailable = updateAvailable
};
if (isSelected)
- vm.Dictionaries.Insert(0, dict);
+ vm.InstalledLanguages.Insert(0, translationVm);
else
- vm.Dictionaries.Add(dict);
+ vm.InstalledLanguages.Add(translationVm);
+ }
+
+ if (!degradedMode)
+ {
+ foreach (var manifestEntry in manifestLanguages.Where(m => !installedNames.Contains(m.Name)).OrderBy(m => m.Name))
+ {
+ vm.AvailableToInstall.Add(new ListTranslationsViewModel.TranslationViewModel
+ {
+ TranslationName = manifestEntry.Name,
+ NativeName = manifestEntry.Native ?? manifestEntry.Name,
+ MaintainerHandle = manifestEntry.MaintainerHandle,
+ MaintainerUrl = manifestEntry.MaintainerUrl,
+ LastUpdated = manifestEntry.Updated
+ });
+ }
}
return View(vm);
}
- [HttpGet("server/dictionaries/create")]
- public async Task<IActionResult> CreateDictionary(string fallback = null)
+ [HttpGet("server/translations/create")]
+ public async Task<IActionResult> CreateTranslation(string fallback = null)
{
- var dictionaries = await localizer.GetDictionaries();
- return View(new CreateDictionaryViewModel
+ var translations = await localizer.GetTranslations();
+ return View(new CreateTranslationViewModel
{
Name = fallback is not null ? $"Clone of {fallback}" : "",
Fallback = fallback ?? Translations.DefaultLanguage,
- }.SetDictionaries(dictionaries));
+ }.SetTranslations(translations));
}
- [HttpPost("server/dictionaries/create")]
- public async Task<IActionResult> CreateDictionary(CreateDictionaryViewModel viewModel)
+ [HttpPost("server/translations/create")]
+ public async Task<IActionResult> CreateTranslation(CreateTranslationViewModel viewModel)
{
if (ModelState.IsValid)
{
try
{
- await localizer.CreateDictionary(viewModel.Name, viewModel.Fallback, "Custom");
+ await localizer.CreateTranslation(viewModel.Name, viewModel.Fallback, "Custom");
}
catch (DbException)
{
@@ -90,29 +127,29 @@ public class UITranslationController(
}
if (!ModelState.IsValid)
- return View(viewModel.SetDictionaries(await localizer.GetDictionaries()));
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Dictionary created"].Value;
- return RedirectToAction(nameof(EditDictionary), new { dictionary = viewModel.Name });
+ return View(viewModel.SetTranslations(await localizer.GetTranslations()));
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Translation created"].Value;
+ return RedirectToAction(nameof(EditTranslation), new { translation = viewModel.Name });
}
- [HttpGet("server/dictionaries/{dictionary}")]
- public async Task<IActionResult> EditDictionary(string dictionary)
+ [HttpGet("server/translations/{translation}")]
+ public async Task<IActionResult> EditTranslation(string translation)
{
- if ((await localizer.GetDictionary(dictionary)) is null)
+ if ((await localizer.GetTranslation(translation)) is null)
return NotFound();
- var translations = await localizer.GetTranslations(dictionary);
- return View(new EditDictionaryViewModel().SetTranslations(translations.Translations));
+ var translations = await localizer.GetTranslations(translation);
+ return View(new EditTranslationViewModel().SetTranslations(translations.Translations));
}
- [HttpPost("server/dictionaries/{dictionary}")]
- public async Task<IActionResult> EditDictionary(string dictionary, EditDictionaryViewModel viewModel)
+ [HttpPost("server/translations/{translation}")]
+ public async Task<IActionResult> EditTranslation(string translation, EditTranslationViewModel viewModel)
{
- var d = await localizer.GetDictionary(dictionary);
+ var d = await localizer.GetTranslation(translation);
if (d is null)
return NotFound();
if (environment.CheatMode && viewModel.Command == "Fake")
{
- var t = await localizer.GetTranslations(dictionary);
+ var t = await localizer.GetTranslations(translation);
var jobj = JObject.Parse(t.Translations.ToJsonFormat());
foreach (var prop in jobj.Properties())
{
@@ -132,57 +169,54 @@ public class UITranslationController(
}
await localizer.Save(d, translations);
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Dictionary updated"].Value;
- return RedirectToAction(nameof(ListDictionaries));
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Translation updated"].Value;
+ return RedirectToAction(nameof(ListTranslations));
}
- [HttpPost("server/dictionaries/{dictionary}/select")]
- public async Task<IActionResult> SelectDictionary(string dictionary)
+ [HttpPost("server/translations/{translation}/select")]
+ public async Task<IActionResult> SelectTranslation(string translation)
{
- if ((await localizer.GetDictionary(dictionary)) is null)
+ if ((await localizer.GetTranslation(translation)) is null)
return NotFound();
var settings = await settingsRepository.GetSettingAsync<PoliciesSettings>() ?? new();
- settings.LangDictionary = dictionary;
+ settings.LangTranslation = translation;
await settingsRepository.UpdateSetting(settings);
await localizer.Load();
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Default dictionary changed to {0}", dictionary].Value;
- return RedirectToAction(nameof(ListDictionaries));
- }
-
- [HttpPost("server/dictionaries/{dictionary}/delete")]
- public async Task<IActionResult> DeleteDictionary(string dictionary)
- {
- await localizer.DeleteDictionary(dictionary);
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Dictionary {0} deleted", dictionary].Value;
- return RedirectToAction(nameof(ListDictionaries));
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Default translation changed to {0}", translation].Value;
+ return RedirectToAction(nameof(ListTranslations));
}
- [HttpPost("server/dictionaries/download")]
+ [HttpPost("server/translations/download")]
public async Task<IActionResult> DownloadLanguagePack(string language)
{
if (string.IsNullOrEmpty(language))
{
TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Please select a language"].Value;
- return RedirectToAction(nameof(ListDictionaries));
+ return RedirectToAction(nameof(ListTranslations));
}
- string translationsJson;
+ Translations translations;
string version;
try
{
- (translationsJson, version) = await languagePackUpdateService.FetchLanguagePackFromRepository(language);
+ var result = await languagePackUpdateService.FetchLanguagePackFromRepository(language);
+ version = result.Item2;
+ if (!Translations.TryCreateFromJson(result.Item1, out translations))
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Downloaded language pack is invalid"].Value;
+ return RedirectToAction(nameof(ListTranslations));
+ }
}
catch (Exception ex)
{
TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Failed to download language pack: {0}", ex.Message].Value;
- return RedirectToAction(nameof(ListDictionaries));
+ return RedirectToAction(nameof(ListTranslations));
}
- var translations = Translations.CreateFromJson(translationsJson);
- var existingDictionary = await localizer.GetDictionary(language);
- if (existingDictionary is null)
+ var existingTranslation = await localizer.GetTranslation(language);
+ if (existingTranslation is null)
{
- existingDictionary = await localizer.CreateDictionary(language, Translations.DefaultLanguage, "Custom");
+ existingTranslation = await localizer.CreateTranslation(language, Translations.DefaultLanguage, "LanguagePack");
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Language pack '{0}' downloaded successfully", language].Value;
}
else
@@ -190,9 +224,43 @@ public class UITranslationController(
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Language pack '{0}' updated successfully", language].Value;
}
- await localizer.Save(existingDictionary, translations);
+ await localizer.Save(existingTranslation, translations);
await localizer.UpdateVersion(language, version);
languagePackUpdateService.InvalidateCache(language);
- return RedirectToAction(nameof(ListDictionaries));
+ return RedirectToAction(nameof(ListTranslations));
+ }
+
+ [HttpPost("server/translations/{translation}/uninstall")]
+ public async Task<IActionResult> UninstallLanguagePack(string translation)
+ {
+ var existing = await localizer.GetTranslation(translation);
+ if (existing is null)
+ return NotFound();
+ if (existing.Source != "LanguagePack" && existing.Source != "Custom")
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Translation {0} is not user-installed and cannot be uninstalled", translation].Value;
+ return RedirectToAction(nameof(ListTranslations));
+ }
+ if (policiesSettings.LangTranslation == translation)
+ {
+ TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Translation {0} is the currently selected one and cannot be uninstalled", translation].Value;
+ return RedirectToAction(nameof(ListTranslations));
+ }
+ await localizer.DeleteTranslation(translation);
+ TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Translation {0} deleted", translation].Value;
+ return RedirectToAction(nameof(ListTranslations));
+ }
+
+ [HttpGet("server/dictionaries")]
+ public IActionResult RedirectToTranslation()
+ {
+ return RedirectToActionPermanent(nameof(ListTranslations));
+ }
+
+ [Route("server/dictionaries/{**catchall}")]
+ public IActionResult RedirectDictionariesSubpath(string catchall)
+ {
+ var newPath = "/server/translations" + (string.IsNullOrEmpty(catchall) ? "" : $"/{catchall}");
+ return RedirectPermanentPreserveMethod(newPath + Request.QueryString);
}
}
diff --git a/BTCPayServer/Plugins/Translations/LanguagePackUpdateService.cs b/BTCPayServer/Plugins/Translations/LanguagePackUpdateService.cs
index a03cd55..5e858da 100644
--- a/BTCPayServer/Plugins/Translations/LanguagePackUpdateService.cs
+++ b/BTCPayServer/Plugins/Translations/LanguagePackUpdateService.cs
@@ -1,106 +1,154 @@
+#nullable enable
using System;
-using System.Collections.Concurrent;
+using System.Globalization;
using System.Linq;
using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text;
using System.Threading.Tasks;
+using Microsoft.Extensions.Caching.Memory;
+using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.Translations
{
- public class LanguagePackUpdateService(IHttpClientFactory httpClientFactory)
+ public class LanguagePackUpdateService(IHttpClientFactory httpClientFactory, IMemoryCache memoryCache)
{
- private readonly ConcurrentDictionary<string, (bool UpdateAvailable, DateTime CheckedAt)> _updateCheckCache = new();
- private readonly TimeSpan _cacheExpiration = TimeSpan.FromHours(1);
-
- public static string[] GetDownloadableLanguages()
+ public record LanguageManifestEntry(
+ string Name,
+ string? Native,
+ string? MaintainerHandle,
+ string? MaintainerUrl,
+ DateTimeOffset? Updated,
+ string File,
+ string Sha)
{
- return new[]
+ internal static LanguageManifestEntry FromDto(ManifestLanguageDto dto)
{
- "Dutch",
- "French",
- "German",
- "Hindi",
- "Indonesian",
- "Italian",
- "Japanese",
- "Norwegian",
- "Korean",
- "Portuguese (Brazil)",
- "Russian",
- "Serbian",
- "Spanish",
- "Thai",
- "Turkish"
- };
- }
+ var (handle, url) = SplitMaintainer(dto.Maintainer);
+ DateTimeOffset? updated = null;
+ if (DateTimeOffset.TryParse(dto.Updated, CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal, out var parsed))
+ updated = parsed;
+ return new LanguageManifestEntry(
+ dto.Name ?? string.Empty,
+ dto.Native,
+ handle,
+ url,
+ updated,
+ dto.File ?? string.Empty,
+ dto.Sha ?? string.Empty);
+ }
- public async Task<(string translationsJson, string version)> FetchLanguagePackFromRepository(string language)
- {
- if (!GetDownloadableLanguages().Contains(language))
+ private static (string? Handle, string? Url) SplitMaintainer(string? raw)
{
- throw new ArgumentException($"Language '{language}' is not a valid downloadable language pack.", nameof(language));
+ if (string.IsNullOrEmpty(raw)) return (null, null);
+ var split = raw.Split('|', 2);
+ return (split[0], split.Length > 1 ? split[1] : null);
}
+ }
+
+ internal record ManifestLanguageDto(
+ string? Name,
+ string? Native,
+ string? Maintainer,
+ string? Updated,
+ string? File,
+ string? Sha);
+
+ internal record ManifestRootDto(ManifestLanguageDto[]? Languages);
- var fileName = Uri.EscapeDataString(language.ToLowerInvariant());
- var url = $"https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/translations/{fileName}.json";
+ private const string ManifestCacheKey = "translations.manifest";
+ private const string ManifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
+ private const string RawBaseUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/";
+ private static readonly TimeSpan CacheLifetime = TimeSpan.FromHours(1);
- var httpClient = httpClientFactory.CreateClient();
+ private async Task<LanguageManifestEntry[]> GetEntries()
+ {
+ if (memoryCache.TryGetValue(ManifestCacheKey, out LanguageManifestEntry[]? cached) && cached is not null)
+ return cached;
+
+ using var httpClient = httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(30);
+ var json = await httpClient.GetStringAsync(ManifestUrl);
- var translationsJson = await httpClient.GetStringAsync(url);
+ var root = JsonConvert.DeserializeObject<ManifestRootDto>(json);
+ if (root?.Languages is null)
+ throw new InvalidOperationException("Manifest is missing the 'Languages' array.");
- var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(translationsJson));
- var version = Convert.ToHexString(hash);
+ var entries = root.Languages
+ .Select(LanguageManifestEntry.FromDto)
+ .Where(e => !string.IsNullOrEmpty(e.Name))
+ .ToArray();
- return (translationsJson, version);
+ memoryCache.Set(ManifestCacheKey, entries, CacheLifetime);
+ return entries;
}
- public async Task<bool> CheckForLanguagePackUpdateCached(string language, JObject metadata)
+ public Task<LanguageManifestEntry[]> GetManifestLanguages() => GetEntries();
+
+ public async Task<(string translationsJson, string version)> FetchLanguagePackFromRepository(string language)
{
- var cacheKey = language;
+ var entries = await GetEntries();
+ var entry = entries.FirstOrDefault(e =>
+ string.Equals(e.Name, language, StringComparison.OrdinalIgnoreCase))
+ ?? throw new ArgumentException($"Language '{language}' was not found in the manifest.", nameof(language));
- if (_updateCheckCache.TryGetValue(cacheKey, out var cached))
- {
- if (DateTime.UtcNow - cached.CheckedAt < _cacheExpiration)
- {
- return cached.UpdateAvailable;
- }
- }
+ if (string.IsNullOrEmpty(entry.File))
+ throw new InvalidOperationException("Manifest entry is missing the 'File' field.");
+ if (string.IsNullOrEmpty(entry.Sha))
+ throw new InvalidOperationException("Manifest entry is missing the 'Sha' field.");
- var updateAvailable = await CheckForLanguagePackUpdate(language, metadata);
- _updateCheckCache[cacheKey] = (updateAvailable, DateTime.UtcNow);
+ using var httpClient = httpClientFactory.CreateClient();
+ httpClient.Timeout = TimeSpan.FromSeconds(30);
+ var translationsBytes = await httpClient.GetByteArrayAsync(RawBaseUrl + entry.File);
+
+ var actualSha = Convert.ToHexString(SHA256.HashData(translationsBytes));
+ if (!string.Equals(actualSha, entry.Sha, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException(
+ $"Downloaded language pack '{language}' SHA-256 mismatch: expected {entry.Sha}, got {actualSha}. The download may be corrupt or tampered with.");
+
+ return (Encoding.UTF8.GetString(translationsBytes), entry.Sha);
+ }
+
+ private static string UpdateCacheKey(string language) => $"translations.update.{language}";
+ public async Task<bool> CheckForLanguagePackUpdateCached(string language, JObject metadata)
+ {
+ if (memoryCache.TryGetValue<bool>(UpdateCacheKey(language), out var cached))
+ return cached;
+
+ var updateAvailable = await CheckForLanguagePackUpdate(language, metadata);
+ memoryCache.Set(UpdateCacheKey(language), updateAvailable, CacheLifetime);
return updateAvailable;
}
public void InvalidateCache(string language)
{
- _updateCheckCache.TryRemove(language, out _);
+ memoryCache.Remove(UpdateCacheKey(language));
}
private async Task<bool> CheckForLanguagePackUpdate(string language, JObject metadata)
{
try
{
- if (!GetDownloadableLanguages().Contains(language))
- {
+ var entries = await GetEntries();
+ var entry = entries.FirstOrDefault(e =>
+ string.Equals(e.Name, language, StringComparison.OrdinalIgnoreCase));
+ if (entry is null || string.IsNullOrEmpty(entry.Sha))
return false;
- }
-
- var (_, remoteVersion) = await FetchLanguagePackFromRepository(language);
var localVersion = metadata["version"]?.ToString();
-
if (string.IsNullOrEmpty(localVersion))
return true;
- return remoteVersion != localVersion;
- }
- catch (HttpRequestException)
- {
- return false;
+ return !string.Equals(entry.Sha, localVersion, StringComparison.OrdinalIgnoreCase);
}
- catch (TaskCanceledException)
+ catch (Exception ex) when (
+ ex is HttpRequestException
+ || ex is TaskCanceledException
+ || ex is InvalidOperationException
+ || ex is JsonException)
{
return false;
}
diff --git a/BTCPayServer/Plugins/Translations/LoadTranslationsStartupTask.cs b/BTCPayServer/Plugins/Translations/LoadTranslationsStartupTask.cs
index 4f86f82..b8f8f6f 100644
--- a/BTCPayServer/Plugins/Translations/LoadTranslationsStartupTask.cs
+++ b/BTCPayServer/Plugins/Translations/LoadTranslationsStartupTask.cs
@@ -24,7 +24,7 @@ namespace BTCPayServer.Plugins.Translations
public ILogger<LoadTranslationsStartupTask> Logger { get; } = logger;
public LocalizerService LocalizerService { get; } = localizerService;
- class DictionaryFileMetadata
+ class TranslationFileMetadata
{
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
[JsonProperty("hash")]
@@ -42,29 +42,29 @@ namespace BTCPayServer.Plugins.Translations
if (files.Length > 0)
{
Logger.LogInformation("Loading language files...");
- var dictionaries = await LocalizerService.GetDictionaries();
+ var availableTranslations = await LocalizerService.GetTranslations();
foreach (var file in files)
{
var langName = Path.GetFileName(file);
- var dictionary = dictionaries.FirstOrDefault(d => d.DictionaryName == langName);
- if (dictionary is null)
- dictionary = await LocalizerService.CreateDictionary(langName, null, "File");
- if (dictionary.Source != "File")
+ var translation = availableTranslations.FirstOrDefault(t => t.TranslationName == langName);
+ if (translation is null)
+ translation = await LocalizerService.CreateTranslation(langName, null, "File");
+ if (translation.Source != "File")
{
Logger.LogWarning($"Impossible to load language '{langName}', as it is already existing in the database, not initially imported by a File");
continue;
}
- var savedHash = dictionary.Metadata.ToObject<DictionaryFileMetadata>().Hash;
+ var savedHash = translation.Metadata.ToObject<TranslationFileMetadata>().Hash;
var translations = Translations.CreateFromJson(await File.ReadAllTextAsync(file, cancellationToken));
var currentHash = new uint256(SHA256.HashData(Encoding.UTF8.GetBytes(translations.ToJsonFormat())));
if (savedHash != currentHash)
{
- var newMetadata = (JObject)dictionary.Metadata.DeepClone();
+ var newMetadata = (JObject)translation.Metadata.DeepClone();
newMetadata["hash"] = currentHash.ToString();
- dictionary = dictionary with { Metadata = newMetadata };
- Logger.LogInformation($"Updating dictionary '{langName}'");
- await LocalizerService.Save(dictionary, translations);
+ translation = translation with { Metadata = newMetadata };
+ Logger.LogInformation($"Updating translation '{langName}'");
+ await LocalizerService.Save(translation, translations);
}
}
}
diff --git a/BTCPayServer/Plugins/Translations/LocalizerService.cs b/BTCPayServer/Plugins/Translations/LocalizerService.cs
index 87c66db..76fcf42 100644
--- a/BTCPayServer/Plugins/Translations/LocalizerService.cs
+++ b/BTCPayServer/Plugins/Translations/LocalizerService.cs
@@ -37,7 +37,7 @@ namespace BTCPayServer.Plugins.Translations
{
try
{
- _LoadedTranslations = await GetTranslations(settingsAccessor.Settings.LangDictionary);
+ _LoadedTranslations = await GetTranslations(settingsAccessor.Settings.LangTranslation);
}
catch (Exception ex)
{
@@ -46,7 +46,7 @@ namespace BTCPayServer.Plugins.Translations
}
}
- public async Task<LoadedTranslations> GetTranslations(string dictionaryName)
+ public async Task<LoadedTranslations> GetTranslations(string translationName)
{
await using var ctx = contextFactory.CreateContext();
var conn = ctx.Database.GetDbConnection();
@@ -56,7 +56,7 @@ namespace BTCPayServer.Plugins.Translations
"SELECT 't'::BOOL fallback, sentence, translation FROM translations WHERE dict_id=(SELECT fallback FROM lang_dictionaries WHERE dict_id=@dict_id)",
new
{
- dict_id = dictionaryName,
+ dict_id = translationName,
});
var defaultDict = Translations.Default;
var loading = defaultTranslationProviders.Select(d => d.GetDefaultTranslations()).ToArray();
@@ -71,12 +71,12 @@ namespace BTCPayServer.Plugins.Translations
defaultDict = new Translations(additionalDefault, defaultDict);
var fallback = new Translations(all.Where(a => a.fallback).Select(o => KeyValuePair.Create(o.sentence, o.translation)), defaultDict);
var translations = new Translations(all.Where(a => !a.fallback).Select(o => KeyValuePair.Create(o.sentence, o.translation)), fallback);
- return new LoadedTranslations(translations, fallback, dictionaryName);
+ return new LoadedTranslations(translations, fallback, translationName);
}
- public async Task Save(Dictionary dictionary, Translations translations)
+ public async Task Save(Translation translation, Translations translations)
{
- var loadedTranslations = await GetTranslations(dictionary.DictionaryName);
+ var loadedTranslations = await GetTranslations(translation.TranslationName);
translations = translations.WithFallback(loadedTranslations.Fallback);
await using var ctx = contextFactory.CreateContext();
var diffs = loadedTranslations.Translations.CalculateDiff(translations);
@@ -86,9 +86,9 @@ namespace BTCPayServer.Plugins.Translations
List<string> values = new List<string>();
// The basic idea here is that we can remove from
- // the dictionary any translations which are the same
+ // the translation any translations which are the same
// as the fallback. This way, if the fallback gets updated,
- // it will also update the dictionary.
+ // it will also update the translation.
foreach (var diff in diffs)
{
if (diff is Translations.Diff.Added a)
@@ -134,45 +134,45 @@ namespace BTCPayServer.Plugins.Translations
_LoadedTranslations = loadedTranslations with { Translations = translations };
}
- public record Dictionary(string DictionaryName, string? Fallback, string Source, JObject Metadata);
- public async Task<Dictionary[]> GetDictionaries()
+ public record Translation(string TranslationName, string? Fallback, string Source, JObject Metadata);
+ public async Task<Translation[]> GetTranslations()
{
await using var ctx = contextFactory.CreateContext();
var db = ctx.Database.GetDbConnection();
var rows = await db.QueryAsync<(string dict_id, string? fallback, string? source, string? metadata)>("SELECT * FROM lang_dictionaries");
- return rows.Select(r => new Dictionary(r.dict_id, r.fallback, r.source ?? "", JObject.Parse(r.metadata ?? "{}"))).ToArray();
+ return rows.Select(r => new Translation(r.dict_id, r.fallback, r.source ?? "", JObject.Parse(r.metadata ?? "{}"))).ToArray();
}
- public async Task<Dictionary?> GetDictionary(string name)
+ public async Task<Translation?> GetTranslation(string name)
{
await using var ctx = contextFactory.CreateContext();
var db = ctx.Database.GetDbConnection();
var r = await db.QueryFirstOrDefaultAsync("SELECT * FROM lang_dictionaries WHERE dict_id=@dict_id", new { dict_id = name });
if (r is null)
return null;
- return new Dictionary(r.dict_id, r.fallback, r.source ?? "", JObject.Parse(r.metadata ?? "{}"));
+ return new Translation(r.dict_id, r.fallback, r.source ?? "", JObject.Parse(r.metadata ?? "{}"));
}
- public async Task<Dictionary> CreateDictionary(string langName, string? fallback, string source)
+ public async Task<Translation> CreateTranslation(string langName, string? fallback, string source)
{
await using var ctx = contextFactory.CreateContext();
var db = ctx.Database.GetDbConnection();
await db.ExecuteAsync("INSERT INTO lang_dictionaries (dict_id, fallback, source) VALUES (@langName, @fallback, @source)", new { langName, fallback, source });
- return new Dictionary(langName, fallback, source ?? "", new JObject());
+ return new Translation(langName, fallback, source ?? "", new JObject());
}
- public async Task DeleteDictionary(string dictionary)
+ public async Task DeleteTranslation(string translationName)
{
await using var ctx = contextFactory.CreateContext();
var db = ctx.Database.GetDbConnection();
- await db.ExecuteAsync("DELETE FROM lang_dictionaries WHERE dict_id=@dict_id AND source='Custom'", new { dict_id = dictionary });
+ await db.ExecuteAsync("DELETE FROM lang_dictionaries WHERE dict_id=@dict_id AND source='Custom'", new { dict_id = translationName });
}
- public async Task UpdateVersion(string dictionary, string version)
+ public async Task UpdateVersion(string translationName, string version)
{
await using var ctx = contextFactory.CreateContext();
var db = ctx.Database.GetDbConnection();
await db.ExecuteAsync("UPDATE lang_dictionaries SET metadata = jsonb_set(COALESCE(metadata, '{}'::jsonb), '{version}', to_jsonb(@version::text)) WHERE dict_id = @dict_id",
- new { dict_id = dictionary, version });
+ new { dict_id = translationName, version });
}
}
}
diff --git a/BTCPayServer/Plugins/Translations/Translations.Default.cs b/BTCPayServer/Plugins/Translations/Translations.Default.cs
index f57fd85..0a3c61c 100644
--- a/BTCPayServer/Plugins/Translations/Translations.Default.cs
+++ b/BTCPayServer/Plugins/Translations/Translations.Default.cs
@@ -224,6 +224,7 @@ namespace BTCPayServer.Plugins.Translations
"Available Payment Methods": "",
"Available placeholders: <code>{StoreName} {ItemDescription} {OrderId}</code>": "",
"Available Plugins": "",
+ "Available to install": "",
"Awaiting": "",
"Azure Blob Storage": "",
"Backend's language": "",
@@ -299,7 +300,6 @@ namespace BTCPayServer.Plugins.Translations
"Checkout Description": "",
"Checkout Experience": "",
"Choose a different offering for monetization": "",
- "Choose a language...": "",
"Choose Point of Sale Style": "",
"Choose what event sends the email.": "",
"choose your file storage service provider": "",
@@ -382,6 +382,7 @@ namespace BTCPayServer.Plugins.Translations
"Copy Tor URL": "",
"Core Lightning {0}": "",
"Could not access your camera. Is it already in use?": "",
+ "Could not fetch the community language manifest. Showing installed languages only (degraded mode).": "",
"Could not generate invoice: {0}": "",
"Could not load log files": "",
"Could not save CSS file: {0}": "",
@@ -411,6 +412,7 @@ namespace BTCPayServer.Plugins.Translations
"Create New Token": "",
"Create Payment Request": "",
"Create pending transaction": "",
+ "Create PSBT": "",
"Create Pull Payment": "",
"Create refund": "",
"Create Request": "",
@@ -444,6 +446,7 @@ namespace BTCPayServer.Plugins.Translations
"Current password": "",
"Current Rates source is": "",
"Currently active!": "",
+ "Currently enabled on this server.": "",
"Custom": "",
"Custom amount": "",
"Custom Amount": "",
@@ -469,7 +472,6 @@ namespace BTCPayServer.Plugins.Translations
"Default": "",
"Default currency": "",
"Default Currency Pairs": "",
- "Default dictionary changed to {0}": "",
"Default Include NonWitness Utxo in PSBTs": "",
"Default language on checkout": "",
"Default Payment Method": "",
@@ -477,6 +479,7 @@ namespace BTCPayServer.Plugins.Translations
"Default role for users on a new store": "",
"Default store template": "",
"Default Tax Rate": "",
+ "Default translation changed to {0}": "",
"Default Value": "",
"Delay": "",
"Delete": "",
@@ -485,7 +488,6 @@ namespace BTCPayServer.Plugins.Translations
"Delete admin": "",
"Delete API key": "",
"Delete app": "",
- "Delete dictionary": "",
"Delete label": "",
"Delete LND seed": "",
"Delete LND seed from server": "",
@@ -512,12 +514,6 @@ namespace BTCPayServer.Plugins.Translations
"Determine the generated invoice amount": "",
"Determine the generated invoice currency": "",
"Device found: {0}": "",
- "Dictionaries": "",
- "Dictionaries enable you to translate the BTCPay Server backend into different languages.": "",
- "Dictionary": "",
- "Dictionary {0} deleted": "",
- "Dictionary created": "",
- "Dictionary updated": "",
"Direct integration": "",
"Disable": "",
"DISABLE": "",
@@ -564,11 +560,9 @@ namespace BTCPayServer.Plugins.Translations
"Donate": "",
"Done": "",
"Downgrade": "",
- "Download": "",
"Download a two-factor authenticator app like …": "",
- "Download language pack": "",
- "Download Language Pack": "",
"Download PSBT file": "",
+ "Downloaded language pack is invalid": "",
"Dynamic": "",
"Dynamic DNS allows you to have a stable DNS name pointing to your server, even if your IP address changes regularly. This is recommended if you are hosting BTCPay Server at home and wish to have a clearnet domain to access your server.": "",
"Dynamic DNS Service": "",
@@ -675,7 +669,6 @@ namespace BTCPayServer.Plugins.Translations
"Failed to archive the app.": "",
"Failed to download language pack: {0}": "",
"Failed to unarchive the app.": "",
- "Fallback": "",
"Fallback rate source": "",
"Fallback rates will be used in case the primary rates are not available.": "",
"Feature disabled": "",
@@ -784,10 +777,11 @@ namespace BTCPayServer.Plugins.Translations
"I wrote down my recovery codes": "",
"Id": "",
"ID": "",
- "If a translation isn’t available in the new dictionary, it will be searched in the fallback.": "",
+ "If a translation isn’t available, it will be searched in the fallback.": "",
"If authorized, the generated API key will be provided to": "",
"If BTCPay Server shows you an invalid balance, {0}.<br />If some transactions appear in BTCPay Server, but are missing in another wallet, {1}.": "",
"If checked, each private key associated with an address generated will be stored as metadata and would be accessible to anyone with admin access to your server. Enable at your own risk!": "",
+ "If some transactions appear in BTCPay Server, but are missing in another wallet, {0}.": "",
"If you change your configured storage provider, your current files will become inaccessible.": "",
"If you do not understand the above, go with the defaults and start scanning.": "",
"If you lose it or write it down incorrectly, you may permanently lose access to your funds.": "",
@@ -822,6 +816,7 @@ namespace BTCPayServer.Plugins.Translations
"Inputs": "",
"Insert your security device and proceed.": "",
"Install": "",
+ "Installed languages": "",
"Installed Plugins": "",
"Instructions": "",
"Interval": "",
@@ -835,7 +830,6 @@ namespace BTCPayServer.Plugins.Translations
"Invalid email": "",
"Invalid email address or placeholder detected": "",
"Invalid file name": "",
- "Invalid keypath": "",
"Invalid login attempt.": "",
"Invalid network": "",
"Invalid passphrase confirmation": "",
@@ -886,6 +880,7 @@ namespace BTCPayServer.Plugins.Translations
"Label name cannot be empty.": "",
"Label:": "",
"Labels": "",
+ "Language": "",
"Language pack '{0}' downloaded successfully": "",
"Language pack '{0}' updated successfully": "",
"Last 24 hours": "",
@@ -942,6 +937,7 @@ namespace BTCPayServer.Plugins.Translations
"Logout": "",
"Logs": "",
"Mails": "",
+ "Maintainer": "",
"Maintenance": "",
"Maintenance feature requires access to SSH properly configured in BTCPay Server configuration.": "",
"Make Crowdfund Public": "",
@@ -1025,6 +1021,7 @@ namespace BTCPayServer.Plugins.Translations
"NFC detected.": "",
"No": "",
"No access tokens yet.": "",
+ "No action needed": "",
"No claim made yet.": "",
"No contributions allowed after the goal has been reached": "",
"No contributions have been made yet.": "",
@@ -1035,6 +1032,7 @@ namespace BTCPayServer.Plugins.Translations
"No end date has been set": "",
"No expiry date has been set for this payment request": "",
"No invoice has been selected": "",
+ "No languages available to install.": "",
"No matching payment method": "",
"No payment handler found for this payment method": "",
"No payment request related email rules have been configured for this store.": "",
@@ -1244,6 +1242,7 @@ namespace BTCPayServer.Plugins.Translations
"Port": "",
"Powered by": "",
"Preferred Price Source": "",
+ "Present in the upstream translation repository but not yet on this server.": "",
"Prev": "",
"Preview": "",
"Price": "",
@@ -1350,7 +1349,7 @@ namespace BTCPayServer.Plugins.Translations
"Remove store template": "",
"Remove store user": "",
"Remove subscriber": "",
- "Remove the translation from this dictionary.": "",
+ "Remove the entry from this translation.": "",
"Remove wallet": "",
"Removing this user would result in the store having no owner.": "",
"Renewable": "",
@@ -1458,7 +1457,6 @@ namespace BTCPayServer.Plugins.Translations
"Segwit wrapped (Compatible with old wallets)": "",
"Select": "",
"Select a field to edit": "",
- "Select a language to download from the community translation repository.": "",
"Select a preset": "",
"Select All": "",
"Select an existing offering": "",
@@ -1466,7 +1464,6 @@ namespace BTCPayServer.Plugins.Translations
"Select default plan": "",
"Select existing offering": "",
"Select labels": "",
- "Select Language": "",
"Select offering": "",
"Select plan": "",
"Select store": "",
@@ -1784,7 +1781,6 @@ namespace BTCPayServer.Plugins.Translations
"There are no webhooks yet.": "",
"There isn't any UTXO available to bump fee with CPFP": "",
"There was an error generating your wallet: {0}": "",
- "These translations are maintained by the community at": "",
"This account has been locked out because of multiple invalid login attempts. Please try again later.": "",
"This account has been locked out. Please try again": "",
"This action is permanent and will remove the ability to change the SSH settings via the BTCPay Server user interface.": "",
@@ -1809,7 +1805,6 @@ namespace BTCPayServer.Plugins.Translations
"This crowdfund page is not publicly viewable!": "",
"This device already signed PSBT.": "",
"This device can't sign the transaction. (The wallet keypath in your wallet settings seems incorrect)": "",
- "This dictionary will be removed from this server.": "",
"This feature is disabled": "",
"This feature is only available to BTC wallets": "",
"This full node does not support rescan of the UTXO set": "",
@@ -1844,6 +1839,7 @@ namespace BTCPayServer.Plugins.Translations
"This store will still be accessible to users sharing it": "",
"This transaction can't be RBF'd": "",
"This transaction will change your balance:": "",
+ "This translation will be removed from this server.": "",
"This version of NBXplorer is not compatible. Please update to 2.5.22 or above": "",
"This webhook will be removed from this store.": "",
"This will approve the user <strong>{0}</strong>.": "",
@@ -1872,7 +1868,7 @@ namespace BTCPayServer.Plugins.Translations
"To start using Pay Button, you need to enable this feature explicitly. Once you do so, anyone could create an invoice on your store (via API, for example).": "",
"To test your settings, enter an email address": "",
"To use an authenticator app go through the following steps:": "",
- "To use the translation from this dictionary's fallback, you can:": "",
+ "To use this translation's fallback, you can:": "",
"Toggle passphrase visibility": "",
"Toggle password visibility": "",
"Toggle seed visibility": "",
@@ -1897,8 +1893,14 @@ namespace BTCPayServer.Plugins.Translations
"Transaction successfully signed": "",
"transactions": "",
"Transactions": "",
+ "Translation {0} deleted": "",
+ "Translation {0} is not user-installed and cannot be uninstalled": "",
+ "Translation {0} is the currently selected one and cannot be uninstalled": "",
+ "Translation created": "",
+ "Translation updated": "",
"Translations": "",
"Translations are formatted as JSON; for example, <b>{0}</b> translates <b>{1}</b> to <b>{2}</b>.": "",
+ "Translations enable you to translate the BTCPay Server backend into different languages.": "",
"Trial": "",
"Trial Period": "",
"Trial Period (days)": "",
@@ -1922,8 +1924,8 @@ namespace BTCPayServer.Plugins.Translations
"Unify on-chain and lightning payment URL/QR code": "",
"Uninstall": "",
"Uninstall all disabled plugins": "",
+ "Uninstall translation": "",
"Unknown": "",
- "Unknown command": "",
"Unknown username": "",
"Unmark test account": "",
"Unnamed Store": "",
diff --git a/BTCPayServer/Plugins/Translations/TranslationsPlugin.cs b/BTCPayServer/Plugins/Translations/TranslationsPlugin.cs
index 36b2fb7..13feeed 100644
--- a/BTCPayServer/Plugins/Translations/TranslationsPlugin.cs
+++ b/BTCPayServer/Plugins/Translations/TranslationsPlugin.cs
@@ -32,7 +32,7 @@ public class TranslationsPlugin : BaseBTCPayServerPlugin
{
RequiredPolicy = Policies.CanModifyServerSettings,
Title = "Localize the user interface",
- Action = nameof(UITranslationController.ListDictionaries),
+ Action = nameof(UITranslationController.ListTranslations),
Controller = "UITranslation",
Values = _ => new { area = Area },
Category = "Server",
diff --git a/BTCPayServer/Plugins/Translations/Views/CreateDictionary.cshtml b/BTCPayServer/Plugins/Translations/Views/CreateDictionary.cshtml
deleted file mode 100644
index e49d7ff..0000000
--- a/BTCPayServer/Plugins/Translations/Views/CreateDictionary.cshtml
+++ /dev/null
@@ -1,36 +0,0 @@
-@using BTCPayServer.Views.Server
-@model CreateDictionaryViewModel
-@{
- ViewData.SetLayoutModel(new LayoutModel(nameof(ServerNavPages.Translations), "Create a new dictionary")
- .SetCategory(WellKnownCategories.Server));
-}
-<form method="post">
- <div class="sticky-header">
- <nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item">
- <a asp-action="ListDictionaries" text-translate="true">Dictionaries</a>
- </li>
- <li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
- </ol>
- <h2>@ViewData["Title"]</h2>
- </nav>
- <input id="page-primary" type="submit" value="Create" class="btn btn-primary" />
- </div>
-
- <div class="row">
- <div class="col-xl-8 col-xxl-constrain">
- <div class="form-group">
- <label asp-for="Name" class="form-label" text-translate="true"></label>
- <input asp-for="Name" class="form-control" />
- <span asp-validation-for="Name" class="text-danger"></span>
- </div>
- <div class="form-group">
- <label asp-for="Fallback" class="form-label" text-translate="true"></label>
- <select asp-for="Fallback" class="form-select w-auto" asp-items="@Model.DictionariesListItems"></select>
- <span asp-validation-for="Fallback" class="text-danger"></span>
- <div class="form-text" text-translate="true">If a translation isn’t available in the new dictionary, it will be searched in the fallback.</div>
- </div>
- </div>
- </div>
-</form>
diff --git a/BTCPayServer/Plugins/Translations/Views/CreateDictionaryViewModel.cs b/BTCPayServer/Plugins/Translations/Views/CreateDictionaryViewModel.cs
deleted file mode 100644
index ff48755..0000000
--- a/BTCPayServer/Plugins/Translations/Views/CreateDictionaryViewModel.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-using System.Linq;
-using Microsoft.AspNetCore.Mvc.Rendering;
-
-namespace BTCPayServer.Plugins.Translations.Views;
-public class CreateDictionaryViewModel
-{
- [Required(AllowEmptyStrings = false)]
- public string Name { get; set; }
- public string Fallback { get; set; }
- public SelectListItem[] DictionariesListItems { get; set; }
-
- internal CreateDictionaryViewModel SetDictionaries(LocalizerService.Dictionary[] dictionaries)
- {
- var items = dictionaries.Select(d => new SelectListItem(d.DictionaryName, d.DictionaryName, d.DictionaryName == Fallback)).ToArray();
- DictionariesListItems = items;
- return this;
- }
-}
diff --git a/BTCPayServer/Plugins/Translations/Views/CreateTranslation.cshtml b/BTCPayServer/Plugins/Translations/Views/CreateTranslation.cshtml
new file mode 100644
index 0000000..abbc04c
--- /dev/null
+++ b/BTCPayServer/Plugins/Translations/Views/CreateTranslation.cshtml
@@ -0,0 +1,36 @@
+@using BTCPayServer.Views.Server
+@model CreateTranslationViewModel
+@{
+ ViewData.SetLayoutModel(new LayoutModel(nameof(ServerNavPages.Translations), "Create a new translation")
+ .SetCategory(WellKnownCategories.Server));
+}
+<form method="post">
+ <div class="sticky-header">
+ <nav aria-label="breadcrumb">
+ <ol class="breadcrumb">
+ <li class="breadcrumb-item">
+ <a asp-action="ListTranslations" text-translate="true">Translations</a>
+ </li>
+ <li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
+ </ol>
+ <h2>@ViewData["Title"]</h2>
+ </nav>
+ <input id="page-primary" type="submit" value="Create" class="btn btn-primary" />
+ </div>
+
+ <div class="row">
+ <div class="col-xl-8 col-xxl-constrain">
+ <div class="form-group">
+ <label asp-for="Name" class="form-label" text-translate="true"></label>
+ <input asp-for="Name" class="form-control" />
+ <span asp-validation-for="Name" class="text-danger"></span>
+ </div>
+ <div class="form-group">
+ <label asp-for="Fallback" class="form-label" text-translate="true"></label>
+ <select asp-for="Fallback" class="form-select w-auto" asp-items="@Model.TranslationsListItems"></select>
+ <span asp-validation-for="Fallback" class="text-danger"></span>
+ <div class="form-text" text-translate="true">If a translation isn’t available, it will be searched in the fallback.</div>
+ </div>
+ </div>
+ </div>
+</form>
diff --git a/BTCPayServer/Plugins/Translations/Views/CreateTranslationViewModel.cs b/BTCPayServer/Plugins/Translations/Views/CreateTranslationViewModel.cs
new file mode 100644
index 0000000..91d22d7
--- /dev/null
+++ b/BTCPayServer/Plugins/Translations/Views/CreateTranslationViewModel.cs
@@ -0,0 +1,19 @@
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace BTCPayServer.Plugins.Translations.Views;
+public class CreateTranslationViewModel
+{
+ [Required(AllowEmptyStrings = false)]
+ public string Name { get; set; }
+ public string Fallback { get; set; }
+ public SelectListItem[] TranslationsListItems { get; set; }
+
+ internal CreateTranslationViewModel SetTranslations(LocalizerService.Translation[] translations)
+ {
+ var items = translations.Select(d => new SelectListItem(d.TranslationName, d.TranslationName, d.TranslationName == Fallback)).ToArray();
+ TranslationsListItems = items;
+ return this;
+ }
+}
diff --git a/BTCPayServer/Plugins/Translations/Views/EditDictionary.cshtml b/BTCPayServer/Plugins/Translations/Views/EditDictionary.cshtml
deleted file mode 100644
index 645c9b4..0000000
--- a/BTCPayServer/Plugins/Translations/Views/EditDictionary.cshtml
+++ /dev/null
@@ -1,49 +0,0 @@
-@using BTCPayServer.Views.Server
-@model EditDictionaryViewModel
-@{
- ViewData.SetLayoutModel(new LayoutModel(nameof(ServerNavPages.Translations), Context.GetRouteValue("dictionary") as string)
- .SetCategory(WellKnownCategories.Server));
-}
-
-<form method="post" class="d-flex flex-column">
- <div class="sticky-header">
- <nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item">
- <a asp-action="ListDictionaries" text-translate="true">Dictionaries</a>
- </li>
- <li class="breadcrumb-item active" aria-current="page" text-translate="true">@ViewData["Title"]</li>
- </ol>
- <h2 text-translate="true">@ViewData["Title"]</h2>
- </nav>
- <div>
- <button cheat-mode="true" class="btn btn-outline-info" name="command" value="Fake" text-translate="true">Fill fake</button>
- <button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save" text-translate="true">Save</button>
- </div>
- </div>
- <partial name="_StatusMessage" />
-
- <div class="d-flex mb-4">
- <div class="flex-fill">
- <p>@ViewLocalizer["Translations are formatted as JSON; for example, <b>{0}</b> translates <b>{1}</b> to <b>{2}</b>.", "{ \"Welcome\": \"Bienvenue\" }", "Welcome", "Bienvenue"]</p>
- <p class="mb-0" text-translate="true">
- To use the translation from this dictionary's fallback, you can:
- </p>
- <ul>
- <li text-translate="true">Remove the translation from this dictionary.</li>
- <li text-translate="true">Set the translation to match the string in the fallback.</li>
- </ul>
- <p class="mb-0" text-translate="true">Please note that not all text is translatable, and future updates may modify existing translations or introduce new translatable phrases.</p>
- </div>
- </div>
-
- <div class="row">
- <div class="col-xl-8 col-xxl-constrain d-flex flex-column">
- <div class="form-group">
- <label asp-for="Translations" class="form-label"></label>
- <textarea asp-for="Translations" class="form-control translation-editor" rows="@Model.Lines" cols="40"></textarea>
- <span asp-validation-for="Translations" class="text-danger"></span>
- </div>
- </div>
- </div>
-</form>
diff --git a/BTCPayServer/Plugins/Translations/Views/EditDictionaryViewModel.cs b/BTCPayServer/Plugins/Translations/Views/EditDictionaryViewModel.cs
deleted file mode 100644
index a079bb1..0000000
--- a/BTCPayServer/Plugins/Translations/Views/EditDictionaryViewModel.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-
-namespace BTCPayServer.Plugins.Translations.Views;
-
-public class EditDictionaryViewModel
-{
- [Display(Name = "Translations")]
- public string Translations { get; set; }
- public int Lines { get; set; }
- public string Command { get; set; }
-
- internal EditDictionaryViewModel SetTranslations(Translations translations)
- {
- Translations = translations.ToJsonFormat();
- Lines = translations.Records.Count;
- return this;
- }
-}
diff --git a/BTCPayServer/Plugins/Translations/Views/EditTranslation.cshtml b/BTCPayServer/Plugins/Translations/Views/EditTranslation.cshtml
new file mode 100644
index 0000000..5bf8242
--- /dev/null
+++ b/BTCPayServer/Plugins/Translations/Views/EditTranslation.cshtml
@@ -0,0 +1,49 @@
+@using BTCPayServer.Views.Server
+@model EditTranslationViewModel
+@{
+ ViewData.SetLayoutModel(new LayoutModel(nameof(ServerNavPages.Translations), Context.GetRouteValue("translation") as string)
+ .SetCategory(WellKnownCategories.Server));
+}
+
+<form method="post" class="d-flex flex-column">
+ <div class="sticky-header">
+ <nav aria-label="breadcrumb">
+ <ol class="breadcrumb">
+ <li class="breadcrumb-item">
+ <a asp-action="ListTranslations" text-translate="true">Translations</a>
+ </li>
+ <li class="breadcrumb-item active" aria-current="page" text-translate="true">@ViewData["Title"]</li>
+ </ol>
+ <h2 text-translate="true">@ViewData["Title"]</h2>
+ </nav>
+ <div>
+ <button cheat-mode="true" class="btn btn-outline-info" name="command" value="Fake" text-translate="true">Fill fake</button>
+ <button id="page-primary" type="submit" class="btn btn-primary" name="command" value="Save" text-translate="true">Save</button>
+ </div>
+ </div>
+ <partial name="_StatusMessage" />
+
+ <div class="d-flex mb-4">
+ <div class="flex-fill">
+ <p>@ViewLocalizer["Translations are formatted as JSON; for example, <b>{0}</b> translates <b>{1}</b> to <b>{2}</b>.", "{ \"Welcome\": \"Bienvenue\" }", "Welcome", "Bienvenue"]</p>
+ <p class="mb-0" text-translate="true">
+ To use this translation's fallback, you can:
+ </p>
+ <ul>
+ <li text-translate="true">Remove the entry from this translation.</li>
+ <li text-translate="true">Set the translation to match the string in the fallback.</li>
+ </ul>
+ <p class="mb-0" text-translate="true">Please note that not all text is translatable, and future updates may modify existing translations or introduce new translatable phrases.</p>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-xl-8 col-xxl-constrain d-flex flex-column">
+ <div class="form-group">
+ <label asp-for="Translations" class="form-label"></label>
+ <textarea asp-for="Translations" class="form-control translation-editor" rows="@Model.Lines" cols="40"></textarea>
+ <span asp-validation-for="Translations" class="text-danger"></span>
+ </div>
+ </div>
+ </div>
+</form>
diff --git a/BTCPayServer/Plugins/Translations/Views/EditTranslationViewModel.cs b/BTCPayServer/Plugins/Translations/Views/EditTranslationViewModel.cs
new file mode 100644
index 0000000..61a95ab
--- /dev/null
+++ b/BTCPayServer/Plugins/Translations/Views/EditTranslationViewModel.cs
@@ -0,0 +1,18 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace BTCPayServer.Plugins.Translations.Views;
+
+public class EditTranslationViewModel
+{
+ [Display(Name = "Translations")]
+ public string Translations { get; set; }
+ public int Lines { get; set; }
+ public string Command { get; set; }
+
+ internal EditTranslationViewModel SetTranslations(Translations translations)
+ {
+ Translations = translations.ToJsonFormat();
+ Lines = translations.Records.Count;
+ return this;
+ }
+}
diff --git a/BTCPayServer/Plugins/Translations/Views/ListDictionaries.cshtml b/BTCPayServer/Plugins/Translations/Views/ListDictionaries.cshtml
deleted file mode 100644
index b1cad03..0000000
--- a/BTCPayServer/Plugins/Translations/Views/ListDictionaries.cshtml
+++ /dev/null
@@ -1,121 +0,0 @@
-@using BTCPayServer.Views.Server
-@model ListDictionariesViewModel
-@{
- ViewData.SetLayoutModel(new LayoutModel(nameof(ServerNavPages.Translations), StringLocalizer["Dictionaries"])
- .SetCategory(WellKnownCategories.Server));
-}
-
-<div class="sticky-header">
- <h2>@ViewData["Title"]</h2>
- <div class="d-flex gap-2">
- <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#downloadLanguagePackModal" text-translate="true">
- Download language pack
- </button>
- <a id="page-primary" asp-action="CreateDictionary" class="btn btn-primary" role="button" text-translate="true">
- Create
- </a>
- </div>
-</div>
-<partial name="_StatusMessage" />
-
-<p class="mb-0" text-translate="true">
- Dictionaries enable you to translate the BTCPay Server backend into different languages.
-</p>
-
-<div class="table-responsive">
- <table class="table table-hover">
- <thead>
- <tr>
- <th text-translate="true">Dictionary</th>
- <th text-translate="true">Fallback</th>
- <th class="actions-col"></th>
- </tr>
- </thead>
- <tbody>
- @foreach (var v in Model.Dictionaries)
- {
- <tr>
- <td>
- <div class="d-flex flex-wrap align-items-center gap-2">
- @if (!v.Editable)
- {
- <span >@v.DictionaryName</span>
- }
- else
- {
- <a asp-action="EditDictionary" asp-route-dictionary="@v.DictionaryName">@v.DictionaryName</a>
- }
- @if (v.IsSelected)
- {
- <span class="badge bg-info" text-translate="true">
- In use
- </span>
- }
- </div>
- </td>
- <td>@v.Fallback</td>
- <td class="actions-col">
- <div class="d-inline-flex align-items-center gap-3">
- @if (v.IsDownloadedLanguagePack && v.UpdateAvailable)
- {
- <form method="post" asp-action="DownloadLanguagePack" asp-route-language="@v.DictionaryName" class="d-inline">
- <button type="submit" class="btn btn-link p-0">Update</button>
- </form>
- }
- <a asp-action="CreateDictionary" asp-route-fallback="@v.DictionaryName" text-translate="true">Clone</a>
- @if (!v.IsSelected)
- {
- <form method="post" asp-action="SelectDictionary" asp-route-dictionary="@v.DictionaryName" class="d-inline">
- <button id="Select-@v.DictionaryName" type="submit" class="btn btn-link p-0" text-translate="true">Select
- </button>
- </form>
- }
- @if (v.Editable && !v.IsSelected)
- {
- <a id="Delete-@v.DictionaryName" asp-action="DeleteDictionary" asp-route-dictionary="@v.DictionaryName" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="The dictionary <b>@Html.Encode(v.DictionaryName)</b> will be removed from this server." data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove</a>
- }
- </div>
- </td>
- </tr>
- }
- </tbody>
- </table>
- <partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Delete dictionary"], StringLocalizer["This dictionary will be removed from this server."], StringLocalizer["Delete"]))" />
-</div>
-
-@* Download Language Pack Modal *@
-<div class="modal fade" id="downloadLanguagePackModal" tabindex="-1" role="dialog" aria-labelledby="downloadLanguagePackModalTitle" aria-hidden="true" data-bs-backdrop="static">
- <div class="modal-dialog modal-dialog-centered" role="document">
- <form method="post" asp-action="DownloadLanguagePack">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title" id="downloadLanguagePackModalTitle" text-translate="true">Download Language Pack</h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
- <vc:icon symbol="close" />
- </button>
- </div>
- <div class="modal-body">
- <p text-translate="true">Select a language to download from the community translation repository.</p>
- <p class="mb-3">
- <span text-translate="true">These translations are maintained by the community at</span>
- <a href="https://github.com/btcpayserver/btcpayserver-translator" target="_blank" rel="noopener noreferrer">btcpayserver-translator</a>.
- </p>
- <div class="form-group">
- <label for="language" class="form-label" text-translate="true">Select Language</label>
- <select id="language" name="language" class="form-select" required>
- <option value="" text-translate="true">Choose a language...</option>
- @foreach (var lang in LanguagePackUpdateService.GetDownloadableLanguages())
- {
- <option value="@lang">@lang</option>
- }
- </select>
- </div>
- </div>
- <div class="modal-footer">
- <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" text-translate="true">Cancel</button>
- <button type="submit" class="btn btn-primary" text-translate="true">Download</button>
- </div>
- </div>
- </form>
- </div>
-</div>
diff --git a/BTCPayServer/Plugins/Translations/Views/ListDictionariesViewModel.cs b/BTCPayServer/Plugins/Translations/Views/ListDictionariesViewModel.cs
deleted file mode 100644
index 94d31a5..0000000
--- a/BTCPayServer/Plugins/Translations/Views/ListDictionariesViewModel.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System.Collections.Generic;
-
-namespace BTCPayServer.Plugins.Translations.Views;
-
-public class ListDictionariesViewModel
-{
- public class DictionaryViewModel
- {
- public string DictionaryName { get; set; }
- public string Fallback { get; set; }
- public string Source { get; set; }
- public bool Editable { get; set; }
- public bool IsSelected { get; set; }
- public bool IsDownloadedLanguagePack { get; set; }
- public bool UpdateAvailable { get; set; }
- }
-
- public List<DictionaryViewModel> Dictionaries = [];
-}
diff --git a/BTCPayServer/Plugins/Translations/Views/ListTranslations.cshtml b/BTCPayServer/Plugins/Translations/Views/ListTranslations.cshtml
new file mode 100644
index 0000000..ab6effb
--- /dev/null
+++ b/BTCPayServer/Plugins/Translations/Views/ListTranslations.cshtml
@@ -0,0 +1,208 @@
+@using BTCPayServer.Views.Server
+@model ListTranslationsViewModel
+@{
+ ViewData.SetLayoutModel(new LayoutModel(nameof(ServerNavPages.Translations), StringLocalizer["Translations"])
+ .SetCategory(WellKnownCategories.Server));
+}
+
+<div class="sticky-header">
+ <h2>@ViewData["Title"]</h2>
+ <div class="d-flex gap-2">
+ <a id="page-primary" asp-action="CreateTranslation" class="btn btn-primary" role="button" text-translate="true">
+ Create
+ </a>
+ </div>
+</div>
+<partial name="_StatusMessage" />
+
+<p class="mb-0" text-translate="true">
+ Translations enable you to translate the BTCPay Server into different languages.
+</p>
+
+@if (Model.ManifestFetchFailed)
+{
+ <div class="alert alert-warning mt-3" role="alert">
+ <span text-translate="true">Could not fetch the community language manifest. Showing installed languages only (degraded mode).</span>
+ </div>
+}
+
+<h4 class="mt-4 mb-3" text-translate="true">Installed languages</h4>
+<p class="text-muted" text-translate="true">Currently enabled on this server.</p>
+<div class="table-responsive">
+ <table class="table table-hover align-middle">
+ <colgroup>
+ <col style="width: 40%;" />
+ <col style="width: 24%;" />
+ <col style="width: 20%;" />
+ <col style="width: 16%;" />
+ </colgroup>
+ <thead>
+ <tr>
+ <th text-translate="true">Language</th>
+ <th text-translate="true">Maintainer</th>
+ <th text-translate="true">Last updated</th>
+ <th class="actions-col" text-translate="true">Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var v in Model.InstalledLanguages)
+ {
+ <tr>
+ <td>
+ <div class="d-flex flex-wrap align-items-center gap-2">
+ @if (v.Editable)
+ {
+ <a asp-action="EditTranslation" asp-route-translation="@v.TranslationName">@v.TranslationName</a>
+ }
+ else
+ {
+ <span>@v.TranslationName</span>
+ }
+ @if (!string.IsNullOrEmpty(v.NativeName) && v.NativeName != v.TranslationName)
+ {
+ <span class="text-muted">(@v.NativeName)</span>
+ }
+ @if (v.IsSelected)
+ {
+ <span class="badge bg-info" text-translate="true">In use</span>
+ }
+ </div>
+ </td>
+ <td>
+ @if (!string.IsNullOrEmpty(v.MaintainerHandle))
+ {
+ @if (!string.IsNullOrEmpty(v.MaintainerUrl))
+ {
+ <a href="@v.MaintainerUrl" target="_blank" rel="noopener noreferrer">@v.MaintainerHandle</a>
+ }
+ else
+ {
+ <span>@v.MaintainerHandle</span>
+ }
+ }
+ else
+ {
+ <span>-</span>
+ }
+ </td>
+ <td>
+ @if (v.LastUpdated is not null)
+ {
+ <span>@v.LastUpdated.Value.ToBrowserDate()</span>
+ }
+ else
+ {
+ <span>-</span>
+ }
+ </td>
+ <td class="actions-col">
+ <div class="d-inline-flex align-items-center gap-3">
+ @if (v.IsDownloadedLanguagePack && v.UpdateAvailable)
+ {
+ <form method="post" asp-action="DownloadLanguagePack" asp-route-language="@v.TranslationName" class="d-inline">
+ <button type="submit" class="btn btn-link p-0" text-translate="true">Update</button>
+ </form>
+ }
+ @if (!v.IsSelected)
+ {
+ <form method="post" asp-action="SelectTranslation" asp-route-translation="@v.TranslationName" class="d-inline">
+ <button id="Select-@v.TranslationName" type="submit" class="btn btn-link p-0" text-translate="true">Select</button>
+ </form>
+ }
+ @if (!v.IsSelected && v.Editable)
+ {
+ <a id="Delete-@v.TranslationName"
+ asp-action="UninstallLanguagePack"
+ asp-route-translation="@v.TranslationName"
+ data-bs-toggle="modal"
+ data-bs-target="#ConfirmModal"
+ data-description="The translation <b>@Html.Encode(v.TranslationName)</b> will be removed from this server."
+ data-confirm-input="@StringLocalizer["Delete"]"
+ class="btn btn-link p-0 text-danger"
+ text-translate="true">Uninstall</a>
+ }
+ </div>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+</div>
+
+<h4 class="mt-4 mb-3" text-translate="true">Available to install</h4>
+<p class="text-muted" text-translate="true">Present in the upstream translation repository but not yet on this server.</p>
+<div class="table-responsive">
+ <table class="table table-hover align-middle">
+ <colgroup>
+ <col style="width: 40%;" />
+ <col style="width: 24%;" />
+ <col style="width: 20%;" />
+ <col style="width: 16%;" />
+ </colgroup>
+ <thead>
+ <tr>
+ <th text-translate="true">Language</th>
+ <th text-translate="true">Maintainer</th>
+ <th text-translate="true">Last updated</th>
+ <th class="actions-col" text-translate="true">Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ @if (Model.AvailableToInstall.Count == 0)
+ {
+ <tr>
+ <td colspan="4" class="text-muted" text-translate="true">No languages available to install.</td>
+ </tr>
+ }
+ else
+ {
+ @foreach (var v in Model.AvailableToInstall)
+ {
+ <tr>
+ <td>
+ <span>@v.TranslationName</span>
+ @if (!string.IsNullOrEmpty(v.NativeName) && v.NativeName != v.TranslationName)
+ {
+ <span class="text-muted">(@v.NativeName)</span>
+ }
+ </td>
+ <td>
+ @if (!string.IsNullOrEmpty(v.MaintainerHandle))
+ {
+ @if (!string.IsNullOrEmpty(v.MaintainerUrl))
+ {
+ <a href="@v.MaintainerUrl" target="_blank" rel="noopener noreferrer">@v.MaintainerHandle</a>
+ }
+ else
+ {
+ <span>@v.MaintainerHandle</span>
+ }
+ }
+ else
+ {
+ <span>-</span>
+ }
+ </td>
+ <td>
+ @if (v.LastUpdated is not null)
+ {
+ <span>@v.LastUpdated.Value.ToBrowserDate()</span>
+ }
+ else
+ {
+ <span>-</span>
+ }
+ </td>
+ <td class="actions-col">
+ <form method="post" asp-action="DownloadLanguagePack" asp-route-language="@v.TranslationName" class="d-inline">
+ <button type="submit" class="btn btn-link p-0" text-translate="true">Install</button>
+ </form>
+ </td>
+ </tr>
+ }
+ }
+ </tbody>
+ </table>
+</div>
+
+<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Uninstall translation"], StringLocalizer["This translation will be removed from this server."], StringLocalizer["Delete"]))" />
diff --git a/BTCPayServer/Plugins/Translations/Views/ListTranslationsViewModel.cs b/BTCPayServer/Plugins/Translations/Views/ListTranslationsViewModel.cs
new file mode 100644
index 0000000..6b86738
--- /dev/null
+++ b/BTCPayServer/Plugins/Translations/Views/ListTranslationsViewModel.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Collections.Generic;
+
+namespace BTCPayServer.Plugins.Translations.Views;
+
+public class ListTranslationsViewModel
+{
+ public class TranslationViewModel
+ {
+ public bool Installed { get; set; }
+ public string TranslationName { get; set; }
+ public string NativeName { get; set; }
+ public string MaintainerHandle { get; set; }
+ public string MaintainerUrl { get; set; }
+ public DateTimeOffset? LastUpdated { get; set; }
+ public string Fallback { get; set; }
+ public string Source { get; set; }
+ public bool Editable { get; set; }
+ public bool IsSelected { get; set; }
+ public bool IsDownloadedLanguagePack { get; set; }
+ public bool UpdateAvailable { get; set; }
+ }
+
+ public List<TranslationViewModel> InstalledLanguages { get; set; } = [];
+ public List<TranslationViewModel> AvailableToInstall { get; set; } = [];
+ public bool ManifestFetchFailed { get; set; }
+}
diff --git a/BTCPayServer/Services/PoliciesSettings.cs b/BTCPayServer/Services/PoliciesSettings.cs
index f768aa4..5d592b9 100644
--- a/BTCPayServer/Services/PoliciesSettings.cs
+++ b/BTCPayServer/Services/PoliciesSettings.cs
@@ -27,9 +27,9 @@ namespace BTCPayServer.Services
}
[DefaultValue("English")]
- [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
+ [JsonProperty("LangDictionary", DefaultValueHandling = DefaultValueHandling.Populate)]
[Display(Name = "Backend's language")]
- public string LangDictionary { get; set; } = "English";
+ public string LangTranslation { get; set; } = "English";
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
[Display(Name = "Admin must approve new users")]
diff --git a/BTCPayServer/Views/UIServer/Policies.cshtml b/BTCPayServer/Views/UIServer/Policies.cshtml
index b57d418..48dd1df 100644
--- a/BTCPayServer/Views/UIServer/Policies.cshtml
+++ b/BTCPayServer/Views/UIServer/Policies.cshtml
@@ -139,14 +139,14 @@
<div class="mb-5">
<h4 class="mb-3" text-translate="true">Server</h4>
<div class="form-group">
- <label asp-for="LangDictionary" class="form-label"></label>
- <select asp-for="LangDictionary"
- asp-items="@(new SelectList(ViewBag.LangDictionaries, nameof(SelectListItem.Value), nameof(SelectListItem.Text), Model.LangDictionary))"
+ <label asp-for="LangTranslation" class="form-label"></label>
+ <select asp-for="LangTranslation"
+ asp-items="@(new SelectList(ViewBag.LangTranslations, nameof(SelectListItem.Value), nameof(SelectListItem.Text), Model.LangTranslation))"
class="form-select">
</select>
- <span asp-validation-for="LangDictionary" class="text-danger"></span>
+ <span asp-validation-for="LangTranslation" class="text-danger"></span>
<div class="form-text">
- <span text-translate="true">Add or customize translations</span> <a asp-area="@TranslationsPlugin.Area" asp-controller="UITranslation" asp-action="ListDictionaries" text-translate="true">here</a>.
+ <span text-translate="true">Add or customize translations</span> <a asp-area="@TranslationsPlugin.Area" asp-controller="UITranslation" asp-action="ListTranslations" text-translate="true">here</a>.
</div>
</div>
<div class="form-group d-flex">
Why this scored 60/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.