mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-03 05:34:21 -04:00
Version Fix and Locale Updates (#3626)
This commit is contained in:
parent
b644022f30
commit
a6ccae5849
@ -312,7 +312,6 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
Assert.NotEmpty(_httpTest.CallLog); // HTTP call was made
|
Assert.NotEmpty(_httpTest.CallLog); // HTTP call was made
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount()
|
public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@ -353,7 +352,6 @@ public class VersionUpdaterServiceTests : IDisposable
|
|||||||
Assert.Equal(2 + 1, result); // Behind 0.7.0 and 0.6.0 - We have to add 1 because the current release is > 0.7.0
|
Assert.Equal(2 + 1, result); // Behind 0.7.0 and 0.6.0 - We have to add 1 because the current release is > 0.7.0
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount_WithNightlies()
|
public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount_WithNightlies()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
@ -27,4 +27,8 @@ public static class EasyCacheProfiles
|
|||||||
/// Match Series metadata for Kavita+ metadata download
|
/// Match Series metadata for Kavita+ metadata download
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string KavitaPlusMatchSeries = "kavita+matchSeries";
|
public const string KavitaPlusMatchSeries = "kavita+matchSeries";
|
||||||
|
/// <summary>
|
||||||
|
/// All Locales on the Server
|
||||||
|
/// </summary>
|
||||||
|
public const string LocaleOptions = "locales";
|
||||||
}
|
}
|
||||||
|
@ -193,7 +193,6 @@ public class LibraryController : BaseApiController
|
|||||||
|
|
||||||
var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username).ToList();
|
var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username).ToList();
|
||||||
await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24));
|
await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24));
|
||||||
_logger.LogDebug("Caching libraries for {Key}", cacheKey);
|
|
||||||
|
|
||||||
return Ok(ret.Find(l => l.Id == libraryId));
|
return Ok(ret.Find(l => l.Id == libraryId));
|
||||||
}
|
}
|
||||||
|
@ -2,9 +2,15 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using API.Constants;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using EasyCaching.Core;
|
||||||
|
using Kavita.Common.EnvironmentInfo;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
namespace API.Controllers;
|
namespace API.Controllers;
|
||||||
|
|
||||||
@ -13,43 +19,34 @@ namespace API.Controllers;
|
|||||||
public class LocaleController : BaseApiController
|
public class LocaleController : BaseApiController
|
||||||
{
|
{
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
|
private readonly IEasyCachingProvider _localeCacheProvider;
|
||||||
|
|
||||||
public LocaleController(ILocalizationService localizationService)
|
private static readonly string CacheKey = "locales_" + BuildInfo.Version;
|
||||||
|
|
||||||
|
public LocaleController(ILocalizationService localizationService, IEasyCachingProviderFactory cachingProviderFactory)
|
||||||
{
|
{
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
|
_localeCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.LocaleOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all applicable locales on the server
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This can be cached as it will not change per version.</remarks>
|
||||||
|
/// <returns></returns>
|
||||||
|
[AllowAnonymous]
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public ActionResult<IEnumerable<string>> GetAllLocales()
|
public async Task<ActionResult<IEnumerable<KavitaLocale>>> GetAllLocales()
|
||||||
{
|
{
|
||||||
// Check if temp/locale_map.json exists
|
var result = await _localeCacheProvider.GetAsync<IEnumerable<KavitaLocale>>(CacheKey);
|
||||||
|
if (result.HasValue)
|
||||||
|
{
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
// If not, scan the 2 locale files and calculate empty keys or empty values
|
var ret = _localizationService.GetLocales().Where(l => l.TranslationCompletion > 0f);
|
||||||
|
await _localeCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromDays(7));
|
||||||
|
|
||||||
// Formulate the Locale object with Percentage
|
return Ok();
|
||||||
var languages = _localizationService.GetLocales().Select(c =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var cult = new CultureInfo(c);
|
|
||||||
return new LanguageDto()
|
|
||||||
{
|
|
||||||
Title = cult.DisplayName,
|
|
||||||
IsoCode = cult.IetfLanguageTag
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Some OS' don't have all culture codes supported like PT_BR, thus we need to default
|
|
||||||
return new LanguageDto()
|
|
||||||
{
|
|
||||||
Title = c,
|
|
||||||
IsoCode = c
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.Where(l => !string.IsNullOrEmpty(l.IsoCode))
|
|
||||||
.OrderBy(d => d.Title);
|
|
||||||
return Ok(languages);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -150,7 +150,7 @@ public class UsersController : BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (_localizationService.GetLocales().Contains(preferencesDto.Locale))
|
if (_localizationService.GetLocales().Select(l => l.FileName).Contains(preferencesDto.Locale))
|
||||||
{
|
{
|
||||||
existingPreferences.Locale = preferencesDto.Locale;
|
existingPreferences.Locale = preferencesDto.Locale;
|
||||||
}
|
}
|
||||||
|
@ -85,6 +85,7 @@ public static class ApplicationServiceExtensions
|
|||||||
options.UseInMemory(EasyCacheProfiles.Favicon);
|
options.UseInMemory(EasyCacheProfiles.Favicon);
|
||||||
options.UseInMemory(EasyCacheProfiles.Library);
|
options.UseInMemory(EasyCacheProfiles.Library);
|
||||||
options.UseInMemory(EasyCacheProfiles.RevokedJwt);
|
options.UseInMemory(EasyCacheProfiles.RevokedJwt);
|
||||||
|
options.UseInMemory(EasyCacheProfiles.LocaleOptions);
|
||||||
|
|
||||||
// KavitaPlus stuff
|
// KavitaPlus stuff
|
||||||
options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries);
|
options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries);
|
||||||
|
@ -10,12 +10,21 @@ using Microsoft.Extensions.Hosting;
|
|||||||
namespace API.Services;
|
namespace API.Services;
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
|
public class KavitaLocale
|
||||||
|
{
|
||||||
|
public string FileName { get; set; } // Key
|
||||||
|
public string RenderName { get; set; }
|
||||||
|
public float TranslationCompletion { get; set; }
|
||||||
|
public bool IsRtL { get; set; }
|
||||||
|
public string Hash { get; set; } // ETAG hash so I can run my own localization busting implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public interface ILocalizationService
|
public interface ILocalizationService
|
||||||
{
|
{
|
||||||
Task<string> Get(string locale, string key, params object[] args);
|
Task<string> Get(string locale, string key, params object[] args);
|
||||||
Task<string> Translate(int userId, string key, params object[] args);
|
Task<string> Translate(int userId, string key, params object[] args);
|
||||||
IEnumerable<string> GetLocales();
|
IEnumerable<KavitaLocale> GetLocales();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LocalizationService : ILocalizationService
|
public class LocalizationService : ILocalizationService
|
||||||
@ -134,14 +143,260 @@ public class LocalizationService : ILocalizationService
|
|||||||
/// Returns all available locales that exist on both the Frontend and the Backend
|
/// Returns all available locales that exist on both the Frontend and the Backend
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public IEnumerable<string> GetLocales()
|
public IEnumerable<KavitaLocale> GetLocales()
|
||||||
{
|
{
|
||||||
var uiLanguages = _directoryService
|
var uiLanguages = _directoryService
|
||||||
.GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json")
|
.GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json");
|
||||||
.Select(f => _directoryService.FileSystem.Path.GetFileName(f).Replace(".json", string.Empty));
|
|
||||||
var backendLanguages = _directoryService
|
var backendLanguages = _directoryService
|
||||||
.GetFilesWithExtension(_directoryService.LocalizationDirectory, @"\.json")
|
.GetFilesWithExtension(_directoryService.LocalizationDirectory, @"\.json");
|
||||||
.Select(f => _directoryService.FileSystem.Path.GetFileName(f).Replace(".json", string.Empty));
|
|
||||||
return uiLanguages.Intersect(backendLanguages).Distinct();
|
var locales = new Dictionary<string, KavitaLocale>();
|
||||||
|
var localeCounts = new Dictionary<string, Tuple<int, int>>(); // fileName -> (nonEmptyValues, totalKeys)
|
||||||
|
|
||||||
|
// First pass: collect all files and count non-empty strings
|
||||||
|
|
||||||
|
// Process UI language files
|
||||||
|
foreach (var file in uiLanguages)
|
||||||
|
{
|
||||||
|
var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(file);
|
||||||
|
var fileContent = _directoryService.FileSystem.File.ReadAllText(file);
|
||||||
|
var hash = ComputeHash(fileContent);
|
||||||
|
|
||||||
|
var counts = CalculateNonEmptyStrings(fileContent);
|
||||||
|
|
||||||
|
if (localeCounts.TryGetValue(fileName, out var existingCount))
|
||||||
|
{
|
||||||
|
// Update existing counts
|
||||||
|
localeCounts[fileName] = Tuple.Create(
|
||||||
|
existingCount.Item1 + counts.Item1,
|
||||||
|
existingCount.Item2 + counts.Item2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Add new counts
|
||||||
|
localeCounts[fileName] = counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!locales.TryGetValue(fileName, out var locale))
|
||||||
|
{
|
||||||
|
locales[fileName] = new KavitaLocale
|
||||||
|
{
|
||||||
|
FileName = fileName,
|
||||||
|
RenderName = GetDisplayName(fileName),
|
||||||
|
TranslationCompletion = 0, // Will be calculated later
|
||||||
|
IsRtL = IsRightToLeft(fileName),
|
||||||
|
Hash = hash
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Update existing locale hash
|
||||||
|
locale.Hash = CombineHashes(locale.Hash, hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process backend language files
|
||||||
|
foreach (var file in backendLanguages)
|
||||||
|
{
|
||||||
|
var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(file);
|
||||||
|
var fileContent = _directoryService.FileSystem.File.ReadAllText(file);
|
||||||
|
var hash = ComputeHash(fileContent);
|
||||||
|
|
||||||
|
var counts = CalculateNonEmptyStrings(fileContent);
|
||||||
|
|
||||||
|
if (localeCounts.TryGetValue(fileName, out var existingCount))
|
||||||
|
{
|
||||||
|
// Update existing counts
|
||||||
|
localeCounts[fileName] = Tuple.Create(
|
||||||
|
existingCount.Item1 + counts.Item1,
|
||||||
|
existingCount.Item2 + counts.Item2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Add new counts
|
||||||
|
localeCounts[fileName] = counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!locales.TryGetValue(fileName, out var locale))
|
||||||
|
{
|
||||||
|
locales[fileName] = new KavitaLocale
|
||||||
|
{
|
||||||
|
FileName = fileName,
|
||||||
|
RenderName = GetDisplayName(fileName),
|
||||||
|
TranslationCompletion = 0, // Will be calculated later
|
||||||
|
IsRtL = IsRightToLeft(fileName),
|
||||||
|
Hash = hash
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Update existing locale hash
|
||||||
|
locale.Hash = CombineHashes(locale.Hash, hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: calculate completion percentages based on English total
|
||||||
|
if (localeCounts.TryGetValue("en", out var englishCounts) && englishCounts.Item2 > 0)
|
||||||
|
{
|
||||||
|
var englishTotalKeys = englishCounts.Item2;
|
||||||
|
|
||||||
|
foreach (var locale in locales.Values)
|
||||||
|
{
|
||||||
|
if (localeCounts.TryGetValue(locale.FileName, out var counts))
|
||||||
|
{
|
||||||
|
// Calculate percentage based on English total keys
|
||||||
|
locale.TranslationCompletion = (float)counts.Item1 / englishTotalKeys * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return locales.Values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods that would need to be implemented
|
||||||
|
private static string ComputeHash(string content)
|
||||||
|
{
|
||||||
|
// Implement a hashing algorithm (e.g., SHA256, MD5) to generate a hash for the content
|
||||||
|
using var md5 = System.Security.Cryptography.MD5.Create();
|
||||||
|
var inputBytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||||
|
var hashBytes = md5.ComputeHash(inputBytes);
|
||||||
|
return Convert.ToBase64String(hashBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CombineHashes(string hash1, string hash2)
|
||||||
|
{
|
||||||
|
// Combine two hashes, possibly by concatenating and rehashing
|
||||||
|
return ComputeHash(hash1 + hash2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDisplayName(string fileName)
|
||||||
|
{
|
||||||
|
// Map the filename to a human-readable display name
|
||||||
|
// This could use a lookup table or follow a naming convention
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cultureInfo = new System.Globalization.CultureInfo(fileName);
|
||||||
|
return cultureInfo.NativeName;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fall back to the file name if the culture isn't recognized
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsRightToLeft(string fileName)
|
||||||
|
{
|
||||||
|
// Determine if the language is right-to-left
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cultureInfo = new System.Globalization.CultureInfo(fileName);
|
||||||
|
return cultureInfo.TextInfo.IsRightToLeft;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false; // Default to left-to-right
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float CalculateTranslationCompletion(string fileContent)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var jsonObject = System.Text.Json.JsonDocument.Parse(fileContent);
|
||||||
|
|
||||||
|
int totalKeys = 0;
|
||||||
|
int nonEmptyValues = 0;
|
||||||
|
|
||||||
|
// Count all keys and non-empty values
|
||||||
|
CountNonEmptyValues(jsonObject.RootElement, ref totalKeys, ref nonEmptyValues);
|
||||||
|
|
||||||
|
return totalKeys > 0 ? (nonEmptyValues * 1f) / totalKeys * 100 : 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Consider logging the exception
|
||||||
|
return 0; // Return 0% completion if there's an error parsing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private static Tuple<int, int> CalculateNonEmptyStrings(string fileContent)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var jsonObject = JsonDocument.Parse(fileContent);
|
||||||
|
|
||||||
|
var totalKeys = 0;
|
||||||
|
var nonEmptyValues = 0;
|
||||||
|
|
||||||
|
// Count all keys and non-empty values
|
||||||
|
CountNonEmptyValues(jsonObject.RootElement, ref totalKeys, ref nonEmptyValues);
|
||||||
|
|
||||||
|
return Tuple.Create(nonEmptyValues, totalKeys);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Consider logging the exception
|
||||||
|
return Tuple.Create(0, 0); // Return 0% completion if there's an error parsing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CountNonEmptyValues(JsonElement element, ref int totalKeys, ref int nonEmptyValues)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (property.Value.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||||
|
{
|
||||||
|
totalKeys++;
|
||||||
|
var value = property.Value.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
nonEmptyValues++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Recursively process nested objects
|
||||||
|
CountNonEmptyValues(property.Value, ref totalKeys, ref nonEmptyValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (element.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in element.EnumerateArray())
|
||||||
|
{
|
||||||
|
CountNonEmptyValues(item, ref totalKeys, ref nonEmptyValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CountEntries(System.Text.Json.JsonElement element, ref int total, ref int translated)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
CountEntries(property.Value, ref total, ref translated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (element.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in element.EnumerateArray())
|
||||||
|
{
|
||||||
|
CountEntries(item, ref total, ref translated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (element.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||||
|
{
|
||||||
|
total++;
|
||||||
|
string value = element.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
translated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -399,10 +399,19 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||||||
public async Task<int> GetNumberOfReleasesBehind()
|
public async Task<int> GetNumberOfReleasesBehind()
|
||||||
{
|
{
|
||||||
var updates = await GetAllReleases();
|
var updates = await GetAllReleases();
|
||||||
return updates
|
|
||||||
|
// If the user is on nightly, then we need to handle releases behind differently
|
||||||
|
if (updates[0].IsPrerelease)
|
||||||
|
{
|
||||||
|
return Math.Min(0, updates
|
||||||
|
.TakeWhile(update => update.UpdateVersion != update.CurrentVersion)
|
||||||
|
.Count() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Min(0, updates
|
||||||
.Where(update => !update.IsPrerelease)
|
.Where(update => !update.IsPrerelease)
|
||||||
.TakeWhile(update => update.UpdateVersion != update.CurrentVersion)
|
.TakeWhile(update => update.UpdateVersion != update.CurrentVersion)
|
||||||
.Count();
|
.Count());
|
||||||
}
|
}
|
||||||
|
|
||||||
private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update)
|
private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update)
|
||||||
|
@ -3,3 +3,10 @@ export interface Language {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KavitaLocale {
|
||||||
|
fileName: string; // isoCode aka what maps to the file on disk and what transloco loads
|
||||||
|
renderName: string;
|
||||||
|
translationCompletion: number;
|
||||||
|
isRtL: boolean;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
@ -18,6 +18,7 @@ import {Action} from "./action-factory.service";
|
|||||||
import {CoverImageSize} from "../admin/_models/cover-image-size";
|
import {CoverImageSize} from "../admin/_models/cover-image-size";
|
||||||
import {LicenseInfo} from "../_models/kavitaplus/license-info";
|
import {LicenseInfo} from "../_models/kavitaplus/license-info";
|
||||||
import {LicenseService} from "./license.service";
|
import {LicenseService} from "./license.service";
|
||||||
|
import {LocalizationService} from "./localization.service";
|
||||||
|
|
||||||
export enum Role {
|
export enum Role {
|
||||||
Admin = 'Admin',
|
Admin = 'Admin',
|
||||||
@ -48,6 +49,7 @@ export class AccountService {
|
|||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly licenseService = inject(LicenseService);
|
private readonly licenseService = inject(LicenseService);
|
||||||
|
private readonly localizationService = inject(LocalizationService);
|
||||||
|
|
||||||
baseUrl = environment.apiUrl;
|
baseUrl = environment.apiUrl;
|
||||||
userKey = 'kavita-user';
|
userKey = 'kavita-user';
|
||||||
@ -168,6 +170,8 @@ export class AccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCurrentUser(user?: User, refreshConnections = true) {
|
setCurrentUser(user?: User, refreshConnections = true) {
|
||||||
|
|
||||||
|
const isSameUser = this.currentUser === user;
|
||||||
if (user) {
|
if (user) {
|
||||||
user.roles = [];
|
user.roles = [];
|
||||||
const roles = this.getDecodedToken(user.token).role;
|
const roles = this.getDecodedToken(user.token).role;
|
||||||
@ -197,7 +201,9 @@ export class AccountService {
|
|||||||
// But that really messes everything up
|
// But that really messes everything up
|
||||||
this.messageHub.stopHubConnection();
|
this.messageHub.stopHubConnection();
|
||||||
this.messageHub.createHubConnection(this.currentUser);
|
this.messageHub.createHubConnection(this.currentUser);
|
||||||
this.licenseService.hasValidLicense().subscribe();
|
if (!isSameUser) {
|
||||||
|
this.licenseService.hasValidLicense().subscribe();
|
||||||
|
}
|
||||||
this.startRefreshTokenTimer();
|
this.startRefreshTokenTimer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -316,6 +322,8 @@ export class AccountService {
|
|||||||
|
|
||||||
// Update the locale on disk (for logout and compact-number pipe)
|
// Update the locale on disk (for logout and compact-number pipe)
|
||||||
localStorage.setItem(AccountService.localeKey, this.currentUser.preferences.locale);
|
localStorage.setItem(AccountService.localeKey, this.currentUser.preferences.locale);
|
||||||
|
this.localizationService.refreshTranslations(this.currentUser.preferences.locale);
|
||||||
|
|
||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}), takeUntilDestroyed(this.destroyRef));
|
}), takeUntilDestroyed(this.destroyRef));
|
||||||
|
@ -1,18 +1,36 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import {inject, Injectable} from '@angular/core';
|
||||||
import {environment} from "../../environments/environment";
|
import {environment} from "../../environments/environment";
|
||||||
import { HttpClient } from "@angular/common/http";
|
import { HttpClient } from "@angular/common/http";
|
||||||
import {Language} from "../_models/metadata/language";
|
import {KavitaLocale, Language} from "../_models/metadata/language";
|
||||||
|
import {ReplaySubject, tap} from "rxjs";
|
||||||
|
import {TranslocoService} from "@jsverse/transloco";
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class LocalizationService {
|
export class LocalizationService {
|
||||||
|
|
||||||
|
private readonly translocoService = inject(TranslocoService);
|
||||||
|
|
||||||
baseUrl = environment.apiUrl;
|
baseUrl = environment.apiUrl;
|
||||||
|
|
||||||
|
private readonly localeSubject = new ReplaySubject<KavitaLocale[]>(1);
|
||||||
|
public readonly locales$ = this.localeSubject.asObservable();
|
||||||
|
|
||||||
constructor(private httpClient: HttpClient) { }
|
constructor(private httpClient: HttpClient) { }
|
||||||
|
|
||||||
getLocales() {
|
getLocales() {
|
||||||
return this.httpClient.get<Language[]>(this.baseUrl + 'locale');
|
return this.httpClient.get<KavitaLocale[]>(this.baseUrl + 'locale').pipe(tap(locales => {
|
||||||
|
this.localeSubject.next(locales);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTranslations(lang: string) {
|
||||||
|
|
||||||
|
// Clear the cached translation
|
||||||
|
localStorage.removeItem(`@@TRANSLOCO_PERSIST_TRANSLATIONS/${lang}`);
|
||||||
|
|
||||||
|
// Reload the translation
|
||||||
|
return this.translocoService.load(lang);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,13 +19,12 @@ import {SideNavComponent} from './sidenav/_components/side-nav/side-nav.componen
|
|||||||
import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component";
|
import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component";
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import {ServerService} from "./_services/server.service";
|
import {ServerService} from "./_services/server.service";
|
||||||
import {OutOfDateModalComponent} from "./announcements/_components/out-of-date-modal/out-of-date-modal.component";
|
|
||||||
import {PreferenceNavComponent} from "./sidenav/preference-nav/preference-nav.component";
|
import {PreferenceNavComponent} from "./sidenav/preference-nav/preference-nav.component";
|
||||||
import {Breakpoint, UtilityService} from "./shared/_services/utility.service";
|
import {Breakpoint, UtilityService} from "./shared/_services/utility.service";
|
||||||
import {TranslocoService} from "@jsverse/transloco";
|
import {TranslocoService} from "@jsverse/transloco";
|
||||||
import {User} from "./_models/user";
|
|
||||||
import {VersionService} from "./_services/version.service";
|
import {VersionService} from "./_services/version.service";
|
||||||
import {LicenseService} from "./_services/license.service";
|
import {LicenseService} from "./_services/license.service";
|
||||||
|
import {LocalizationService} from "./_services/localization.service";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@ -36,8 +35,9 @@ import {LicenseService} from "./_services/license.service";
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnInit {
|
export class AppComponent implements OnInit {
|
||||||
|
protected readonly Breakpoint = Breakpoint;
|
||||||
|
|
||||||
|
|
||||||
transitionState$!: Observable<boolean>;
|
|
||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly offcanvas = inject(NgbOffcanvas);
|
private readonly offcanvas = inject(NgbOffcanvas);
|
||||||
@ -53,8 +53,9 @@ export class AppComponent implements OnInit {
|
|||||||
private readonly translocoService = inject(TranslocoService);
|
private readonly translocoService = inject(TranslocoService);
|
||||||
private readonly versionService = inject(VersionService); // Needs to be injected to run background job
|
private readonly versionService = inject(VersionService); // Needs to be injected to run background job
|
||||||
private readonly licenseService = inject(LicenseService);
|
private readonly licenseService = inject(LicenseService);
|
||||||
|
private readonly localizationService = inject(LocalizationService);
|
||||||
|
|
||||||
protected readonly Breakpoint = Breakpoint;
|
transitionState$!: Observable<boolean>;
|
||||||
|
|
||||||
|
|
||||||
constructor(ratingConfig: NgbRatingConfig, modalConfig: NgbModalConfig) {
|
constructor(ratingConfig: NgbRatingConfig, modalConfig: NgbModalConfig) {
|
||||||
@ -112,6 +113,7 @@ export class AppComponent implements OnInit {
|
|||||||
this.setDocHeight();
|
this.setDocHeight();
|
||||||
this.setCurrentUser();
|
this.setCurrentUser();
|
||||||
this.themeService.setColorScape('');
|
this.themeService.setColorScape('');
|
||||||
|
this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,8 +15,8 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template #edit>
|
<ng-template #edit>
|
||||||
<select class="form-select" aria-describedby="global-header" formControlName="locale">
|
<select class="form-select" aria-describedby="global-header" formControlName="locale">
|
||||||
@for(opt of locales; track opt.title) {
|
@for(opt of locales; track opt.renderName) {
|
||||||
<option [value]="opt.isoCode">{{opt.title | titlecase}}</option>
|
<option [value]="opt.fileName">{{opt.renderName | titlecase}} ({{opt.translationCompletion | number:'1.0-1'}}%)</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -21,7 +21,7 @@ import {LocalizationService} from "../../_services/localization.service";
|
|||||||
import {bookColorThemes} from "../../book-reader/_components/reader-settings/reader-settings.component";
|
import {bookColorThemes} from "../../book-reader/_components/reader-settings/reader-settings.component";
|
||||||
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||||
import {User} from "../../_models/user";
|
import {User} from "../../_models/user";
|
||||||
import {Language} from "../../_models/metadata/language";
|
import {KavitaLocale, Language} from "../../_models/metadata/language";
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import {debounceTime, distinctUntilChanged, filter, forkJoin, switchMap, tap} from "rxjs";
|
import {debounceTime, distinctUntilChanged, filter, forkJoin, switchMap, tap} from "rxjs";
|
||||||
import {take} from "rxjs/operators";
|
import {take} from "rxjs/operators";
|
||||||
@ -35,7 +35,7 @@ import {
|
|||||||
NgbAccordionDirective, NgbAccordionHeader,
|
NgbAccordionDirective, NgbAccordionHeader,
|
||||||
NgbAccordionItem, NgbTooltip
|
NgbAccordionItem, NgbTooltip
|
||||||
} from "@ng-bootstrap/ng-bootstrap";
|
} from "@ng-bootstrap/ng-bootstrap";
|
||||||
import {AsyncPipe, NgStyle, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
|
import {AsyncPipe, DecimalPipe, NgStyle, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
|
||||||
import {ColorPickerModule} from "ngx-color-picker";
|
import {ColorPickerModule} from "ngx-color-picker";
|
||||||
import {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component";
|
import {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component";
|
||||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||||
@ -76,7 +76,8 @@ import {LicenseService} from "../../_services/license.service";
|
|||||||
PdfSpreadModePipe,
|
PdfSpreadModePipe,
|
||||||
PdfThemePipe,
|
PdfThemePipe,
|
||||||
PdfScrollModePipe,
|
PdfScrollModePipe,
|
||||||
AsyncPipe
|
AsyncPipe,
|
||||||
|
DecimalPipe
|
||||||
],
|
],
|
||||||
templateUrl: './manage-user-preferences.component.html',
|
templateUrl: './manage-user-preferences.component.html',
|
||||||
styleUrl: './manage-user-preferences.component.scss',
|
styleUrl: './manage-user-preferences.component.scss',
|
||||||
@ -112,7 +113,7 @@ export class ManageUserPreferencesComponent implements OnInit {
|
|||||||
|
|
||||||
|
|
||||||
fontFamilies: Array<string> = [];
|
fontFamilies: Array<string> = [];
|
||||||
locales: Array<Language> = [{title: 'English', isoCode: 'en'}];
|
locales: Array<KavitaLocale> = [];
|
||||||
|
|
||||||
settingsForm: FormGroup = new FormGroup({});
|
settingsForm: FormGroup = new FormGroup({});
|
||||||
user: User | undefined = undefined;
|
user: User | undefined = undefined;
|
||||||
@ -120,7 +121,7 @@ export class ManageUserPreferencesComponent implements OnInit {
|
|||||||
get Locale() {
|
get Locale() {
|
||||||
if (!this.settingsForm.get('locale')) return 'English';
|
if (!this.settingsForm.get('locale')) return 'English';
|
||||||
|
|
||||||
return this.locales.filter(l => l.isoCode === this.settingsForm.get('locale')!.value)[0].title;
|
return this.locales.filter(l => l.fileName === this.settingsForm.get('locale')!.value)[0].renderName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -128,7 +129,7 @@ export class ManageUserPreferencesComponent implements OnInit {
|
|||||||
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
|
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
this.localizationService.getLocales().subscribe(res => {
|
this.localizationService.locales$.subscribe(res => {
|
||||||
this.locales = res;
|
this.locales = res;
|
||||||
|
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
@ -30,6 +30,7 @@ import {LazyLoadImageModule} from "ng-lazyload-image";
|
|||||||
import {getSaver, SAVER} from "./app/_providers/saver.provider";
|
import {getSaver, SAVER} from "./app/_providers/saver.provider";
|
||||||
import {distinctUntilChanged} from "rxjs/operators";
|
import {distinctUntilChanged} from "rxjs/operators";
|
||||||
import {APP_BASE_HREF, PlatformLocation} from "@angular/common";
|
import {APP_BASE_HREF, PlatformLocation} from "@angular/common";
|
||||||
|
import {provideTranslocoDefaultLocale} from "@jsverse/transloco-locale/lib/transloco-locale.providers";
|
||||||
|
|
||||||
const disableAnimations = !('animate' in document.documentElement);
|
const disableAnimations = !('animate' in document.documentElement);
|
||||||
|
|
||||||
@ -112,7 +113,9 @@ const translocoOptions = {
|
|||||||
missingHandler: {
|
missingHandler: {
|
||||||
useFallbackTranslation: true,
|
useFallbackTranslation: true,
|
||||||
allowEmpty: false,
|
allowEmpty: false,
|
||||||
|
logMissingKey: true
|
||||||
},
|
},
|
||||||
|
failedRetries: 2,
|
||||||
} as TranslocoConfig
|
} as TranslocoConfig
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user