Localization - First Pass (#2174)

* Started designing the backend localization service

* Worked in Transloco for initial PoC

* Worked in Transloco for initial PoC

* Translated the login screen

* translated dashboard screen

* Started work on the backend

* Fixed a logic bug

* translated edit-user screen

* Hooked up the backend for having a locale property.

* Hooked up the ability to view the available locales and switch to them.

* Made the localization service languages be derived from what's in langs/ directory.

* Fixed up localization switching

* Switched when we check for a license on UI bootstrap

* Tweaked some code

* Fixed the bug where dashboard wasn't loading and made it so language switching is working.

* Fixed a bug on dashboard with languagePath

* Converted user-scrobble-history.component.html

* Converted spoiler.component.html

* Converted review-series-modal.component.html

* Converted review-card-modal.component.html

* Updated the readme

* Translated using Weblate (English)

Currently translated at 100.0% (54 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/en/

* Converted review-card.component.html

* Deleted dead component

* Converted want-to-read.component.html

* Added translation using Weblate (Korean)

* Translated using Weblate (Spanish)

Currently translated at 40.7% (22 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/es/

* Translated using Weblate (Korean)

Currently translated at 62.9% (34 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/

* Converted user-preferences.component.html

* Translated using Weblate (Korean)

Currently translated at 92.5% (50 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/

* Converted user-holds.component.html

* Converted theme-manager.component.html

* Converted restriction-selector.component.html

* Converted manage-devices.component.html

* Converted edit-device.component.html

* Converted change-password.component.html

* Converted change-email.component.html

* Converted change-age-restriction.component.html

* Converted api-key.component.html

* Converted anilist-key.component.html

* Converted typeahead.component.html

* Converted user-stats-info-cards.component.html

* Converted user-stats.component.html

* Converted top-readers.component.html

* Converted some pipes and ensure translation is loaded before the app.

* Finished all but one pipe for localization

* Converted directory-picker.component.html

* Converted library-access-modal.component.html

* Converted a few components

* Converted a few components

* Converted a few components

* Converted a few components

* Converted a few components

* Merged weblate in

* ... -> … update

* Updated the readme

* Updateded all fonts to be woff2

* Cleaned up some strings to increase re-use

* Removed an old flow (that doesn't exist in backend any longer) from when we introduced emails on Kavita.

* Converted Series detail

* Lots more converted

* Lots more converted & hooked up the ability to flatten during prod build the language files.

* Lots more converted

* Lots more converted & fixed a bunch of broken pipes due to inject()

* Lots more converted

* Lots more converted

* Lots more converted & fixed some bad keys

* Lots more converted

* Fixed some bugs with admin dasbhoard nested tabs not rendering on first load due to not using onpush change detection

* Fixed up some localization errors and fixed forgot password error when the user doesn't have change password permission

* Fixed a stupid build issue again

* Started adding errors for interceptor and backend.

* Finished off manga-reader

* More translations

* Few fixes

* Fixed a bug where character tag badges weren't showing the name on chapter info

* All components are translated

* All toasts are translated

* All confirm/alerts are translated

* Trying something new for the backend

* Migrated the localization strings for the backend into a new file.

* Updated the localization service to be able to do backend localization with fallback to english.

* Cleaned up some external reviews code to reduce looping

* Localized AccountController.cs

* 60% done with controllers

* All controllers are done

* All KavitaExceptions are covered

* Some shakeout fixes

* Prep for initial merge

* Everything is done except options and basic shakeout proves response times are good. Unit tests are broken.

* Fixed up the unit tests

* All unit tests are now working

* Removed some quantifier

* I'm not sure I can support localization for some Volume/Chapter/Book strings within the codebase.

---------

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: majora2007 <kavitareader@gmail.com>
Co-authored-by: expertjun <jtrobin@naver.com>
Co-authored-by: ThePromidius <thepromidiusyt@gmail.com>
This commit is contained in:
Joe Milazzo 2023-08-03 10:33:51 -05:00 committed by GitHub
parent 670bf82c38
commit 3b23d63234
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
389 changed files with 13652 additions and 7925 deletions

View file

@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Xml.Serialization;
using API.Comparators;
using API.Data;
using API.Data.Repositories;
@ -17,7 +18,6 @@ using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using Kavita.Common;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
namespace API.Services;
@ -49,7 +49,7 @@ public interface IReadingListService
/// <summary>
/// Methods responsible for management of Reading Lists
/// </summary>
/// <remarks>If called from API layer, expected for <see cref="UserHasReadingListAccess(int, String)"/> to be called beforehand</remarks>
/// <remarks>If called from API layer, expected for <see cref="UserHasReadingListAccess(int, string)"/> to be called beforehand</remarks>
public class ReadingListService : IReadingListService
{
private readonly IUnitOfWork _unitOfWork;
@ -69,13 +69,13 @@ public class ReadingListService : IReadingListService
public static string FormatTitle(ReadingListItemDto item)
{
var title = string.Empty;
if (item.ChapterNumber == Tasks.Scanner.Parser.Parser.DefaultChapter && item.VolumeNumber != Tasks.Scanner.Parser.Parser.DefaultVolume) {
if (item.ChapterNumber == Parser.DefaultChapter && item.VolumeNumber != Parser.DefaultVolume) {
title = $"Volume {item.VolumeNumber}";
}
if (item.SeriesFormat == MangaFormat.Epub) {
var specialTitle = Tasks.Scanner.Parser.Parser.CleanSpecialTitle(item.ChapterNumber);
if (specialTitle == Tasks.Scanner.Parser.Parser.DefaultChapter)
var specialTitle = Parser.CleanSpecialTitle(item.ChapterNumber);
if (specialTitle == Parser.DefaultChapter)
{
if (!string.IsNullOrEmpty(item.ChapterTitleName))
{
@ -83,7 +83,7 @@ public class ReadingListService : IReadingListService
}
else
{
title = $"Volume {Tasks.Scanner.Parser.Parser.CleanSpecialTitle(item.VolumeNumber)}";
title = $"Volume {Parser.CleanSpecialTitle(item.VolumeNumber)}";
}
} else {
title = $"Volume {specialTitle}";
@ -92,12 +92,12 @@ public class ReadingListService : IReadingListService
var chapterNum = item.ChapterNumber;
if (!string.IsNullOrEmpty(chapterNum) && !JustNumbers.Match(item.ChapterNumber).Success) {
chapterNum = Tasks.Scanner.Parser.Parser.CleanSpecialTitle(item.ChapterNumber);
chapterNum = Parser.CleanSpecialTitle(item.ChapterNumber);
}
if (title != string.Empty) return title;
if (item.ChapterNumber == Tasks.Scanner.Parser.Parser.DefaultChapter &&
if (item.ChapterNumber == Parser.DefaultChapter &&
!string.IsNullOrEmpty(item.ChapterTitleName))
{
title = item.ChapterTitleName;
@ -124,13 +124,13 @@ public class ReadingListService : IReadingListService
var hasExisting = userWithReadingList.ReadingLists.Any(l => l.Title.Equals(title));
if (hasExisting)
{
throw new KavitaException("A list of this name already exists");
throw new KavitaException("reading-list-name-exists");
}
var readingList = new ReadingListBuilder(title).Build();
userWithReadingList.ReadingLists.Add(readingList);
if (!_unitOfWork.HasChanges()) throw new KavitaException("There was a problem creating list");
if (!_unitOfWork.HasChanges()) throw new KavitaException("generic-reading-list-create");
await _unitOfWork.CommitAsync();
return readingList;
}
@ -144,10 +144,10 @@ public class ReadingListService : IReadingListService
public async Task UpdateReadingList(ReadingList readingList, UpdateReadingListDto dto)
{
dto.Title = dto.Title.Trim();
if (string.IsNullOrEmpty(dto.Title)) throw new KavitaException("Title must be set");
if (string.IsNullOrEmpty(dto.Title)) throw new KavitaException("reading-list-title-required");
if (!dto.Title.Equals(readingList.Title) && await _unitOfWork.ReadingListRepository.ReadingListExists(dto.Title))
throw new KavitaException("Reading list already exists");
throw new KavitaException("reading-list-name-exists");
readingList.Summary = dto.Summary;
readingList.Title = dto.Title.Trim();
@ -192,7 +192,7 @@ public class ReadingListService : IReadingListService
/// <summary>
/// Removes all entries that are fully read from the reading list. This commits
/// </summary>
/// <remarks>If called from API layer, expected for <see cref="UserHasReadingListAccess(int, String)"/> to be called beforehand</remarks>
/// <remarks>If called from API layer, expected for <see cref="UserHasReadingListAccess(int, string)"/> to be called beforehand</remarks>
/// <param name="readingListId">Reading List Id</param>
/// <param name="user">User</param>
/// <returns></returns>
@ -404,7 +404,7 @@ public class ReadingListService : IReadingListService
var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet();
var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds, ChapterIncludes.Volumes))
.OrderBy(c => Tasks.Scanner.Parser.Parser.MinNumberFromRange(c.Volume.Name))
.OrderBy(c => Parser.MinNumberFromRange(c.Volume.Name))
.ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting)
.ToList();
@ -529,7 +529,7 @@ public class ReadingListService : IReadingListService
/// <param name="cblReading"></param>
public async Task<CblImportSummaryDto> ValidateCblFile(int userId, CblReadingList cblReading)
{
var importSummary = new CblImportSummaryDto()
var importSummary = new CblImportSummaryDto
{
CblName = cblReading.Name,
Success = CblImportResult.Success,
@ -542,20 +542,20 @@ public class ReadingListService : IReadingListService
if (await _unitOfWork.ReadingListRepository.ReadingListExists(cblReading.Name))
{
importSummary.Success = CblImportResult.Fail;
importSummary.Results.Add(new CblBookResult()
importSummary.Results.Add(new CblBookResult
{
Reason = CblImportReason.NameConflict,
ReadingListName = cblReading.Name
});
}
var uniqueSeries = cblReading.Books.Book.Select(b => Tasks.Scanner.Parser.Parser.Normalize(b.Series)).Distinct().ToList();
var uniqueSeries = cblReading.Books.Book.Select(b => Parser.Normalize(b.Series)).Distinct().ToList();
var userSeries =
(await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList();
if (!userSeries.Any())
{
// Report that no series exist in the reading list
importSummary.Results.Add(new CblBookResult()
importSummary.Results.Add(new CblBookResult
{
Reason = CblImportReason.AllSeriesMissing
});
@ -569,7 +569,7 @@ public class ReadingListService : IReadingListService
importSummary.Success = CblImportResult.Fail;
foreach (var conflict in conflicts)
{
importSummary.Results.Add(new CblBookResult()
importSummary.Results.Add(new CblBookResult
{
Reason = CblImportReason.SeriesCollision,
Series = conflict.Name,
@ -593,7 +593,7 @@ public class ReadingListService : IReadingListService
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.ReadingListsWithItems);
_logger.LogDebug("Importing {ReadingListName} CBL for User {UserName}", cblReading.Name, user!.UserName);
var importSummary = new CblImportSummaryDto()
var importSummary = new CblImportSummaryDto
{
CblName = cblReading.Name,
Success = CblImportResult.Success,
@ -601,13 +601,13 @@ public class ReadingListService : IReadingListService
SuccessfulInserts = new List<CblBookResult>()
};
var uniqueSeries = cblReading.Books.Book.Select(b => Tasks.Scanner.Parser.Parser.Normalize(b.Series)).Distinct().ToList();
var uniqueSeries = cblReading.Books.Book.Select(b => Parser.Normalize(b.Series)).Distinct().ToList();
var userSeries =
(await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList();
var allSeries = userSeries.ToDictionary(s => Tasks.Scanner.Parser.Parser.Normalize(s.Name));
var allSeriesLocalized = userSeries.ToDictionary(s => Tasks.Scanner.Parser.Parser.Normalize(s.LocalizedName));
var allSeries = userSeries.ToDictionary(s => Parser.Normalize(s.Name));
var allSeriesLocalized = userSeries.ToDictionary(s => Parser.Normalize(s.LocalizedName));
var readingListNameNormalized = Tasks.Scanner.Parser.Parser.Normalize(cblReading.Name);
var readingListNameNormalized = Parser.Normalize(cblReading.Name);
// Get all the user's reading lists
var allReadingLists = (user.ReadingLists).ToDictionary(s => s.NormalizedTitle);
if (!allReadingLists.TryGetValue(readingListNameNormalized, out var readingList))
@ -620,7 +620,7 @@ public class ReadingListService : IReadingListService
// Reading List exists, check if we own it
if (user.ReadingLists.All(l => l.NormalizedTitle != readingListNameNormalized))
{
importSummary.Results.Add(new CblBookResult()
importSummary.Results.Add(new CblBookResult
{
Reason = CblImportReason.NameConflict
});
@ -632,7 +632,7 @@ public class ReadingListService : IReadingListService
readingList.Items ??= new List<ReadingListItem>();
foreach (var (book, i) in cblReading.Books.Book.Select((value, i) => ( value, i )))
{
var normalizedSeries = Tasks.Scanner.Parser.Parser.Normalize(book.Series);
var normalizedSeries = Parser.Normalize(book.Series);
if (!allSeries.TryGetValue(normalizedSeries, out var bookSeries) && !allSeriesLocalized.TryGetValue(normalizedSeries, out bookSeries))
{
importSummary.Results.Add(new CblBookResult(book)
@ -644,7 +644,7 @@ public class ReadingListService : IReadingListService
}
// Prioritize lookup by Volume then Chapter, but allow fallback to just Chapter
var bookVolume = string.IsNullOrEmpty(book.Volume)
? Tasks.Scanner.Parser.Parser.DefaultVolume
? Parser.DefaultVolume
: book.Volume;
var matchingVolume = bookSeries.Volumes.Find(v => bookVolume == v.Name) ?? bookSeries.Volumes.Find(v => v.Number == 0);
if (matchingVolume == null)
@ -660,7 +660,7 @@ public class ReadingListService : IReadingListService
// We need to handle chapter 0 or empty string when it's just a volume
var bookNumber = string.IsNullOrEmpty(book.Number)
? Tasks.Scanner.Parser.Parser.DefaultChapter
? Parser.DefaultChapter
: book.Number;
var chapter = matchingVolume.Chapters.FirstOrDefault(c => c.Number == bookNumber);
if (chapter == null)
@ -720,7 +720,7 @@ public class ReadingListService : IReadingListService
private static IList<Series> FindCblImportConflicts(IEnumerable<Series> userSeries)
{
var dict = new HashSet<string>();
return userSeries.Where(series => !dict.Add(Tasks.Scanner.Parser.Parser.Normalize(series.Name))).ToList();
return userSeries.Where(series => !dict.Add(Parser.Normalize(series.Name))).ToList();
}
private static bool IsCblEmpty(CblReadingList cblReading, CblImportSummaryDto importSummary,
@ -729,7 +729,7 @@ public class ReadingListService : IReadingListService
readingListFromCbl = new CblImportSummaryDto();
if (cblReading.Books == null || cblReading.Books.Book.Count == 0)
{
importSummary.Results.Add(new CblBookResult()
importSummary.Results.Add(new CblBookResult
{
Reason = CblImportReason.EmptyFile
});
@ -755,7 +755,7 @@ public class ReadingListService : IReadingListService
public static CblReadingList LoadCblFromPath(string path)
{
var reader = new System.Xml.Serialization.XmlSerializer(typeof(CblReadingList));
var reader = new XmlSerializer(typeof(CblReadingList));
using var file = new StreamReader(path);
var cblReadingList = (CblReadingList) reader.Deserialize(file);
file.Close();