Skip to content

Commit

Permalink
Allowing built-in (WIP)
Browse files Browse the repository at this point in the history
  • Loading branch information
NicolasDorier committed Feb 7, 2024
1 parent 9d580a9 commit 8bc7600
Show file tree
Hide file tree
Showing 15 changed files with 230 additions and 63 deletions.
4 changes: 3 additions & 1 deletion BTCPayServer/BTCPayServer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@
<Watch Remove="Views\Shared\LocalhostBrowserSupport.cshtml" />
<Watch Remove="Views\UIAccount\CheatPermissions.cshtml" />
<Watch Remove="Views\UIReports\StoreReports.cshtml" />
<Watch Remove="Views\UIServer\ServerTranslations.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>
Expand Down
75 changes: 66 additions & 9 deletions BTCPayServer/Controllers/UIServerController.Translations.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Data.Common;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
Expand All @@ -9,18 +11,73 @@ namespace BTCPayServer.Controllers
{
public partial class UIServerController
{
[HttpGet("server/translations")]
public IActionResult ServerTranslations()
[HttpGet("server/dictionaries")]
public async Task<IActionResult> ListDictionaries()
{
return View(new ServerTranslationsViewModel().SetTranslations(_localizer.Translations));
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,
});
}
return View(vm);
}
[HttpPost("server/translations")]
public async Task<IActionResult> ServerTranslations(ServerTranslationsViewModel viewModel)

[HttpGet("server/dictionaries/create")]
public IActionResult CreateDictionary(string fallback)
{
return View(new CreateDictionaryViewModel()
{
Name = $"{fallback} (Copy)",
Fallback = fallback
});
}
[HttpPost("server/dictionaries/create")]
public async Task<IActionResult> CreateDictionary(CreateDictionaryViewModel viewModel)
{
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);
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 translation = Translations.CreateFromText(viewModel.Translations);
await _localizer.Save(translation);
TempData[WellKnownTempData.SuccessMessage] = "Translations updated";
return RedirectToAction();
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));
}
}
}
2 changes: 1 addition & 1 deletion BTCPayServer/Controllers/UIServerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ private async Task<List<SelectListItem>> GetAppSelectList()
private async Task<List<SelectListItem>> GetLangDictionariesSelectList()
{
var dictionaries = await this._localizer.GetDictionaries();
return dictionaries.Select(d => new SelectListItem(d.LangName, d.LangName)).OrderBy(d => d.Value).ToList();
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)
Expand Down
4 changes: 2 additions & 2 deletions BTCPayServer/Hosting/LoadTranslationsStartupTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ public async Task ExecuteAsync(CancellationToken cancellationToken = default)
foreach (var file in Directory.GetFiles(DataDirectories.LangsDir))
{
var langName = Path.GetFileName(file);
var dictionary = dictionaries.FirstOrDefault(d => d.LangName == langName);
var dictionary = dictionaries.FirstOrDefault(d => d.DictionaryName == langName);
if (dictionary is null)
dictionary = await LocalizerService.CreateDictionary(langName, "File");
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");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace BTCPayServer.Models.ServerViewModels;
public class CreateDictionaryViewModel
{
public string Name { get; set; }
public string Fallback { get; set; }
}
16 changes: 16 additions & 0 deletions BTCPayServer/Models/ServerViewModels/EditDictionaryViewModel.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
19 changes: 19 additions & 0 deletions BTCPayServer/Models/ServerViewModels/ListDictionariesViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Services;

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 List<DictionaryViewModel> Dictionaries = new List<DictionaryViewModel>();
}
}

This file was deleted.

74 changes: 45 additions & 29 deletions BTCPayServer/Services/LocalizerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@
using Newtonsoft.Json.Linq;
using System.Data;
using System.Data.Common;
using ExchangeSharp;
using Amazon.Runtime.Internal.Util;
using Microsoft.Extensions.Logging;
using static BTCPayServer.Services.LocalizerService;

namespace BTCPayServer.Services
{
Expand All @@ -33,7 +32,7 @@ public class LocalizerService
_LoadedTranslations = new LoadedTranslations(Translations.Default, Translations.Default, "English");
}

