diff --git a/API.Tests/Services/VersionUpdaterServiceTests.cs b/API.Tests/Services/VersionUpdaterServiceTests.cs index d3cdb4412..9132db4df 100644 --- a/API.Tests/Services/VersionUpdaterServiceTests.cs +++ b/API.Tests/Services/VersionUpdaterServiceTests.cs @@ -312,7 +312,6 @@ public class VersionUpdaterServiceTests : IDisposable Assert.NotEmpty(_httpTest.CallLog); // HTTP call was made } - [Fact] public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount() { // 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 } - [Fact] public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount_WithNightlies() { // Arrange diff --git a/API/Constants/CacheProfiles.cs b/API/Constants/CacheProfiles.cs index 4953c5568..ccbbf2479 100644 --- a/API/Constants/CacheProfiles.cs +++ b/API/Constants/CacheProfiles.cs @@ -27,4 +27,8 @@ public static class EasyCacheProfiles /// Match Series metadata for Kavita+ metadata download /// public const string KavitaPlusMatchSeries = "kavita+matchSeries"; + /// + /// All Locales on the Server + /// + public const string LocaleOptions = "locales"; } diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index d3d6b8375..9604592c7 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -193,7 +193,6 @@ public class LibraryController : BaseApiController var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username).ToList(); await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24)); - _logger.LogDebug("Caching libraries for {Key}", cacheKey); return Ok(ret.Find(l => l.Id == libraryId)); } diff --git a/API/Controllers/LocaleController.cs b/API/Controllers/LocaleController.cs index 9190c72cb..e6e85658c 100644 --- a/API/Controllers/LocaleController.cs +++ b/API/Controllers/LocaleController.cs @@ -2,9 +2,15 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Threading.Tasks; +using API.Constants; using API.DTOs.Filtering; using API.Services; +using EasyCaching.Core; +using Kavita.Common.EnvironmentInfo; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; namespace API.Controllers; @@ -13,43 +19,34 @@ namespace API.Controllers; public class LocaleController : BaseApiController { 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; + _localeCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.LocaleOptions); } + /// + /// Returns all applicable locales on the server + /// + /// This can be cached as it will not change per version. + /// + [AllowAnonymous] [HttpGet] - public ActionResult> GetAllLocales() + public async Task>> GetAllLocales() { - // Check if temp/locale_map.json exists + var result = await _localeCacheProvider.GetAsync>(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 - 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); + return Ok(); } } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index b5d7826c1..944ea987b 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -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; } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index adabb436e..7afdf4ace 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -85,6 +85,7 @@ public static class ApplicationServiceExtensions options.UseInMemory(EasyCacheProfiles.Favicon); options.UseInMemory(EasyCacheProfiles.Library); options.UseInMemory(EasyCacheProfiles.RevokedJwt); + options.UseInMemory(EasyCacheProfiles.LocaleOptions); // KavitaPlus stuff options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries); diff --git a/API/Services/LocalizationService.cs b/API/Services/LocalizationService.cs index ab3ad3d89..3bc3cf3b2 100644 --- a/API/Services/LocalizationService.cs +++ b/API/Services/LocalizationService.cs @@ -10,12 +10,21 @@ using Microsoft.Extensions.Hosting; namespace API.Services; #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 { Task Get(string locale, string key, params object[] args); Task Translate(int userId, string key, params object[] args); - IEnumerable GetLocales(); + IEnumerable GetLocales(); } 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 /// /// - public IEnumerable GetLocales() + public IEnumerable GetLocales() { var uiLanguages = _directoryService - .GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json") - .Select(f => _directoryService.FileSystem.Path.GetFileName(f).Replace(".json", string.Empty)); + .GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json"); var backendLanguages = _directoryService - .GetFilesWithExtension(_directoryService.LocalizationDirectory, @"\.json") - .Select(f => _directoryService.FileSystem.Path.GetFileName(f).Replace(".json", string.Empty)); - return uiLanguages.Intersect(backendLanguages).Distinct(); + .GetFilesWithExtension(_directoryService.LocalizationDirectory, @"\.json"); + + var locales = new Dictionary(); + var localeCounts = new Dictionary>(); // 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 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++; + } + } } } diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index 96ef50fdd..fc332c4eb 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -399,10 +399,19 @@ public partial class VersionUpdaterService : IVersionUpdaterService public async Task GetNumberOfReleasesBehind() { 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) .TakeWhile(update => update.UpdateVersion != update.CurrentVersion) - .Count(); + .Count()); } private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update) diff --git a/UI/Web/src/app/_models/metadata/language.ts b/UI/Web/src/app/_models/metadata/language.ts index e8f606bec..8b68c7233 100644 --- a/UI/Web/src/app/_models/metadata/language.ts +++ b/UI/Web/src/app/_models/metadata/language.ts @@ -3,3 +3,10 @@ export interface Language { 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; +} diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 5da1cce6a..32b127a1f 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -18,6 +18,7 @@ import {Action} from "./action-factory.service"; import {CoverImageSize} from "../admin/_models/cover-image-size"; import {LicenseInfo} from "../_models/kavitaplus/license-info"; import {LicenseService} from "./license.service"; +import {LocalizationService} from "./localization.service"; export enum Role { Admin = 'Admin', @@ -48,6 +49,7 @@ export class AccountService { private readonly destroyRef = inject(DestroyRef); private readonly licenseService = inject(LicenseService); + private readonly localizationService = inject(LocalizationService); baseUrl = environment.apiUrl; userKey = 'kavita-user'; @@ -168,6 +170,8 @@ export class AccountService { } setCurrentUser(user?: User, refreshConnections = true) { + + const isSameUser = this.currentUser === user; if (user) { user.roles = []; const roles = this.getDecodedToken(user.token).role; @@ -197,7 +201,9 @@ export class AccountService { // But that really messes everything up this.messageHub.stopHubConnection(); this.messageHub.createHubConnection(this.currentUser); - this.licenseService.hasValidLicense().subscribe(); + if (!isSameUser) { + this.licenseService.hasValidLicense().subscribe(); + } this.startRefreshTokenTimer(); } } @@ -316,6 +322,8 @@ export class AccountService { // Update the locale on disk (for logout and compact-number pipe) localStorage.setItem(AccountService.localeKey, this.currentUser.preferences.locale); + this.localizationService.refreshTranslations(this.currentUser.preferences.locale); + } return settings; }), takeUntilDestroyed(this.destroyRef)); diff --git a/UI/Web/src/app/_services/localization.service.ts b/UI/Web/src/app/_services/localization.service.ts index 0c4e2bdbf..7519a9562 100644 --- a/UI/Web/src/app/_services/localization.service.ts +++ b/UI/Web/src/app/_services/localization.service.ts @@ -1,18 +1,36 @@ -import { Injectable } from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import {environment} from "../../environments/environment"; 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({ providedIn: 'root' }) export class LocalizationService { + private readonly translocoService = inject(TranslocoService); + baseUrl = environment.apiUrl; + private readonly localeSubject = new ReplaySubject(1); + public readonly locales$ = this.localeSubject.asObservable(); + constructor(private httpClient: HttpClient) { } getLocales() { - return this.httpClient.get(this.baseUrl + 'locale'); + return this.httpClient.get(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); } } diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index 1c7a960ca..13b87588f 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -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 {takeUntilDestroyed} from "@angular/core/rxjs-interop"; 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 {Breakpoint, UtilityService} from "./shared/_services/utility.service"; import {TranslocoService} from "@jsverse/transloco"; -import {User} from "./_models/user"; import {VersionService} from "./_services/version.service"; import {LicenseService} from "./_services/license.service"; +import {LocalizationService} from "./_services/localization.service"; @Component({ selector: 'app-root', @@ -36,8 +35,9 @@ import {LicenseService} from "./_services/license.service"; changeDetection: ChangeDetectionStrategy.OnPush }) export class AppComponent implements OnInit { + protected readonly Breakpoint = Breakpoint; + - transitionState$!: Observable; private readonly destroyRef = inject(DestroyRef); private readonly offcanvas = inject(NgbOffcanvas); @@ -53,8 +53,9 @@ export class AppComponent implements OnInit { private readonly translocoService = inject(TranslocoService); private readonly versionService = inject(VersionService); // Needs to be injected to run background job private readonly licenseService = inject(LicenseService); + private readonly localizationService = inject(LocalizationService); - protected readonly Breakpoint = Breakpoint; + transitionState$!: Observable; constructor(ratingConfig: NgbRatingConfig, modalConfig: NgbModalConfig) { @@ -112,6 +113,7 @@ export class AppComponent implements OnInit { this.setDocHeight(); this.setCurrentUser(); this.themeService.setColorScape(''); + this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup } diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html index f199fb93a..8e380b8f8 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html @@ -15,8 +15,8 @@ diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts index 8b190fbdd..c3190ce93 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts @@ -21,7 +21,7 @@ import {LocalizationService} from "../../_services/localization.service"; import {bookColorThemes} from "../../book-reader/_components/reader-settings/reader-settings.component"; import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; 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 {debounceTime, distinctUntilChanged, filter, forkJoin, switchMap, tap} from "rxjs"; import {take} from "rxjs/operators"; @@ -35,7 +35,7 @@ import { NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem, NgbTooltip } 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 {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; @@ -76,7 +76,8 @@ import {LicenseService} from "../../_services/license.service"; PdfSpreadModePipe, PdfThemePipe, PdfScrollModePipe, - AsyncPipe + AsyncPipe, + DecimalPipe ], templateUrl: './manage-user-preferences.component.html', styleUrl: './manage-user-preferences.component.scss', @@ -112,7 +113,7 @@ export class ManageUserPreferencesComponent implements OnInit { fontFamilies: Array = []; - locales: Array = [{title: 'English', isoCode: 'en'}]; + locales: Array = []; settingsForm: FormGroup = new FormGroup({}); user: User | undefined = undefined; @@ -120,7 +121,7 @@ export class ManageUserPreferencesComponent implements OnInit { get Locale() { 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.cdRef.markForCheck(); - this.localizationService.getLocales().subscribe(res => { + this.localizationService.locales$.subscribe(res => { this.locales = res; this.cdRef.markForCheck(); diff --git a/UI/Web/src/main.ts b/UI/Web/src/main.ts index ca877b40c..58b5ba2a0 100644 --- a/UI/Web/src/main.ts +++ b/UI/Web/src/main.ts @@ -30,6 +30,7 @@ import {LazyLoadImageModule} from "ng-lazyload-image"; import {getSaver, SAVER} from "./app/_providers/saver.provider"; import {distinctUntilChanged} from "rxjs/operators"; import {APP_BASE_HREF, PlatformLocation} from "@angular/common"; +import {provideTranslocoDefaultLocale} from "@jsverse/transloco-locale/lib/transloco-locale.providers"; const disableAnimations = !('animate' in document.documentElement); @@ -112,7 +113,9 @@ const translocoOptions = { missingHandler: { useFallbackTranslation: true, allowEmpty: false, + logMissingKey: true }, + failedRetries: 2, } as TranslocoConfig };