#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 interface IDefaultTransactionProvider { Task[]> GetDefaultTransaction(); } public class InMemoryDefaultTransactionProvider : IDefaultTransactionProvider { private readonly KeyValuePair[] _values; public InMemoryDefaultTransactionProvider(KeyValuePair[] values) { _values = values; } public Task[]> GetDefaultTransaction() { return Task.FromResult(_values); } } public class LocalizerService { public LocalizerService( ILogger logger, ApplicationDbContextFactory contextFactory, ISettingsAccessor settingsAccessor, IEnumerable defaultTransactionProviders) { _logger = logger; _ContextFactory = contextFactory; _settingsAccessor = settingsAccessor; _defaultTransactionProviders = defaultTransactionProviders; _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 _logger; private readonly ApplicationDbContextFactory _ContextFactory; private readonly ISettingsAccessor _settingsAccessor; private readonly IEnumerable _defaultTransactionProviders; /// /// Load the translation of the server into memory /// /// 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 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 defaultDict = Translations.Default; var loading = _defaultTransactionProviders.Select(d => d.GetDefaultTransaction()).ToArray(); Dictionary additionalDefault = new(); foreach (var defaultProvider in loading) { foreach (var kv in await defaultProvider) { additionalDefault.TryAdd(kv.Key, string.IsNullOrEmpty(kv.Value) ? kv.Key : kv.Value); } } 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); } 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 keys = new List(); List deletedKeys = new List(); List values = new List(); // 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 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 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 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 }); } } }