record LoadedTranslations(Translations Translations, Translations Fallback, string LangName);
public record LoadedTranslations(Translations Translations, Translations Fallback, string LangName);
LoadedTranslations _LoadedTranslations;
public Translations Translations => _LoadedTranslations.Translations;

Expand All @@ -49,30 +48,35 @@ public async Task Load()
{
try
{
await using var ctx = _ContextFactory.CreateContext();
var conn = ctx.Database.GetDbConnection();
var langName = _settingsAccessor.Settings.LangDictionary;
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 = langName,
});
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);
_LoadedTranslations = new LoadedTranslations(translations, fallback, langName);
_LoadedTranslations = await GetTranslations(_settingsAccessor.Settings.LangDictionary);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load translations");
throw;
}
}
public async Task Save(Translations translations)

public async Task<LoadedTranslations> GetTranslations(string dictionaryName)
{
var loadedTranslations = _LoadedTranslations;
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 = new Translations(translations, loadedTranslations.Fallback);
await using var ctx = _ContextFactory.CreateContext();
var diffs = loadedTranslations.Translations.CalculateDiff(translations);
Expand Down Expand Up @@ -112,36 +116,48 @@ public async Task Save(Translations translations)
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 (key) DO UPDATE SET value = EXCLUDED.value; ",
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 translations WHERE key=ANY(@keys)",
await conn.ExecuteAsync("DELETE FROM lang_translations WHERE dict_id=@dict_id AND sentence=ANY(@keys)",
new
{
dict_id = loadedTranslations.LangName,
keys = deletedKeys.ToArray()
});
_LoadedTranslations = _LoadedTranslations with { Translations = translations };

if (_LoadedTranslations.LangName == loadedTranslations.LangName)
_LoadedTranslations = loadedTranslations with { Translations = translations };
}

public record Dictionary(string LangName, string? Fallback, string Source, JObject Metadata);
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 source)
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, source) VALUES (@langName, @source)", new { langName, source });
return new Dictionary(langName, null, source ?? "", new JObject());
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 UpdateDictionary(Dictionary dictionary, Translations translations)
Expand All @@ -151,7 +167,7 @@ public async Task UpdateDictionary(Dictionary dictionary, Translations translati
var udpated = await db.ExecuteAsync("UPDATE lang_dictionaries SET metadata=@metadata::JSONB, fallback=@fallback WHERE dict_id=@dict_id AND source=@source",
new
{
dict_id = dictionary.LangName,
dict_id = dictionary.DictionaryName,
metadata = dictionary.Metadata.ToString(),
fallback = dictionary.Fallback,
source = dictionary.Source
Expand All @@ -161,9 +177,9 @@ public async Task UpdateDictionary(Dictionary dictionary, Translations translati
await db.ExecuteAsync("DELETE FROM lang_translations WHERE dict_id=@dict_id",
new
{
dict_id = dictionary.LangName
dict_id = dictionary.DictionaryName
});
await translations_update(db, dictionary.LangName, translations.Records);
await translations_update(db, dictionary.DictionaryName, translations.Records);
}

internal static async Task translations_update(DbConnection db, string dictId, IEnumerable<KeyValuePair<string, string>> translations)
Expand Down
13 changes: 13 additions & 0 deletions BTCPayServer/Services/Translations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ 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");
Expand Down
19 changes: 19 additions & 0 deletions BTCPayServer/Views/UIServer/CreateDictionary.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@using BTCPayServer.Abstractions.Models
@model CreateDictionaryViewModel
@{
ViewData.SetActivePage(ServerNavPages.Translations);
}

<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<form method="post" class="d-flex flex-column">
<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>
<input asp-for="Fallback" type="hidden" />
<button id="SaveButton" type="submit" class="btn btn-primary" name="command" value="Save">Create new dictionary</button>
</form>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
@using BTCPayServer.Abstractions.Models
@model ServerTranslationsViewModel
@model EditDictionaryViewModel
@{
ViewData.SetActivePage(ServerNavPages.Translations);
}
Expand Down

0 comments on commit 8bc7600

Please sign in to comment.