mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2024-11-19 01:43:50 +01:00
Allow translations of BTCPay Server Backend by admins (#5662)
This commit is contained in:
parent
acbc75d077
commit
ca4abcb497
@ -9,6 +9,7 @@ namespace BTCPayServer.Configuration
|
||||
public string TempStorageDir { get; set; }
|
||||
public string StorageDir { get; set; }
|
||||
public string TempDir { get; set; }
|
||||
public string LangsDir { get; set; }
|
||||
|
||||
public string ToDatadirFullPath(string path)
|
||||
{
|
||||
|
@ -0,0 +1,61 @@
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BTCPayServer.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20231219031609_translationsmigration")]
|
||||
public partial class translationsmigration : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
if (migrationBuilder.IsNpgsql())
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
CREATE TABLE lang_dictionaries (
|
||||
dict_id TEXT PRIMARY KEY,
|
||||
fallback TEXT DEFAULT NULL,
|
||||
source TEXT DEFAULT NULL,
|
||||
metadata JSONB DEFAULT NULL,
|
||||
FOREIGN KEY (fallback) REFERENCES lang_dictionaries(dict_id) ON UPDATE CASCADE ON DELETE SET NULL
|
||||
);
|
||||
INSERT INTO lang_dictionaries(dict_id, source) VALUES ('English', 'Default');
|
||||
|
||||
CREATE TABLE lang_translations (
|
||||
dict_id TEXT NOT NULL,
|
||||
sentence TEXT NOT NULL,
|
||||
translation TEXT NOT NULL,
|
||||
PRIMARY KEY (dict_id, sentence),
|
||||
FOREIGN KEY (dict_id) REFERENCES lang_dictionaries(dict_id) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE VIEW translations AS
|
||||
WITH RECURSIVE translations_with_paths AS (
|
||||
SELECT d.dict_id, t.sentence, t.translation, ARRAY[d.dict_id] AS path FROM lang_translations t
|
||||
INNER JOIN lang_dictionaries d USING (dict_id)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT d.dict_id, t.sentence, t.translation, d.dict_id || t.path FROM translations_with_paths t
|
||||
INNER JOIN lang_dictionaries d ON d.fallback=t.dict_id
|
||||
),
|
||||
ranked_translations AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY dict_id, sentence ORDER BY array_length(path, 1)) AS rn
|
||||
FROM translations_with_paths
|
||||
)
|
||||
SELECT dict_id, sentence, translation, path FROM ranked_translations WHERE rn=1;
|
||||
COMMENT ON VIEW translations IS 'Compute the translation for all sentences for all dictionaries, taking into account fallbacks';
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
@ -21,10 +21,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="TestData\OldInvoices.csv" />
|
||||
<None Remove="TestData\Langs\Cypherpunk" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="TestData\OldInvoices.csv" />
|
||||
<Content Include="TestData\Langs\Cypherpunk" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -39,8 +39,7 @@ namespace BTCPayServer.Tests
|
||||
|
||||
public class BTCPayServerTester : IDisposable
|
||||
{
|
||||
private readonly string _Directory;
|
||||
|
||||
internal readonly string _Directory;
|
||||
public ILoggerProvider LoggerProvider { get; }
|
||||
|
||||
ILog TestLogs;
|
||||
@ -95,6 +94,13 @@ namespace BTCPayServer.Tests
|
||||
get; set;
|
||||
}
|
||||
|
||||
public async Task RestartStartupTask<T>()
|
||||
{
|
||||
var startupTask = GetService<IServiceProvider>().GetServices<Abstractions.Contracts.IStartupTask>()
|
||||
.Single(task => task is T);
|
||||
await startupTask.ExecuteAsync();
|
||||
}
|
||||
|
||||
public bool MockRates { get; set; } = true;
|
||||
public string SocksEndpoint { get; set; }
|
||||
|
||||
|
@ -1,8 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Hosting;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Tests.Logging;
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Support.Extensions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using static BTCPayServer.Services.LocalizerService;
|
||||
|
||||
namespace BTCPayServer.Tests
|
||||
{
|
||||
@ -14,6 +25,155 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
[Trait("Selenium", "Selenium")]
|
||||
public async Task CanTranslateLoginPage()
|
||||
{
|
||||
using var tester = CreateSeleniumTester(newDb: true);
|
||||
tester.Server.ActivateLangs();
|
||||
await tester.StartAsync();
|
||||
await tester.Server.PayTester.RestartStartupTask<LoadTranslationsStartupTask>();
|
||||
|
||||
// Check if the Cypherpunk translation has been loaded from the file
|
||||
tester.RegisterNewUser(true);
|
||||
tester.CreateNewStore();
|
||||
tester.GoToServer(Views.Server.ServerNavPages.Translations);
|
||||
tester.Driver.FindElement(By.Id("Select-Cypherpunk")).Click();
|
||||
tester.Logout();
|
||||
Assert.Contains("Cyphercode", tester.Driver.PageSource);
|
||||
Assert.Contains("Yo at BTCPay Server", tester.Driver.PageSource);
|
||||
|
||||
// Create English (Custom)
|
||||
tester.LogIn();
|
||||
tester.GoToServer(Views.Server.ServerNavPages.Translations);
|
||||
tester.Driver.FindElement(By.Id("CreateDictionary")).Click();
|
||||
tester.Driver.FindElement(By.Name("Name")).SendKeys("English (Custom)");
|
||||
tester.Driver.FindElement(By.Id("Create")).Click();
|
||||
var translations = tester.Driver.FindElement(By.Name("Translations"));
|
||||
var text = translations.Text;
|
||||
text = text.Replace("Password => Password", "Password => Mot de passe");
|
||||
translations.Clear();
|
||||
translations.SendKeys("Password => Mot de passe");
|
||||
tester.Driver.FindElement(By.Id("SaveButton")).Click();
|
||||
|
||||
// Check English (Custom) can be selected
|
||||
tester.Driver.FindElement(By.Id("Select-English (Custom)")).Click();
|
||||
tester.Logout();
|
||||
Assert.Contains("Mot de passe", tester.Driver.PageSource);
|
||||
|
||||
// Check if we can remove English (Custom)
|
||||
tester.LogIn();
|
||||
tester.GoToServer(Views.Server.ServerNavPages.Translations);
|
||||
text = tester.Driver.PageSource;
|
||||
Assert.Contains("Select-Cypherpunk", text);
|
||||
Assert.DoesNotContain("Select-English (Custom)", text);
|
||||
// Cypherpunk is loaded from file, can't edit
|
||||
Assert.DoesNotContain("Delete-Cypherpunk", text);
|
||||
// English (Custom) is selected, can't edit
|
||||
Assert.DoesNotContain("Delete-English (Custom)", text);
|
||||
tester.Driver.FindElement(By.Id("Select-Cypherpunk")).Click();
|
||||
tester.Driver.FindElement(By.Id("Delete-English (Custom)")).Click();
|
||||
tester.Driver.WaitForElement(By.Id("ConfirmInput")).SendKeys("DELETE");
|
||||
tester.Driver.FindElement(By.Id("ConfirmContinue")).Click();
|
||||
|
||||
text = tester.Driver.PageSource;
|
||||
Assert.DoesNotContain("Select-English (Custom)", text);
|
||||
Assert.Contains("English (Custom) deleted", text);
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanUpdateTranslationsInDatabase()
|
||||
{
|
||||
using var tester = CreateServerTester(newDb: true);
|
||||
await tester.StartAsync();
|
||||
var localizer = tester.PayTester.GetService<LocalizerService>();
|
||||
var factory = tester.PayTester.GetService<ApplicationDbContextFactory>();
|
||||
var db = factory.CreateContext().Database.GetDbConnection();
|
||||
|
||||
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)
|
||||
{
|
||||
var dict = await localizer.GetDictionary(dictId);
|
||||
var t = new Translations(translations.Select(t => KeyValuePair.Create(t.Sentence, t.Translation)));
|
||||
await localizer.Save(dict, t);
|
||||
}
|
||||
async Task AssertTranslations(string dictionary, (string Sentence, string Expected)[] expectations)
|
||||
{
|
||||
var all = await db.QueryAsync<(string sentence, string translation)>($"SELECT sentence, translation from translations WHERE dict_id='{dictionary}'");
|
||||
foreach (var expectation in expectations)
|
||||
{
|
||||
if (expectation.Expected is not null)
|
||||
Assert.Equal(expectation.Expected, all.Single(a => a.sentence == expectation.Sentence).translation);
|
||||
else
|
||||
Assert.DoesNotContain(all, a => a.sentence == expectation.Sentence);
|
||||
}
|
||||
}
|
||||
|
||||
await SetDictionary("English",
|
||||
[
|
||||
("Hello", "Hello"),
|
||||
("Goodbye", "Goodbye"),
|
||||
("Good afternoon", "Good afternoon")
|
||||
]);
|
||||
await SetDictionary("French",
|
||||
[
|
||||
("Hello", "Salut"),
|
||||
("Good afternoon", "Bonne aprem")
|
||||
]);
|
||||
|
||||
TestLogs.LogInformation("French should override Hello and Good afternoon, but not Goodbye");
|
||||
await AssertTranslations("French",
|
||||
[("Hello", "Salut"),
|
||||
("Good afternoon", "Bonne aprem"),
|
||||
("Goodbye", "Goodbye"),
|
||||
("lol", null)]);
|
||||
await AssertTranslations("English",
|
||||
[("Hello", "Hello"),
|
||||
("Good afternoon", "Good afternoon"),
|
||||
("Goodbye", "Goodbye"),
|
||||
("lol", null)]);
|
||||
|
||||
TestLogs.LogInformation("Can use fallback by setting null to a sentence");
|
||||
await SetDictionary("French",
|
||||
[
|
||||
("Good afternoon", "Bonne aprem"),
|
||||
("Goodbye", "Goodbye"),
|
||||
("Hello", null)
|
||||
]);
|
||||
await AssertTranslations("French",
|
||||
[("Hello", "Hello"),
|
||||
("Good afternoon", "Bonne aprem"),
|
||||
("Goodbye", "Goodbye"),
|
||||
("lol", null)]);
|
||||
|
||||
TestLogs.LogInformation("Can use fallback by setting same as fallback to a sentence");
|
||||
await SetDictionary("French",
|
||||
[
|
||||
("Good afternoon", "Good afternoon")
|
||||
]);
|
||||
await AssertTranslations("French",
|
||||
[("Hello", "Hello"),
|
||||
("Good afternoon", "Good afternoon"),
|
||||
("Goodbye", "Goodbye"),
|
||||
("lol", null)]);
|
||||
|
||||
await SetDictionary("English",
|
||||
[
|
||||
("Hello", null as string),
|
||||
("Good afternoon", "Good afternoon"),
|
||||
("Goodbye", "Goodbye")
|
||||
]);
|
||||
await AssertTranslations("French",
|
||||
[("Hello", null),
|
||||
("Good afternoon", "Good afternoon"),
|
||||
("Goodbye", "Goodbye"),
|
||||
("lol", null)]);
|
||||
await db.ExecuteAsync("DELETE FROM lang_dictionaries WHERE dict_id='English'");
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanAutoDetectLanguage()
|
||||
|
@ -396,6 +396,10 @@ retry:
|
||||
Driver.FindElement(By.Id("Nav-Logout")).Click();
|
||||
}
|
||||
|
||||
public void LogIn()
|
||||
{
|
||||
LogIn(CreatedUser, "123456");
|
||||
}
|
||||
public void LogIn(string user, string password = "123456")
|
||||
{
|
||||
Driver.FindElement(By.Id("Email")).SendKeys(user);
|
||||
|
@ -18,6 +18,8 @@ using NBitcoin;
|
||||
using NBitcoin.RPC;
|
||||
using NBitpayClient;
|
||||
using NBXplorer;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace BTCPayServer.Tests
|
||||
{
|
||||
@ -72,6 +74,17 @@ namespace BTCPayServer.Tests
|
||||
PayTester.SSHConnection = GetEnvironment("TESTS_SSHCONNECTION", "root@127.0.0.1:21622");
|
||||
PayTester.SocksEndpoint = GetEnvironment("TESTS_SOCKSENDPOINT", "localhost:9050");
|
||||
}
|
||||
|
||||
public void ActivateLangs()
|
||||
{
|
||||
TestLogs.LogInformation("Activating Langs...");
|
||||
var dir = TestUtils.GetTestDataFullPath("Langs");
|
||||
var langdir = Path.Combine(PayTester._Directory, "Langs");
|
||||
Directory.CreateDirectory(langdir);
|
||||
foreach (var file in Directory.GetFiles(dir))
|
||||
File.Copy(file, Path.Combine(langdir, Path.GetFileName(file)));
|
||||
}
|
||||
|
||||
#if ALTCOINS
|
||||
public void ActivateLTC()
|
||||
{
|
||||
|
3
BTCPayServer.Tests/TestData/Langs/Cypherpunk
Normal file
3
BTCPayServer.Tests/TestData/Langs/Cypherpunk
Normal file
@ -0,0 +1,3 @@
|
||||
Password => Cyphercode
|
||||
Email address => Cypher ID
|
||||
Welcome to {0} => Yo at {0}
|
@ -2878,9 +2878,7 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
var settings = tester.PayTester.GetService<SettingsRepository>();
|
||||
await settings.UpdateSetting<MigrationSettings>(new MigrationSettings());
|
||||
var migrationStartupTask = tester.PayTester.GetService<IServiceProvider>().GetServices<IStartupTask>()
|
||||
.Single(task => task is MigrationStartupTask);
|
||||
await migrationStartupTask.ExecuteAsync();
|
||||
await tester.PayTester.RestartStartupTask<MigrationStartupTask>();
|
||||
}
|
||||
|
||||
[Fact(Timeout = LongRunningTestTimeout)]
|
||||
|
@ -1,10 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Amazon.Auth.AccessControlPolicy;
|
||||
@ -13,6 +15,10 @@ using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers;
|
||||
using ExchangeSharp;
|
||||
using Microsoft.AspNetCore.Razor.Language;
|
||||
using Microsoft.AspNetCore.Razor.Language.Intermediate;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
@ -21,18 +27,17 @@ using OpenQA.Selenium.Chrome;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using static BTCPayServer.Tests.TransifexClient;
|
||||
|
||||
namespace BTCPayServer.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// This class hold easy to run utilities for dev time
|
||||
/// </summary>
|
||||
public class UtilitiesTests
|
||||
public class UtilitiesTests : UnitTestBase
|
||||
{
|
||||
public ITestOutputHelper Logs { get; }
|
||||
|
||||
public UtilitiesTests(ITestOutputHelper logs)
|
||||
public UtilitiesTests(ITestOutputHelper logs) : base(logs)
|
||||
{
|
||||
Logs = logs;
|
||||
}
|
||||
@ -262,6 +267,120 @@ retry:
|
||||
Thread.Sleep(200);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This utilities crawl through the cs files in search for
|
||||
/// Display attributes, then update Translations.Default to list them
|
||||
/// </summary>
|
||||
[Trait("Utilities", "Utilities")]
|
||||
[Fact]
|
||||
public async Task UpdateDefaultTranslations()
|
||||
{
|
||||
var soldir = TestUtils.TryGetSolutionDirectoryInfo();
|
||||
List<string> defaultTranslatedKeys = new List<string>();
|
||||
|
||||
// Go through all cs files, and find [Display] and [DisplayName] attributes
|
||||
foreach (var file in soldir.EnumerateFiles("*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var txt = File.ReadAllText(file.FullName);
|
||||
var tree = CSharpSyntaxTree.ParseText(txt, new CSharpParseOptions(LanguageVersion.Default));
|
||||
var walker = new DisplayNameWalker();
|
||||
walker.Visit(tree.GetRoot());
|
||||
foreach (var k in walker.Keys)
|
||||
{
|
||||
defaultTranslatedKeys.Add(k);
|
||||
}
|
||||
}
|
||||
|
||||
// Go through all cshtml file, search for text-translate or ViewLocalizer usage
|
||||
using (var tester = CreateServerTester())
|
||||
{
|
||||
await tester.StartAsync();
|
||||
var engine = tester.PayTester.GetService<RazorProjectEngine>();
|
||||
foreach (var file in soldir.EnumerateFiles("*.cshtml", SearchOption.AllDirectories))
|
||||
{
|
||||
var filePath = file.FullName;
|
||||
var txt = File.ReadAllText(file.FullName);
|
||||
if (txt.Contains("ViewLocalizer"))
|
||||
{
|
||||
var matches = Regex.Matches(txt, "ViewLocalizer\\[\"(.*?)\"[\\],]");
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
defaultTranslatedKeys.Add(match.Groups[1].Value);
|
||||
}
|
||||
}
|
||||
else if (txt.Contains("text-translate"))
|
||||
{
|
||||
filePath = filePath.Replace(Path.Combine(soldir.FullName, "BTCPayServer"), "/");
|
||||
var item = engine.FileSystem.GetItem(filePath);
|
||||
|
||||
var node = (DocumentIntermediateNode)engine.Process(item).Items[typeof(DocumentIntermediateNode)];
|
||||
foreach (var n in node.FindDescendantNodes<TagHelperIntermediateNode>())
|
||||
{
|
||||
foreach (var tagHelper in n.TagHelpers)
|
||||
{
|
||||
if (tagHelper.Name.EndsWith("TranslateTagHelper"))
|
||||
{
|
||||
var htmlContent = n.FindDescendantNodes<HtmlContentIntermediateNode>().First();
|
||||
var inner = txt.Substring(htmlContent.Source.Value.AbsoluteIndex, htmlContent.Source.Value.Length);
|
||||
defaultTranslatedKeys.Add(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
defaultTranslatedKeys = defaultTranslatedKeys.Distinct().OrderBy(o => o).ToList();
|
||||
var path = Path.Combine(soldir.FullName, "BTCPayServer/Services/Translations.Default.cs");
|
||||
var defaultTranslation = File.ReadAllText(path);
|
||||
var startIdx = defaultTranslation.IndexOf("\"\"\"");
|
||||
var endIdx = defaultTranslation.LastIndexOf("\"\"\"");
|
||||
var content = defaultTranslation.Substring(0, startIdx + 3);
|
||||
content += "\n" + String.Join('\n', defaultTranslatedKeys) + "\n";
|
||||
content += defaultTranslation.Substring(endIdx);
|
||||
File.WriteAllText(path, content);
|
||||
}
|
||||
class DisplayNameWalker : CSharpSyntaxWalker
|
||||
{
|
||||
public List<string> Keys = new List<string>();
|
||||
public bool InAttribute = false;
|
||||
public override void VisitAttribute(AttributeSyntax node)
|
||||
{
|
||||
InAttribute = true;
|
||||
base.VisitAttribute(node);
|
||||
InAttribute = false;
|
||||
}
|
||||
public override void VisitIdentifierName(IdentifierNameSyntax node)
|
||||
{
|
||||
if (InAttribute)
|
||||
{
|
||||
InAttribute = node.Identifier.Text switch
|
||||
{
|
||||
"Display" => true,
|
||||
"DisplayAttribute" => true,
|
||||
"DisplayName" => true,
|
||||
"DisplayNameAttribute" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
public override void VisitAttributeArgument(AttributeArgumentSyntax node)
|
||||
{
|
||||
if (InAttribute)
|
||||
{
|
||||
var name = node.Expression switch
|
||||
{
|
||||
LiteralExpressionSyntax les => les.Token.ValueText,
|
||||
IdentifierNameSyntax ins => ins.Identifier.Text,
|
||||
_ => throw new InvalidOperationException("Unknown node")
|
||||
};
|
||||
Keys.Add(name);
|
||||
InAttribute = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This utility will make sure that permission documentation is properly written in swagger.template.json
|
||||
/// </summary>
|
||||
|
@ -111,6 +111,9 @@
|
||||
<Watch Remove="Views\Shared\LocalhostBrowserSupport.cshtml" />
|
||||
<Watch Remove="Views\UIAccount\CheatPermissions.cshtml" />
|
||||
<Watch Remove="Views\UIReports\StoreReports.cshtml" />
|
||||
<Watch Remove="Views\UIServer\CreateDictionary.cshtml" />
|
||||
<Watch Remove="Views\UIServer\EditDictionary.cshtml" />
|
||||
<Watch Remove="Views\UIServer\ListDictionaries.cshtml" />
|
||||
<Content Update="Views\UIApps\_ViewImports.cshtml">
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||
|
@ -309,6 +309,9 @@
|
||||
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
|
||||
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Branding" class="nav-link @ViewData.ActivePageClass(ServerNavPages.Branding)" asp-action="Branding">Branding</a>
|
||||
</li>
|
||||
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
|
||||
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Translations" class="nav-link @ViewData.ActivePageClass(ServerNavPages.Translations)" asp-action="ListDictionaries">Translations</a>
|
||||
</li>
|
||||
@if (BtcPayServerOptions.DockerDeployment)
|
||||
{
|
||||
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
|
108
BTCPayServer/Controllers/UIServerController.Translations.cs
Normal file
108
BTCPayServer/Controllers/UIServerController.Translations.cs
Normal file
@ -0,0 +1,108 @@
|
||||
using System.Data.Common;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Models.ServerViewModels;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
public partial class UIServerController
|
||||
{
|
||||
[HttpGet("server/dictionaries")]
|
||||
public async Task<IActionResult> ListDictionaries()
|
||||
{
|
||||
var dictionaries = await this._localizer.GetDictionaries();
|
||||
var vm = new ListDictionariesViewModel();
|
||||
foreach (var dictionary in dictionaries)
|
||||
{
|
||||
vm.Dictionaries.Add(new()
|
||||
{
|
||||
Editable = dictionary.Source == "Custom",
|
||||
Source = dictionary.Source,
|
||||
DictionaryName = dictionary.DictionaryName,
|
||||
Fallback = dictionary.Fallback,
|
||||
IsSelected =
|
||||
_policiesSettings.LangDictionary == dictionary.DictionaryName ||
|
||||
(_policiesSettings.LangDictionary is null && dictionary.Source == "Default")
|
||||
});
|
||||
}
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
[HttpGet("server/dictionaries/create")]
|
||||
public async Task<IActionResult> CreateDictionary(string fallback = null)
|
||||
{
|
||||
var dictionaries = await this._localizer.GetDictionaries();
|
||||
return View(new CreateDictionaryViewModel()
|
||||
{
|
||||
Name = fallback is not null ? $"{fallback} (Copy)" : "",
|
||||
Fallback = fallback ?? Translations.DefaultLanguage,
|
||||
}.SetDictionaries(dictionaries));
|
||||
}
|
||||
[HttpPost("server/dictionaries/create")]
|
||||
public async Task<IActionResult> CreateDictionary(CreateDictionaryViewModel viewModel)
|
||||
{
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
try
|
||||
{
|
||||
await this._localizer.CreateDictionary(viewModel.Name, viewModel.Fallback, "Custom");
|
||||
}
|
||||
catch (DbException)
|
||||
{
|
||||
ModelState.AddModelError(nameof(viewModel.Name), $"'{viewModel.Name}' already exists");
|
||||
}
|
||||
}
|
||||
if (!ModelState.IsValid)
|
||||
return View(viewModel.SetDictionaries(await this._localizer.GetDictionaries()));
|
||||
TempData[WellKnownTempData.SuccessMessage] = "Dictionary created";
|
||||
return RedirectToAction(nameof(EditDictionary), new { dictionary = viewModel.Name });
|
||||
}
|
||||
|
||||
[HttpGet("server/dictionaries/{dictionary}")]
|
||||
public async Task<IActionResult> EditDictionary(string dictionary)
|
||||
{
|
||||
if ((await this._localizer.GetDictionary(dictionary)) is null)
|
||||
return NotFound();
|
||||
var translations = await _localizer.GetTranslations(dictionary);
|
||||
return View(new EditDictionaryViewModel().SetTranslations(translations.Translations));
|
||||
}
|
||||
|
||||
[HttpPost("server/dictionaries/{dictionary}")]
|
||||
public async Task<IActionResult> EditDictionary(string dictionary, EditDictionaryViewModel viewModel)
|
||||
{
|
||||
var d = await this._localizer.GetDictionary(dictionary);
|
||||
if (d is null)
|
||||
return NotFound();
|
||||
if (!Translations.TryCreateFromText(viewModel.Translations, out var translations))
|
||||
{
|
||||
ModelState.AddModelError(nameof(viewModel.Translations), "Syntax error");
|
||||
return View(viewModel);
|
||||
}
|
||||
await _localizer.Save(d, translations);
|
||||
TempData[WellKnownTempData.SuccessMessage] = "Dictionary updated";
|
||||
return RedirectToAction(nameof(ListDictionaries));
|
||||
}
|
||||
[HttpGet("server/dictionaries/{dictionary}/select")]
|
||||
public async Task<IActionResult> SelectDictionary(string dictionary)
|
||||
{
|
||||
var settings = await this._SettingsRepository.GetSettingAsync<PoliciesSettings>() ?? new();
|
||||
settings.LangDictionary = dictionary;
|
||||
await _SettingsRepository.UpdateSetting(settings);
|
||||
await _localizer.Load();
|
||||
TempData[WellKnownTempData.SuccessMessage] = $"Default dictionary changed to {dictionary}";
|
||||
return RedirectToAction(nameof(ListDictionaries));
|
||||
}
|
||||
[HttpPost("server/dictionaries/{dictionary}/delete")]
|
||||
public async Task<IActionResult> DeleteDictionary(string dictionary)
|
||||
{
|
||||
await _localizer.DeleteDictionary(dictionary);
|
||||
TempData[WellKnownTempData.SuccessMessage] = $"Dictionary {dictionary} deleted";
|
||||
return RedirectToAction(nameof(ListDictionaries));
|
||||
}
|
||||
}
|
||||
}
|
@ -68,6 +68,7 @@ namespace BTCPayServer.Controllers
|
||||
private readonly UriResolver _uriResolver;
|
||||
private readonly EmailSenderFactory _emailSenderFactory;
|
||||
private readonly TransactionLinkProviders _transactionLinkProviders;
|
||||
private readonly LocalizerService _localizer;
|
||||
|
||||
public UIServerController(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
@ -93,7 +94,8 @@ namespace BTCPayServer.Controllers
|
||||
EmailSenderFactory emailSenderFactory,
|
||||
IHostApplicationLifetime applicationLifetime,
|
||||
IHtmlHelper html,
|
||||
TransactionLinkProviders transactionLinkProviders
|
||||
TransactionLinkProviders transactionLinkProviders,
|
||||
LocalizerService localizer
|
||||
)
|
||||
{
|
||||
_policiesSettings = policiesSettings;
|
||||
@ -120,6 +122,7 @@ namespace BTCPayServer.Controllers
|
||||
ApplicationLifetime = applicationLifetime;
|
||||
Html = html;
|
||||
_transactionLinkProviders = transactionLinkProviders;
|
||||
_localizer = localizer;
|
||||
}
|
||||
|
||||
[HttpGet("server/stores")]
|
||||
@ -325,17 +328,22 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
[Route("server/policies")]
|
||||
public async Task<IActionResult> Policies()
|
||||
{
|
||||
await UpdateViewBag();
|
||||
return View(_policiesSettings);
|
||||
}
|
||||
|
||||
private async Task UpdateViewBag()
|
||||
{
|
||||
ViewBag.UpdateUrlPresent = _Options.UpdateUrl != null;
|
||||
ViewBag.AppsList = await GetAppSelectList();
|
||||
return View(_policiesSettings);
|
||||
ViewBag.LangDictionaries = await GetLangDictionariesSelectList();
|
||||
}
|
||||
|
||||
[HttpPost("server/policies")]
|
||||
public async Task<IActionResult> Policies([FromServices] BTCPayNetworkProvider btcPayNetworkProvider, PoliciesSettings settings, string command = "")
|
||||
{
|
||||
ViewBag.UpdateUrlPresent = _Options.UpdateUrl != null;
|
||||
ViewBag.AppsList = await GetAppSelectList();
|
||||
await UpdateViewBag();
|
||||
|
||||
if (command == "add-domain")
|
||||
{
|
||||
@ -385,8 +393,11 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await _SettingsRepository.UpdateSetting(settings);
|
||||
_ = _transactionLinkProviders.RefreshTransactionLinkTemplates();
|
||||
if (_policiesSettings.LangDictionary != settings.LangDictionary)
|
||||
await _localizer.Load();
|
||||
TempData[WellKnownTempData.SuccessMessage] = "Policies updated successfully";
|
||||
return RedirectToAction(nameof(Policies));
|
||||
}
|
||||
@ -455,6 +466,12 @@ namespace BTCPayServer.Controllers
|
||||
return apps;
|
||||
}
|
||||
|
||||
private async Task<List<SelectListItem>> GetLangDictionariesSelectList()
|
||||
{
|
||||
var dictionaries = await this._localizer.GetDictionaries();
|
||||
return dictionaries.Select(d => new SelectListItem(d.DictionaryName, d.DictionaryName)).OrderBy(d => d.Value).ToList();
|
||||
}
|
||||
|
||||
private static bool TryParseAsExternalService(TorService torService, [MaybeNullWhen(false)] out ExternalService externalService)
|
||||
{
|
||||
externalService = null;
|
||||
|
@ -630,6 +630,7 @@ namespace BTCPayServer
|
||||
dataDirectories.StorageDir = Path.Combine(dataDirectories.DataDir, Storage.Services.Providers.FileSystemStorage.FileSystemFileProviderService.LocalStorageDirectoryName);
|
||||
dataDirectories.TempStorageDir = Path.Combine(dataDirectories.StorageDir, "tmp");
|
||||
dataDirectories.TempDir = Path.Combine(dataDirectories.DataDir, "tmp");
|
||||
dataDirectories.LangsDir = Path.Combine(dataDirectories.DataDir, "Langs");
|
||||
return dataDirectories;
|
||||
}
|
||||
|
||||
|
@ -76,6 +76,10 @@ using ExchangeSharp;
|
||||
|
||||
|
||||
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.AspNetCore.Mvc.Localization;
|
||||
|
||||
|
||||
#if ALTCOINS
|
||||
using BTCPayServer.Services.Altcoins.Monero;
|
||||
using BTCPayServer.Services.Altcoins.Zcash;
|
||||
@ -91,6 +95,11 @@ namespace BTCPayServer.Hosting
|
||||
}
|
||||
public static IServiceCollection AddBTCPayServer(this IServiceCollection services, IConfiguration configuration, Logs logs)
|
||||
{
|
||||
services.TryAddSingleton<IStringLocalizerFactory, LocalizerFactory>();
|
||||
services.TryAddSingleton<IHtmlLocalizerFactory, LocalizerFactory>();
|
||||
services.TryAddSingleton<LocalizerService>();
|
||||
services.TryAddSingleton<ViewLocalizer>();
|
||||
|
||||
services.AddSingleton<MvcNewtonsoftJsonOptions>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value);
|
||||
services.AddSingleton<JsonSerializerSettings>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value.SerializerSettings);
|
||||
services.AddDbContext<ApplicationDbContext>((provider, o) =>
|
||||
@ -161,6 +170,7 @@ namespace BTCPayServer.Hosting
|
||||
services.AddSingleton<IUIExtension>(new UIExtension("Lightning/ViewLightningLikePaymentData", "store-invoices-payments"));
|
||||
|
||||
services.AddStartupTask<BlockExplorerLinkStartupTask>();
|
||||
services.AddStartupTask<LoadTranslationsStartupTask>();
|
||||
services.TryAddSingleton<InvoiceRepository>();
|
||||
services.AddSingleton<PaymentService>();
|
||||
services.AddSingleton<BTCPayServerEnvironment>();
|
||||
|
86
BTCPayServer/Hosting/LoadTranslationsStartupTask.cs
Normal file
86
BTCPayServer/Hosting/LoadTranslationsStartupTask.cs
Normal file
@ -0,0 +1,86 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Configuration;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Hosting
|
||||
{
|
||||
public class LoadTranslationsStartupTask : IStartupTask
|
||||
{
|
||||
public LoadTranslationsStartupTask(
|
||||
ILogger<LoadTranslationsStartupTask> logger,
|
||||
LocalizerService localizerService,
|
||||
IOptions<DataDirectories> dataDirectories)
|
||||
{
|
||||
DataDirectories = dataDirectories.Value;
|
||||
Logger = logger;
|
||||
LocalizerService = localizerService;
|
||||
}
|
||||
|
||||
public DataDirectories DataDirectories { get; }
|
||||
public ILogger<LoadTranslationsStartupTask> Logger { get; }
|
||||
public LocalizerService LocalizerService { get; }
|
||||
|
||||
class DictionaryFileMetadata
|
||||
{
|
||||
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
|
||||
[JsonProperty("hash")]
|
||||
public uint256 Hash { get; set; }
|
||||
}
|
||||
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// This load languages files from a [datadir]/Langs into the database
|
||||
// to make startup faster, we skip update if we see that files didn't changes
|
||||
// since the last time they got loaded.
|
||||
// We do this by comparing hashes of the current file, to the one stored in DB.
|
||||
if (Directory.Exists(DataDirectories.LangsDir))
|
||||
{
|
||||
var files = Directory.GetFiles(DataDirectories.LangsDir);
|
||||
if (files.Length > 0)
|
||||
{
|
||||
Logger.LogInformation("Loading language files...");
|
||||
var dictionaries = await LocalizerService.GetDictionaries();
|
||||
foreach (var file in Directory.GetFiles(DataDirectories.LangsDir))
|
||||
{
|
||||
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")
|
||||
{
|
||||
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 translations = Translations.CreateFromText(File.ReadAllText(file));
|
||||
var currentHash = new uint256(SHA256.HashData(Encoding.UTF8.GetBytes(translations.ToTextFormat())));
|
||||
|
||||
if (savedHash != currentHash)
|
||||
{
|
||||
var newMetadata = (JObject)dictionary.Metadata.DeepClone();
|
||||
newMetadata["hash"] = currentHash.ToString();
|
||||
dictionary = dictionary with { Metadata = newMetadata };
|
||||
Logger.LogInformation($"Updating dictionary '{langName}'");
|
||||
await LocalizerService.Save(dictionary, translations);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do not make startup longer for this
|
||||
_ = LocalizerService.Load();
|
||||
}
|
||||
}
|
||||
}
|
@ -32,6 +32,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
@ -168,6 +169,7 @@ namespace BTCPayServer.Hosting
|
||||
.AddNewtonsoftJson()
|
||||
.AddRazorRuntimeCompilation()
|
||||
.AddPlugins(services, Configuration, LoggerFactory, bootstrapServiceProvider)
|
||||
.AddDataAnnotationsLocalization()
|
||||
.AddControllersAsServices();
|
||||
|
||||
services.AddServerSideBlazor();
|
||||
|
@ -11,6 +11,7 @@ namespace BTCPayServer.Models.AccountViewModels
|
||||
|
||||
[Required]
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "Password")]
|
||||
public string Password { get; set; }
|
||||
public string LoginCode { get; set; }
|
||||
|
||||
|
@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
|
||||
namespace BTCPayServer.Models.ServerViewModels;
|
||||
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;
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
using BTCPayServer.Services;
|
||||
|
||||
namespace BTCPayServer.Models.ServerViewModels;
|
||||
|
||||
public class EditDictionaryViewModel
|
||||
{
|
||||
public string Translations { get; set; }
|
||||
public int Lines { get; set; }
|
||||
|
||||
internal EditDictionaryViewModel SetTranslations(Translations translations)
|
||||
{
|
||||
Translations = translations.ToTextFormat();
|
||||
Lines = translations.Records.Count;
|
||||
return this;
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
|
||||
namespace BTCPayServer.Models.ServerViewModels
|
||||
{
|
||||
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 List<DictionaryViewModel> Dictionaries = new List<DictionaryViewModel>();
|
||||
}
|
||||
}
|
@ -8,7 +8,7 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
public class GeneralSettingsViewModel
|
||||
{
|
||||
|
||||
[Display(Name = "Store ID")]
|
||||
[Display(Name = "Store Id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
[Display(Name = "Store Name")]
|
||||
@ -45,7 +45,7 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
[Display(Name = "Add additional fee (network fee) to invoice …")]
|
||||
public NetworkFeeMode NetworkFeeMode { get; set; }
|
||||
|
||||
[Display(Name = "Consider the invoice paid even if the paid amount is ... % less than expected")]
|
||||
[Display(Name = "Consider the invoice paid even if the paid amount is … % less than expected")]
|
||||
[Range(0, 100)]
|
||||
public double PaymentTolerance { get; set; }
|
||||
|
||||
|
@ -17,7 +17,7 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
[Display(Name = "Add additional fee (network fee) to invoice …")]
|
||||
public NetworkFeeMode NetworkFeeMode { get; set; }
|
||||
|
||||
[Display(Name = "Consider the invoice paid even if the paid amount is ... % less than expected")]
|
||||
[Display(Name = "Consider the invoice paid even if the paid amount is … % less than expected")]
|
||||
[Range(0, 100)]
|
||||
public double PaymentTolerance { get; set; }
|
||||
|
||||
|
@ -10,9 +10,9 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||
public class WalletPSBTCombineViewModel
|
||||
{
|
||||
public string OtherPSBT { get; set; }
|
||||
[Display(Name = "PSBT to combine with...")]
|
||||
[Display(Name = "PSBT to combine with…")]
|
||||
public string PSBT { get; set; }
|
||||
[Display(Name = "Upload PSBT from file...")]
|
||||
[Display(Name = "Upload PSBT from file…")]
|
||||
public IFormFile UploadedPSBTFile { get; set; }
|
||||
|
||||
public string BackUrl { get; set; }
|
||||
|
@ -32,7 +32,7 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||
public string PSBT { get; set; }
|
||||
public List<string> Errors { get; set; } = new List<string>();
|
||||
|
||||
[Display(Name = "Upload PSBT from file")]
|
||||
[Display(Name = "Upload PSBT from file…")]
|
||||
public IFormFile UploadedPSBTFile { get; set; }
|
||||
|
||||
|
||||
|
@ -9,7 +9,7 @@ namespace BTCPayServer.Plugins.Shopify.Models
|
||||
[Display(Name = "Shop Name")]
|
||||
public string ShopName { get; set; }
|
||||
|
||||
[Display(Name = "Api Key")]
|
||||
[Display(Name = "API Key")]
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
[Display(Name = "Admin API access token")]
|
||||
|
130
BTCPayServer/Services/LocalizerFactory.cs
Normal file
130
BTCPayServer/Services/LocalizerFactory.cs
Normal file
@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using BTCPayServer.Logging;
|
||||
using Microsoft.AspNetCore.Mvc.Localization;
|
||||
using Microsoft.AspNetCore.Mvc.TagHelpers;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace BTCPayServer.Services
|
||||
{
|
||||
public class LocalizerFactory : IStringLocalizerFactory, IHtmlLocalizerFactory
|
||||
{
|
||||
internal readonly Logs _logs;
|
||||
private readonly LocalizerService _localizerService;
|
||||
|
||||
class StringLocalizer : IStringLocalizer, IHtmlLocalizer
|
||||
{
|
||||
private Type _resourceSource;
|
||||
private string _baseName;
|
||||
private string _location;
|
||||
private LocalizerFactory _Factory;
|
||||
|
||||
public StringLocalizer(LocalizerFactory factory, Type resourceSource)
|
||||
{
|
||||
_Factory = factory;
|
||||
_resourceSource = resourceSource;
|
||||
}
|
||||
Logs Logs => _Factory._logs;
|
||||
|
||||
|
||||
public StringLocalizer(LocalizerFactory jsonStringLocalizerFactory, string baseName, string location)
|
||||
{
|
||||
_Factory = jsonStringLocalizerFactory;
|
||||
_baseName = baseName;
|
||||
_location = location;
|
||||
}
|
||||
Translations Translations => _Factory._localizerService.Translations;
|
||||
public LocalizedString this[string name]
|
||||
{
|
||||
get
|
||||
{
|
||||
//Logs.PayServer.LogInformation($"this[name] with name:{name}, location:{_location}, baseName:{_baseName}, resource:{_resourceSource}");
|
||||
Translations.Records.TryGetValue(name, out var result);
|
||||
result = result ?? name;
|
||||
return new LocalizedString(name, result);
|
||||
}
|
||||
}
|
||||
|
||||
public LocalizedString this[string name, params object[] arguments]
|
||||
{
|
||||
get
|
||||
{
|
||||
//var args = String.Join(", ", arguments.Select((a, i) => $"arg[{i}]:{a}").ToArray());
|
||||
//Logs.PayServer.LogInformation($"this[name, arguments] with name:{name}, {args}, location:{_location}, baseName:{_baseName}, resource:{_resourceSource}");
|
||||
Translations.Records.TryGetValue(name, out var result);
|
||||
result = result ?? name;
|
||||
return new LocalizedString(name, string.Format(result, arguments));
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
|
||||
{
|
||||
//Logs.PayServer.LogInformation($"GetAllStrings");
|
||||
return Translations.Records.Select(r => new LocalizedString(r.Key, r.Value));
|
||||
}
|
||||
|
||||
LocalizedHtmlString IHtmlLocalizer.this[string name]
|
||||
{
|
||||
get
|
||||
{
|
||||
//Logs.PayServer.LogInformation($"[HTML]: this[name] with name:{name}, location:{_location}, baseName:{_baseName}, resource:{_resourceSource}");
|
||||
Translations.Records.TryGetValue(name, out var result);
|
||||
result = result ?? name;
|
||||
return new LocalizedHtmlString(name, result);
|
||||
}
|
||||
}
|
||||
|
||||
LocalizedHtmlString IHtmlLocalizer.this[string name, params object[] arguments]
|
||||
{
|
||||
get
|
||||
{
|
||||
//var args = String.Join(", ", arguments.Select((a, i) => $"arg[{i}]:{a}").ToArray());
|
||||
//Logs.PayServer.LogInformation($"[HTML]:this[name, arguments] with name:{name}, {args}, location:{_location}, baseName:{_baseName}, resource:{_resourceSource}");
|
||||
Translations.Records.TryGetValue(name, out var result);
|
||||
result = result ?? name;
|
||||
return new LocalizedHtmlString(name, result, true, arguments);
|
||||
}
|
||||
}
|
||||
|
||||
public LocalizedString GetString(string name)
|
||||
{
|
||||
//Logs.PayServer.LogInformation($"[HTML] GetString(name):");
|
||||
return this[name];
|
||||
}
|
||||
|
||||
public LocalizedString GetString(string name, params object[] arguments)
|
||||
{
|
||||
//var args = String.Join(", ", arguments.Select((a, i) => $"arg[{i}]:{a}").ToArray());
|
||||
Logs.PayServer.LogInformation($"[HTML] GetString(name,args):");
|
||||
return this[name, arguments];
|
||||
}
|
||||
}
|
||||
public LocalizerFactory(Logs logs, LocalizerService localizerService)
|
||||
{
|
||||
_logs = logs;
|
||||
_localizerService = localizerService;
|
||||
}
|
||||
public IStringLocalizer Create(Type resourceSource)
|
||||
{
|
||||
return new StringLocalizer(this, resourceSource);
|
||||
}
|
||||
|
||||
public IStringLocalizer Create(string baseName, string location)
|
||||
{
|
||||
return new StringLocalizer(this, baseName, location);
|
||||
}
|
||||
|
||||
IHtmlLocalizer IHtmlLocalizerFactory.Create(Type resourceSource)
|
||||
{
|
||||
return new StringLocalizer(this, resourceSource);
|
||||
}
|
||||
|
||||
IHtmlLocalizer IHtmlLocalizerFactory.Create(string baseName, string location)
|
||||
{
|
||||
return new StringLocalizer(this, baseName, location);
|
||||
}
|
||||
}
|
||||
}
|
170
BTCPayServer/Services/LocalizerService.cs
Normal file
170
BTCPayServer/Services/LocalizerService.cs
Normal file
@ -0,0 +1,170 @@
|
||||
#nullable enable
|
||||
using System.Collections;
|
||||
using System.Collections.Frozen;
|
||||
using Dapper;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static BTCPayServer.Services.LocalizerService;
|
||||
|
||||
namespace BTCPayServer.Services
|
||||
{
|
||||
public class LocalizerService
|
||||
{
|
||||
public LocalizerService(
|
||||
ILogger<LocalizerService> logger,
|
||||
ApplicationDbContextFactory contextFactory,
|
||||
ISettingsAccessor<PoliciesSettings> settingsAccessor)
|
||||
{
|
||||
_logger = logger;
|
||||
_ContextFactory = contextFactory;
|
||||
_settingsAccessor = settingsAccessor;
|
||||
_LoadedTranslations = new LoadedTranslations(Translations.Default, Translations.Default, Translations.DefaultLanguage);
|
||||
}
|
||||
|
||||
public record LoadedTranslations(Translations Translations, Translations Fallback, string LangName);
|
||||
LoadedTranslations _LoadedTranslations;
|
||||
public Translations Translations => _LoadedTranslations.Translations;
|
||||
|
||||
private readonly ILogger<LocalizerService> _logger;
|
||||
private readonly ApplicationDbContextFactory _ContextFactory;
|
||||
private readonly ISettingsAccessor<PoliciesSettings> _settingsAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Load the translation of the server into memory
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
_LoadedTranslations = await GetTranslations(_settingsAccessor.Settings.LangDictionary);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load translations");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LoadedTranslations> GetTranslations(string dictionaryName)
|
||||
{
|
||||
var ctx = _ContextFactory.CreateContext();
|
||||
var conn = ctx.Database.GetDbConnection();
|
||||
var all = await conn.QueryAsync<(bool fallback, string sentence, string? translation)>(
|
||||
"SELECT 'f'::BOOL fallback, sentence, translation FROM translations WHERE dict_id=@dict_id " +
|
||||
"UNION ALL " +
|
||||
"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,
|
||||
});
|
||||
var fallback = new Translations(all.Where(a => a.fallback).Select(o => KeyValuePair.Create(o.sentence, o.translation)), Translations.Default);
|
||||
var translations = new Translations(all.Where(a => !a.fallback).Select(o => KeyValuePair.Create(o.sentence, o.translation)), fallback);
|
||||
return new LoadedTranslations(translations, fallback, dictionaryName);
|
||||
}
|
||||
|
||||
public async Task Save(Dictionary dictionary, Translations translations)
|
||||
{
|
||||
var loadedTranslations = await GetTranslations(dictionary.DictionaryName);
|
||||
translations = translations.WithFallback(loadedTranslations.Fallback);
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
var diffs = loadedTranslations.Translations.CalculateDiff(translations);
|
||||
var conn = ctx.Database.GetDbConnection();
|
||||
List<string> keys = new List<string>();
|
||||
List<string> deletedKeys = new List<string>();
|
||||
List<string> values = new List<string>();
|
||||
|
||||
// The basic idea here is that we can remove from
|
||||
// the dictionary any translations which are the same
|
||||
// as the fallback. This way, if the fallback get updated,
|
||||
// it will also update the dictionary.
|
||||
foreach (var diff in diffs)
|
||||
{
|
||||
if (diff is Translations.Diff.Added a)
|
||||
{
|
||||
if (a.Value != loadedTranslations.Fallback[a.Key])
|
||||
{
|
||||
keys.Add(a.Key);
|
||||
values.Add(a.Value);
|
||||
}
|
||||
}
|
||||
else if (diff is Translations.Diff.Modified m)
|
||||
{
|
||||
if (m.NewValue != loadedTranslations.Fallback[m.Key])
|
||||
{
|
||||
keys.Add(m.Key);
|
||||
values.Add(m.NewValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
deletedKeys.Add(m.Key);
|
||||
}
|
||||
}
|
||||
else if (diff is Translations.Diff.Deleted d)
|
||||
{
|
||||
deletedKeys.Add(d.Key);
|
||||
}
|
||||
}
|
||||
await conn.ExecuteAsync("INSERT INTO lang_translations SELECT @dict_id, sentence, translation FROM unnest(@keys, @values) AS t(sentence, translation) ON CONFLICT (dict_id, sentence) DO UPDATE SET translation = EXCLUDED.translation; ",
|
||||
new
|
||||
{
|
||||
dict_id = loadedTranslations.LangName,
|
||||
keys = keys.ToArray(),
|
||||
values = values.ToArray()
|
||||
});
|
||||
await conn.ExecuteAsync("DELETE FROM lang_translations WHERE dict_id=@dict_id AND sentence=ANY(@keys)",
|
||||
new
|
||||
{
|
||||
dict_id = loadedTranslations.LangName,
|
||||
keys = deletedKeys.ToArray()
|
||||
});
|
||||
|
||||
if (_LoadedTranslations.LangName == loadedTranslations.LangName)
|
||||
_LoadedTranslations = loadedTranslations with { Translations = translations };
|
||||
}
|
||||
|
||||
public record Dictionary(string DictionaryName, string? Fallback, string Source, JObject Metadata);
|
||||
public async Task<Dictionary[]> GetDictionaries()
|
||||
{
|
||||
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();
|
||||
}
|
||||
public async Task<Dictionary?> GetDictionary(string name)
|
||||
{
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
var db = ctx.Database.GetDbConnection();
|
||||
var r = await db.QueryFirstAsync("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 ?? "{}"));
|
||||
}
|
||||
|
||||
public async Task<Dictionary> CreateDictionary(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());
|
||||
}
|
||||
|
||||
public async Task DeleteDictionary(string dictionary)
|
||||
{
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
@ -23,6 +23,11 @@ namespace BTCPayServer.Services
|
||||
set { LockSubscription = !value; }
|
||||
}
|
||||
|
||||
[DefaultValue("English")]
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
|
||||
[Display(Name = "Backend's language")]
|
||||
public string LangDictionary { get; set; } = "English";
|
||||
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
|
||||
[Display(Name = "Admin must approve new users")]
|
||||
public bool RequiresUserApproval { get; set; }
|
||||
|
242
BTCPayServer/Services/Translations.Default.cs
Normal file
242
BTCPayServer/Services/Translations.Default.cs
Normal file
@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BTCPayServer.Services
|
||||
{
|
||||
public partial class Translations
|
||||
{
|
||||
static Translations()
|
||||
{
|
||||
// Text generated by UpdateDefaultTranslations.
|
||||
// Please run it before release.
|
||||
var knownTranslations =
|
||||
"""
|
||||
Account key
|
||||
Account key path
|
||||
Add additional fee (network fee) to invoice …
|
||||
Add Exchange Rate Spread
|
||||
Add hop hints for private channels to the Lightning invoice
|
||||
Admin API access token
|
||||
Admin must approve new users
|
||||
Allow anyone to create invoice
|
||||
Allow form for public use
|
||||
Allow payee to create invoices with custom amounts
|
||||
Allow payee to pass a comment
|
||||
Allow Stores use the Server's SMTP email settings as their default
|
||||
Always include non-witness UTXO if available
|
||||
Amazon S3
|
||||
Amount
|
||||
API Key
|
||||
App
|
||||
App Name
|
||||
App Type
|
||||
Application
|
||||
Authenticator code
|
||||
Auto-detect language on checkout
|
||||
Automatically approve claims
|
||||
Available Payment Methods
|
||||
Azure Blob Storage
|
||||
Backend's language
|
||||
Batch size
|
||||
BIP39 Seed (12/24 word mnemonic phrase) or HD private key (xprv...)
|
||||
Brand Color
|
||||
Buyer Email
|
||||
Callback Notification URL
|
||||
Can use hot wallet
|
||||
Can use RPC import
|
||||
Celebrate payment with confetti
|
||||
Check releases on GitHub and notify when new BTCPay Server version is available
|
||||
Colors to rotate between with animation when a payment is made. One color per line.
|
||||
Confirm new password
|
||||
Confirm password
|
||||
Connection string
|
||||
Consider the invoice paid even if the paid amount is … % less than expected
|
||||
Consider the invoice settled when the payment transaction …
|
||||
Contact URL
|
||||
Contribution Perks Template
|
||||
Count all invoices created on the store as part of the goal
|
||||
Currency
|
||||
Current password
|
||||
Custom CSS
|
||||
Custom HTML title to display on Checkout page
|
||||
Custom sound file for successful payment
|
||||
Custom Theme Extension Type
|
||||
Custom Theme File
|
||||
Default currency
|
||||
Default language on checkout
|
||||
Default payment method on checkout
|
||||
Default role for users on a new store
|
||||
Derivation scheme
|
||||
Derivation scheme format
|
||||
Description
|
||||
Description template of the lightning invoice
|
||||
Destination Address
|
||||
Disable public user registration
|
||||
Disable stores from using the server's email settings as backup
|
||||
Discourage search engines from indexing this site
|
||||
Display app on website root
|
||||
Display contribution ranking
|
||||
Display contribution value
|
||||
Display item selection for keypad
|
||||
Display Lightning payment amounts in Satoshis
|
||||
Display the category list
|
||||
Display the search bar
|
||||
Display Title
|
||||
Disqus Shortname
|
||||
Do not allow additional contributions after target has been reached
|
||||
Does not extend a BTCPay Server theme, fully custom
|
||||
Domain
|
||||
Domain name
|
||||
Don't create UTXO change
|
||||
Email
|
||||
Email address
|
||||
Email confirmation required
|
||||
Email confirmed?
|
||||
Enable background animations on new payments
|
||||
Enable Disqus Comments
|
||||
Enable experimental features
|
||||
Enable LNURL
|
||||
Enable Payjoin/P2EP
|
||||
Enable public receipt page for settled invoices
|
||||
Enable public user registration
|
||||
Enable sounds on checkout page
|
||||
Enable sounds on new payments
|
||||
Enable tips
|
||||
End date
|
||||
Error
|
||||
Expiration Date
|
||||
Extends the BTCPay Server Dark theme
|
||||
Extends the BTCPay Server Light theme
|
||||
Featured Image URL
|
||||
Fee rate (sat/vB)
|
||||
Form configuration (JSON)
|
||||
Gap limit
|
||||
Google Cloud Storage
|
||||
GRPC SSL Cipher suite (GRPC_SSL_CIPHER_SUITES)
|
||||
Image
|
||||
Invoice currency
|
||||
Invoice expires if the full amount has not been paid after …
|
||||
Invoice metadata
|
||||
Is administrator?
|
||||
Is signing key
|
||||
Item Description
|
||||
Keypad
|
||||
Lightning node (LNURL Auth)
|
||||
LNURL Classic Mode
|
||||
Local File System
|
||||
Logo
|
||||
Make Crowdfund Public
|
||||
Master fingerprint
|
||||
Max sats
|
||||
Memo
|
||||
Metadata
|
||||
Min sats
|
||||
Minimum acceptable expiration time for BOLT11 for refunds
|
||||
New password
|
||||
Non-admins can access the User Creation API Endpoint
|
||||
Non-admins can create Hot Wallets for their Store
|
||||
Non-admins can import Hot Wallets for their Store
|
||||
Non-admins can use the Internal Lightning Node for their Store
|
||||
Non-admins cannot access the User Creation API Endpoint
|
||||
Notification Email
|
||||
Notification URL
|
||||
Only enable the payment method after user explicitly chooses it
|
||||
Optional seed passphrase
|
||||
Order Id
|
||||
Override the block explorers used
|
||||
Pair to
|
||||
Password
|
||||
Password (leave blank to generate invite-link)
|
||||
PayJoin BIP21
|
||||
Payment invalid if transactions fails to confirm … after invoice expiration
|
||||
Payout Methods
|
||||
Plugin server
|
||||
Point of Sale Style
|
||||
Policies
|
||||
Preferred Price Source
|
||||
Print display
|
||||
Product list
|
||||
Product list with cart
|
||||
Profile Picture
|
||||
PSBT content
|
||||
PSBT to combine with…
|
||||
Public Key
|
||||
Rate Rules
|
||||
Recommended fee confirmation target blocks
|
||||
Recovery Code
|
||||
Redirect invoice to redirect url automatically after paid
|
||||
Redirect URL
|
||||
Remember me
|
||||
Remember this machine
|
||||
Request contributor data on checkout
|
||||
Request customer data on checkout
|
||||
Reset goal every
|
||||
REST Uri
|
||||
Role
|
||||
Root fingerprint
|
||||
Scope
|
||||
Search engines can index this site
|
||||
Security device (FIDO2)
|
||||
Select the Default Currency during Store Creation
|
||||
Select the payout method used for refund
|
||||
Server Name
|
||||
Shop Name
|
||||
Show "Pay in wallet" button
|
||||
Show a timer … minutes before invoice expiration
|
||||
Show plugins in pre-release
|
||||
Show recommended fee
|
||||
Show the payment list in the public receipt page
|
||||
Show the QR code of the receipt in the public receipt page
|
||||
Show the store header
|
||||
Sign in
|
||||
Sort contribution perks by popularity
|
||||
Sounds to play when a payment is made. One sound per line
|
||||
Specify the amount and currency for the refund
|
||||
Start date
|
||||
Starting index
|
||||
Store
|
||||
Store Id
|
||||
Store Name
|
||||
Store Website
|
||||
Subtract fees from amount
|
||||
Support URL
|
||||
Supported Transaction Currencies
|
||||
Target Amount
|
||||
Test Email
|
||||
Text to display in the tip input
|
||||
Text to display on buttons allowing the user to enter a custom amount
|
||||
Text to display on each button for items with a specific price
|
||||
Tip percentage amounts (comma separated)
|
||||
Unify on-chain and lightning payment URL/QR code
|
||||
Upload PSBT from file…
|
||||
Url of the Dynamic DNS service you are using
|
||||
Use custom theme
|
||||
Use SSL
|
||||
User can input custom amount
|
||||
User can input discount in %
|
||||
UTXOs to spend from
|
||||
Verification Code
|
||||
Wallet file
|
||||
Wallet file content
|
||||
Welcome to {0}
|
||||
Your dynamic DNS hostname
|
||||
""";
|
||||
Default = Translations.CreateFromText(knownTranslations);
|
||||
Default = new Translations(new KeyValuePair<string, string>[]
|
||||
{
|
||||
// You can add additional hard coded default here
|
||||
// KeyValuePair.Create("key1", "value")
|
||||
// KeyValuePair.Create("key2", "value")
|
||||
}, Default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translations which are already in the Default aren't saved into database.
|
||||
/// This allows us to automatically update the english version if the translations didn't changed.
|
||||
///
|
||||
/// We only save into database the key/values that differ from Default
|
||||
/// </summary>
|
||||
public static Translations Default;
|
||||
public readonly static string DefaultLanguage = "English";
|
||||
}
|
||||
}
|
125
BTCPayServer/Services/Translations.cs
Normal file
125
BTCPayServer/Services/Translations.cs
Normal file
@ -0,0 +1,125 @@
|
||||
#nullable enable
|
||||
using System.Collections;
|
||||
using System.Collections.Frozen;
|
||||
using Dapper;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace BTCPayServer.Services
|
||||
{
|
||||
public partial class Translations : IEnumerable<KeyValuePair<string, string>>
|
||||
{
|
||||
public record Diff(string Key)
|
||||
{
|
||||
public record Deleted(string Key, string OldValue) : Diff(Key);
|
||||
public record Added(string Key, string Value) : Diff(Key);
|
||||
public record Modified(string Key, string NewValue, string OldValue) : Diff(Key);
|
||||
}
|
||||
public static bool TryCreateFromText(string text, [MaybeNullWhen(false)] out Translations translations)
|
||||
{
|
||||
translations = null;
|
||||
try
|
||||
{
|
||||
translations = CreateFromText(text);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public static Translations CreateFromText(string text)
|
||||
{
|
||||
text = (text ?? "").Replace("\r\n", "\n");
|
||||
var translations = new List<(string key, string? value)>();
|
||||
foreach (var line in text.Split("\n", StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var splitted = line.Split("=>", StringSplitOptions.RemoveEmptyEntries);
|
||||
if (splitted is [var key, var value])
|
||||
{
|
||||
translations.Add((key, value));
|
||||
}
|
||||
else if (splitted is [var key2])
|
||||
{
|
||||
translations.Add((key2, key2));
|
||||
}
|
||||
}
|
||||
return new Translations(translations
|
||||
.Select(t => KeyValuePair.Create(t.key, t.value)));
|
||||
}
|
||||
|
||||
public Translations(IEnumerable<KeyValuePair<string, string?>> records) : this (records, null)
|
||||
{
|
||||
}
|
||||
public Translations(IEnumerable<KeyValuePair<string, string?>> records, Translations? fallback)
|
||||
{
|
||||
Dictionary<string, string> thisRecords = new Dictionary<string, string>();
|
||||
foreach (var r in records)
|
||||
{
|
||||
var v = r.Value?.Trim();
|
||||
if (string.IsNullOrEmpty(v))
|
||||
continue;
|
||||
thisRecords.TryAdd(r.Key.Trim(), v);
|
||||
}
|
||||
if (fallback is not null)
|
||||
{
|
||||
foreach (var r in fallback.Records)
|
||||
{
|
||||
thisRecords.TryAdd(r.Key, r.Value);
|
||||
}
|
||||
}
|
||||
Records = thisRecords.ToFrozenDictionary();
|
||||
}
|
||||
public readonly FrozenDictionary<string, string> Records;
|
||||
|
||||
public string? this[string? key] => key is null ? null : Records.TryGetValue(key, out var v) ? v : null;
|
||||
|
||||
public Diff[] CalculateDiff(Translations translations)
|
||||
{
|
||||
List<Diff> diff = new List<Diff>(translations.Records.Count + 10);
|
||||
foreach (var kv in translations)
|
||||
{
|
||||
if (Records.TryGetValue(kv.Key, out var oldValue))
|
||||
{
|
||||
if (oldValue != kv.Value)
|
||||
diff.Add(new Diff.Modified(kv.Key, kv.Value, oldValue));
|
||||
}
|
||||
else
|
||||
{
|
||||
diff.Add(new Diff.Added(kv.Key, kv.Value));
|
||||
}
|
||||
}
|
||||
foreach (var kv in this)
|
||||
{
|
||||
if (!translations.Records.ContainsKey(kv.Key))
|
||||
diff.Add(new Diff.Deleted(kv.Key, kv.Value));
|
||||
}
|
||||
return diff.ToArray();
|
||||
}
|
||||
|
||||
public Translations WithFallback(Translations? fallback)
|
||||
{
|
||||
return new Translations(this!, fallback);
|
||||
}
|
||||
public IEnumerator<KeyValuePair<string, string>> GetEnumerator()
|
||||
{
|
||||
return Records.GetEnumerator();
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return GetEnumerator();
|
||||
}
|
||||
|
||||
public string ToTextFormat()
|
||||
{
|
||||
return string.Join('\n', Records.OrderBy(r => r.Key).Select(r => $"{r.Key} => {r.Value}").ToArray());
|
||||
}
|
||||
}
|
||||
}
|
55
BTCPayServer/TagHelpers/TranslateTagHelper.cs
Normal file
55
BTCPayServer/TagHelpers/TranslateTagHelper.cs
Normal file
@ -0,0 +1,55 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Services;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Mvc.Localization;
|
||||
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace BTCPayServer.TagHelpers
|
||||
{
|
||||
[HtmlTargetElement(Attributes = "text-translate")]
|
||||
[HtmlTargetElement(Attributes = "html-translate")]
|
||||
public class TranslateTagHelper : TagHelper
|
||||
{
|
||||
private readonly IStringLocalizer<TranslateTagHelper> _localizer;
|
||||
private readonly Safe _safe;
|
||||
|
||||
public bool TextTranslate { get; set; }
|
||||
public bool HtmlTranslate { get; set; }
|
||||
|
||||
|
||||
public TranslateTagHelper(
|
||||
IStringLocalizer<TranslateTagHelper> localizer,
|
||||
Safe safe)
|
||||
{
|
||||
_localizer = localizer;
|
||||
_safe = safe;
|
||||
}
|
||||
|
||||
|
||||
public override int Order
|
||||
{
|
||||
get
|
||||
{
|
||||
// Run this TagHelper before others
|
||||
return -10;
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
|
||||
{
|
||||
var originalContent = output.Content.IsModified
|
||||
? output.Content.GetContent()
|
||||
: (await output.GetChildContentAsync()).GetContent();
|
||||
|
||||
var newContent = _localizer[originalContent];
|
||||
if (TextTranslate)
|
||||
output.Content.SetContent(newContent);
|
||||
else
|
||||
output.Content.SetHtmlContent(_safe.Raw(newContent.Value));
|
||||
}
|
||||
}
|
||||
}
|
@ -49,7 +49,7 @@
|
||||
<vc:main-logo />
|
||||
</a>
|
||||
|
||||
<h1 class="h2 mb-3">Welcome to @(string.IsNullOrWhiteSpace(settings.ServerName) ? "your BTCPay\u00a0Server" : settings.ServerName)</h1>
|
||||
<h1 class="h2 mb-3" text-translate="true">@ViewLocalizer["Welcome to {0}", string.IsNullOrWhiteSpace(settings.ServerName) ? "BTCPay Server" : settings.ServerName]</h1>
|
||||
@if (ViewBag.ShowLeadText)
|
||||
{
|
||||
<p class="lead">
|
||||
|
@ -2,7 +2,7 @@
|
||||
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
|
||||
@inject BTCPayServer.Services.PoliciesSettings PoliciesSettings
|
||||
@{
|
||||
ViewData["Title"] = "Sign in";
|
||||
ViewData["Title"] = ViewLocalizer["Sign in"];
|
||||
Layout = "_LayoutSignedOut";
|
||||
Csp.UnsafeEval();
|
||||
}
|
||||
@ -21,7 +21,7 @@
|
||||
<div class="form-group">
|
||||
<div class="d-flex justify-content-between">
|
||||
<label asp-for="Password" class="form-label"></label>
|
||||
<a asp-action="ForgotPassword" tabindex="-1">Forgot password?</a>
|
||||
<a asp-action="ForgotPassword" text-translate="true" tabindex="-1">Forgot password?</a>
|
||||
</div>
|
||||
<div class="input-group d-flex">
|
||||
<input asp-for="Password" class="form-control" required />
|
||||
@ -35,7 +35,7 @@
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<div class="btn-group w-100">
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="LoginButton"><span class="ps-3">Sign in</span></button>
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="LoginButton"><span class="ps-3" text-translate="true">Sign in</span></button>
|
||||
<button type="button" class="btn btn-outline-primary btn-lg w-auto only-for-js" data-bs-toggle="modal" data-bs-target="#scanModal" title="Scan Login code with camera">
|
||||
<vc:icon symbol="scan-qr" />
|
||||
</button>
|
||||
@ -49,7 +49,7 @@
|
||||
@if (!PoliciesSettings.LockSubscription)
|
||||
{
|
||||
<p class="text-center mt-2 mb-0">
|
||||
<a id="Register" style="font-size:1.15rem" asp-action="Register" asp-route-returnurl="@ViewData["ReturnUrl"]">Create your account</a>
|
||||
<a id="Register" style="font-size:1.15rem" asp-action="Register" asp-route-returnurl="@ViewData["ReturnUrl"]" text-translate="true">Create your account</a>
|
||||
</p>
|
||||
}
|
||||
|
||||
|
@ -9,7 +9,7 @@
|
||||
|
||||
<partial name="_StatusMessage" />
|
||||
|
||||
<h2>Welcome to @(string.IsNullOrWhiteSpace(settings.ServerName) ? "your BTCPay\u00a0Server" : settings.ServerName)</h2>
|
||||
<h2 text-translate="true">@ViewLocalizer["Welcome to {0}", string.IsNullOrWhiteSpace(settings.ServerName) ? "BTCPay Server" : settings.ServerName]</h2>
|
||||
|
||||
@if (!Model.HasStore)
|
||||
{
|
||||
|
37
BTCPayServer/Views/UIServer/CreateDictionary.cshtml
Normal file
37
BTCPayServer/Views/UIServer/CreateDictionary.cshtml
Normal file
@ -0,0 +1,37 @@
|
||||
@using BTCPayServer.Abstractions.Models
|
||||
@model CreateDictionaryViewModel
|
||||
@{
|
||||
ViewData.SetActivePage(ServerNavPages.Translations);
|
||||
ViewData["Title"] = "Create a new dictionary";
|
||||
}
|
||||
<form method="post" class="d-flex flex-column">
|
||||
<div class="form-group">
|
||||
<div class="sticky-header">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a asp-action="ListDictionaries">Dictionaries</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
|
||||
</ol>
|
||||
<h2>@ViewData["Title"]</h2>
|
||||
</nav>
|
||||
<input type="submit" value="Create" class="btn btn-primary" id="Create" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xl-8 col-xxl-constrain">
|
||||
<div class="form-group">
|
||||
<label asp-for="Name" class="form-label"></label>
|
||||
<input asp-for="Name" class="form-control" />
|
||||
<span asp-validation-for="Name" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<label asp-for="Fallback" class="form-label"></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">If a translation isn’t available in the new dictionary, it will be searched in the fallback.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
49
BTCPayServer/Views/UIServer/EditDictionary.cshtml
Normal file
49
BTCPayServer/Views/UIServer/EditDictionary.cshtml
Normal file
@ -0,0 +1,49 @@
|
||||
@using BTCPayServer.Abstractions.Models
|
||||
@model EditDictionaryViewModel
|
||||
@{
|
||||
ViewData.SetActivePage(ServerNavPages.Translations);
|
||||
ViewData["Title"] = Context.GetRouteValue("dictionary");
|
||||
}
|
||||
|
||||
<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">Dictionaries</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
|
||||
</ol>
|
||||
<h2>@ViewData["Title"]</h2>
|
||||
</nav>
|
||||
<button id="SaveButton" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
|
||||
</div>
|
||||
<partial name="_StatusMessage" />
|
||||
|
||||
<div class="d-flex mb-4">
|
||||
<div class="flex-fill">
|
||||
<p>
|
||||
|
||||
Translations are formatted as <b>KEY => TRANSLATION</b>; for example, <b>Welcome => Bienvenue</b> translates <b>Welcome</b> to <b>Bienvenue</b>.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
To use the translation from this dictionary's fallback, you can:
|
||||
<ul>
|
||||
<li>Remove the translation from this dictionary.</li>
|
||||
<li>Set the translation to match the string in the fallback.</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p class="mb-0">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>
|
67
BTCPayServer/Views/UIServer/ListDictionaries.cshtml
Normal file
67
BTCPayServer/Views/UIServer/ListDictionaries.cshtml
Normal file
@ -0,0 +1,67 @@
|
||||
@using BTCPayServer.Abstractions.Models
|
||||
@model ListDictionariesViewModel
|
||||
@{
|
||||
ViewData.SetActivePage(ServerNavPages.Translations);
|
||||
ViewData["Title"] = "Dictionaries";
|
||||
}
|
||||
|
||||
<div class="sticky-header">
|
||||
<h2>@ViewData["Title"]</h2>
|
||||
<a asp-action="CreateDictionary" class="btn btn-primary" role="button" id="CreateDictionary">
|
||||
Create
|
||||
</a>
|
||||
</div>
|
||||
<partial name="_StatusMessage" />
|
||||
|
||||
|
||||
<div class="d-flex mb-4">
|
||||
<div class="flex-fill">
|
||||
<p class="mb-0">
|
||||
Dictionaries enable you to translate the BTCPay Server backend into different languages.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dictionary</th>
|
||||
<th>Fallback</th>
|
||||
<th class="actions-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var v in Model.Dictionaries)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
@if (!v.Editable)
|
||||
{
|
||||
<span class="@(v.IsSelected? "fw-bold" : "")">@v.DictionaryName</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="@(v.IsSelected? "fw-bold" : "")" asp-action="EditDictionary" asp-route-dictionary="@v.DictionaryName">@v.DictionaryName</a>
|
||||
}
|
||||
</td>
|
||||
<td>@v.Fallback</td>
|
||||
<td class="actions-col">
|
||||
<div class="d-inline-flex align-items-center gap-3">
|
||||
<a asp-action="CreateDictionary" asp-route-fallback="@v.DictionaryName">Clone</a>
|
||||
@if (!v.IsSelected)
|
||||
{
|
||||
<a id="Select-@v.DictionaryName" asp-action="SelectDictionary" asp-route-dictionary="@v.DictionaryName">Select</a>
|
||||
}
|
||||
@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="DELETE">Remove</a>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<partial name="_Confirm" model="@(new ConfirmModel("Delete dictionary", "This dictionary will be removed from this server.", "Delete"))" />
|
||||
</div>
|
@ -143,6 +143,15 @@
|
||||
<span asp-validation-for="AllowSearchEngines" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check my-3">
|
||||
<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))"
|
||||
class="form-select">
|
||||
</select>
|
||||
<span asp-validation-for="LangDictionary" class="text-danger"></span>
|
||||
<div class="form-text">Add or customize translations <a asp-action="ListDictionaries">here</a>.</div>
|
||||
</div>
|
||||
@if (ViewBag.UpdateUrlPresent)
|
||||
{
|
||||
<div class="d-flex my-3">
|
||||
|
@ -4,6 +4,7 @@ namespace BTCPayServer.Views.Server
|
||||
{
|
||||
Users, Emails, Policies, Branding, Services, Maintenance, Logs, Files, Plugins,
|
||||
Roles,
|
||||
Stores
|
||||
Stores,
|
||||
Translations
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@
|
||||
@using BTCPayServer.Data
|
||||
@using Microsoft.AspNetCore.Routing;
|
||||
@using BTCPayServer.Abstractions.Extensions;
|
||||
@inject Microsoft.AspNetCore.Mvc.Localization.ViewLocalizer ViewLocalizer
|
||||
@inject Safe Safe
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@addTagHelper *, BTCPayServer
|
||||
|
Loading…
Reference in New Issue
Block a user