diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 941468045..f1419f298 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -268,7 +268,7 @@ jobs: cd UI/Web || exit echo 'Installing web dependencies' - npm ci + npm install --legacy-peer-deps echo 'Building UI' npm run prod diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs index a6f36aada..7bc001faf 100644 --- a/API.Tests/Services/ReadingListServiceTests.cs +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -806,7 +806,7 @@ public class ReadingListServiceTests } catch (Exception ex) { - Assert.Equal("A list of this name already exists", ex.Message); + Assert.Equal("reading-list-name-exists", ex.Message); } Assert.Single((await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists)) .ReadingLists); @@ -834,7 +834,7 @@ public class ReadingListServiceTests } catch (Exception ex) { - Assert.Equal("A list of this name already exists", ex.Message); + Assert.Equal("reading-list-name-exists", ex.Message); } } @@ -860,7 +860,7 @@ public class ReadingListServiceTests } catch (Exception ex) { - Assert.Equal("A list of this name already exists", ex.Message); + Assert.Equal("reading-list-name-exists", ex.Message); } Assert.Single((await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists)) .ReadingLists); diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index db7a03267..759577bc1 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO.Abstractions; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -19,21 +20,48 @@ using API.SignalR; using API.Tests.Helpers; using Hangfire; using Hangfire.InMemory; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting.Internal; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; namespace API.Tests.Services; +internal class MockHostingEnvironment : IHostEnvironment { + public string ApplicationName { get => "API"; set => throw new NotImplementedException(); } + public IFileProvider ContentRootFileProvider { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public string ContentRootPath + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public string EnvironmentName { get => "Testing"; set => throw new NotImplementedException(); } +} + + public class SeriesServiceTests : AbstractDbTest { private readonly ISeriesService _seriesService; public SeriesServiceTests() : base() { + var ds = new DirectoryService(Substitute.For>(), new FileSystem() + { + + }); + + + var locService = new LocalizationService(ds, new MockHostingEnvironment(), + Substitute.For(), Substitute.For()); + _seriesService = new SeriesService(_unitOfWork, Substitute.For(), Substitute.For(), Substitute.For>(), - Substitute.For()); + Substitute.For(), locService); } #region Setup @@ -1194,9 +1222,19 @@ public class SeriesServiceTests : AbstractDbTest [InlineData(LibraryType.Comic, false, "Issue")] [InlineData(LibraryType.Comic, true, "Issue #")] [InlineData(LibraryType.Book, false, "Book")] - public void FormatChapterNameTest(LibraryType libraryType, bool withHash, string expected ) + public async Task FormatChapterNameTest(LibraryType libraryType, bool withHash, string expected ) { - Assert.Equal(expected, SeriesService.FormatChapterName(libraryType, withHash)); + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + .WithLocale("en") + .Build()) + .Build()); + + await _context.SaveChangesAsync(); + + Assert.Equal(expected, await _seriesService.FormatChapterName(1, libraryType, withHash)); } #endregion @@ -1204,59 +1242,132 @@ public class SeriesServiceTests : AbstractDbTest #region FormatChapterTitle [Fact] - public void FormatChapterTitle_Manga_NonSpecial() + public async Task FormatChapterTitle_Manga_NonSpecial() { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + .WithLocale("en") + .Build()) + .Build()); + + await _context.SaveChangesAsync(); + var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(false).Build(); - Assert.Equal("Chapter Some title", SeriesService.FormatChapterTitle(chapter, LibraryType.Manga, false)); + Assert.Equal("Chapter Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Manga, false)); } [Fact] - public void FormatChapterTitle_Manga_Special() + public async Task FormatChapterTitle_Manga_Special() { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + .WithLocale("en") + .Build()) + .Build()); + + await _context.SaveChangesAsync(); var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(true).Build(); - Assert.Equal("Some title", SeriesService.FormatChapterTitle(chapter, LibraryType.Manga, false)); + Assert.Equal("Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Manga, false)); } [Fact] - public void FormatChapterTitle_Comic_NonSpecial_WithoutHash() + public async Task FormatChapterTitle_Comic_NonSpecial_WithoutHash() { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + .WithLocale("en") + .Build()) + .Build()); + + await _context.SaveChangesAsync(); var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(false).Build(); - Assert.Equal("Issue Some title", SeriesService.FormatChapterTitle(chapter, LibraryType.Comic, false)); + Assert.Equal("Issue Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Comic, false)); } [Fact] - public void FormatChapterTitle_Comic_Special_WithoutHash() + public async Task FormatChapterTitle_Comic_Special_WithoutHash() { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + .WithLocale("en") + .Build()) + .Build()); + + await _context.SaveChangesAsync(); var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(true).Build(); - Assert.Equal("Some title", SeriesService.FormatChapterTitle(chapter, LibraryType.Comic, false)); + Assert.Equal("Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Comic, false)); } [Fact] - public void FormatChapterTitle_Comic_NonSpecial_WithHash() + public async Task FormatChapterTitle_Comic_NonSpecial_WithHash() { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + .WithLocale("en") + .Build()) + .Build()); + + await _context.SaveChangesAsync(); var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(false).Build(); - Assert.Equal("Issue #Some title", SeriesService.FormatChapterTitle(chapter, LibraryType.Comic, true)); + Assert.Equal("Issue #Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Comic, true)); } [Fact] - public void FormatChapterTitle_Comic_Special_WithHash() + public async Task FormatChapterTitle_Comic_Special_WithHash() { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + .WithLocale("en") + .Build()) + .Build()); + + await _context.SaveChangesAsync(); var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(true).Build(); - Assert.Equal("Some title", SeriesService.FormatChapterTitle(chapter, LibraryType.Comic, true)); + Assert.Equal("Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Comic, true)); } [Fact] - public void FormatChapterTitle_Book_NonSpecial() + public async Task FormatChapterTitle_Book_NonSpecial() { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + .WithLocale("en") + .Build()) + .Build()); + + await _context.SaveChangesAsync(); var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(false).Build(); - Assert.Equal("Book Some title", SeriesService.FormatChapterTitle(chapter, LibraryType.Book, false)); + Assert.Equal("Book Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Book, false)); } [Fact] - public void FormatChapterTitle_Book_Special() + public async Task FormatChapterTitle_Book_Special() { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + .WithLocale("en") + .Build()) + .Build()); + + await _context.SaveChangesAsync(); var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(true).Build(); - Assert.Equal("Some title", SeriesService.FormatChapterTitle(chapter, LibraryType.Book, false)); + Assert.Equal("Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Book, false)); } #endregion diff --git a/API/API.csproj b/API/API.csproj index 435dc59e4..513ab182b 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -191,6 +191,7 @@ + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index c021571ee..cfb956a09 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -14,12 +14,9 @@ using API.Entities.Enums; using API.Errors; using API.Extensions; using API.Helpers.Builders; -using API.Middleware.RateLimit; using API.Services; -using API.Services.Plus; using API.SignalR; using AutoMapper; -using EasyCaching.Core; using Hangfire; using Kavita.Common; using Kavita.Common.EnvironmentInfo; @@ -46,6 +43,7 @@ public class AccountController : BaseApiController private readonly IAccountService _accountService; private readonly IEmailService _emailService; private readonly IEventHub _eventHub; + private readonly ILocalizationService _localizationService; /// public AccountController(UserManager userManager, @@ -53,7 +51,8 @@ public class AccountController : BaseApiController ITokenService tokenService, IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, IAccountService accountService, - IEmailService emailService, IEventHub eventHub) + IEmailService emailService, IEventHub eventHub, + ILocalizationService localizationService) { _userManager = userManager; _signInManager = signInManager; @@ -64,6 +63,7 @@ public class AccountController : BaseApiController _accountService = accountService; _emailService = emailService; _eventHub = eventHub; + _localizationService = localizationService; } /// @@ -82,19 +82,21 @@ public class AccountController : BaseApiController var isAdmin = User.IsInRole(PolicyConstants.AdminRole); if (resetPasswordDto.UserName == User.GetUsername() && !(User.IsInRole(PolicyConstants.ChangePasswordRole) || isAdmin)) - return Unauthorized("You are not permitted to this operation."); + return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); if (resetPasswordDto.UserName != User.GetUsername() && !isAdmin) - return Unauthorized("You are not permitted to this operation."); + return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); if (string.IsNullOrEmpty(resetPasswordDto.OldPassword) && !isAdmin) - return BadRequest(new ApiException(400, "You must enter your existing password to change your account unless you're an admin")); + return BadRequest( + new ApiException(400, + await _localizationService.Translate(User.GetUserId(), "password-required"))); // If you're an admin and the username isn't yours, you don't need to validate the password var isResettingOtherUser = (resetPasswordDto.UserName != User.GetUsername() && isAdmin); if (!isResettingOtherUser && !await _userManager.CheckPasswordAsync(user, resetPasswordDto.OldPassword)) { - return BadRequest("Invalid Password"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-password")); } var errors = await _accountService.ChangeUserPassword(user, resetPasswordDto.Password); @@ -117,7 +119,7 @@ public class AccountController : BaseApiController public async Task> RegisterFirstUser(RegisterDto registerDto) { var admins = await _userManager.GetUsersInRoleAsync("Admin"); - if (admins.Count > 0) return BadRequest("Not allowed"); + if (admins.Count > 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "denied")); try { @@ -135,8 +137,8 @@ public class AccountController : BaseApiController if (!result.Succeeded) return BadRequest(result.Errors); var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); - if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue generating a confirmation token."); - if (!await ConfirmEmailToken(token, user)) return BadRequest($"There was an issue validating your email: {token}"); + if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "confirm-token-gen")); + if (!await ConfirmEmailToken(token, user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "validate-email", token)); var roleResult = await _userManager.AddToRoleAsync(user, PolicyConstants.AdminRole); @@ -163,7 +165,7 @@ public class AccountController : BaseApiController await _unitOfWork.CommitAsync(); } - return BadRequest("Something went wrong when registering user"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "register-user")); } @@ -180,9 +182,9 @@ public class AccountController : BaseApiController .Include(u => u.UserPreferences) .SingleOrDefaultAsync(x => x.NormalizedUserName == loginDto.Username.ToUpper()); - if (user == null) return Unauthorized("Your credentials are not correct"); + if (user == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "bad-credentials")); var roles = await _userManager.GetRolesAsync(user); - if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized("Your account is disabled. Contact the server admin."); + if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "disabled-account")); var result = await _signInManager .CheckPasswordSignInAsync(user, loginDto.Password, true); @@ -190,12 +192,12 @@ public class AccountController : BaseApiController if (result.IsLockedOut) { await _userManager.UpdateSecurityStampAsync(user); - return Unauthorized("You've been locked out from too many authorization attempts. Please wait 10 minutes."); + return Unauthorized(await _localizationService.Translate(User.GetUserId(), "locked-out")); } if (!result.Succeeded) { - return Unauthorized(result.IsNotAllowed ? "You must confirm your email first" : "Your credentials are not correct"); + return Unauthorized(await _localizationService.Translate(User.GetUserId(), result.IsNotAllowed ? "confirm-email" : "bad-credentials")); } // Update LastActive on account @@ -256,7 +258,7 @@ public class AccountController : BaseApiController var token = await _tokenService.ValidateRefreshToken(tokenRequestDto); if (token == null) { - return Unauthorized(new { message = "Invalid token" }); + return Unauthorized(new { message = await _localizationService.Translate(User.GetUserId(), "invalid-token") }); } return Ok(token); @@ -295,7 +297,7 @@ public class AccountController : BaseApiController } await _unitOfWork.RollbackAsync(); - return BadRequest("Something went wrong, unable to reset key"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "unable-to-reset-key")); } @@ -310,26 +312,27 @@ public class AccountController : BaseApiController public async Task UpdateEmail(UpdateEmailDto? dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (user == null) return Unauthorized("You do not have permission"); + if (user == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); - if (dto == null || string.IsNullOrEmpty(dto.Email) || string.IsNullOrEmpty(dto.Password)) return BadRequest("Invalid payload"); + if (dto == null || string.IsNullOrEmpty(dto.Email) || string.IsNullOrEmpty(dto.Password)) + return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-payload")); // Validate this user's password if (! await _userManager.CheckPasswordAsync(user, dto.Password)) { _logger.LogCritical("A user tried to change {UserName}'s email, but password didn't validate", user.UserName); - return BadRequest("You do not have permission"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); } // Validate no other users exist with this email - if (user.Email!.Equals(dto.Email)) return Ok("Nothing to do"); + if (user.Email!.Equals(dto.Email)) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); // Check if email is used by another user var existingUserEmail = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); if (existingUserEmail != null) { - return BadRequest("You cannot share emails across multiple accounts"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "share-multiple-emails")); } // All validations complete, generate a new token and email it to the user at the new address. Confirm email link will update the email @@ -337,7 +340,7 @@ public class AccountController : BaseApiController if (string.IsNullOrEmpty(token)) { _logger.LogError("There was an issue generating a token for the email"); - return BadRequest("There was an issue creating a confirmation email token. See logs."); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generate-token")); } user.EmailConfirmed = false; @@ -392,10 +395,10 @@ public class AccountController : BaseApiController public async Task UpdateAgeRestriction(UpdateAgeRestrictionDto dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (user == null) return Unauthorized("You do not have permission"); + if (user == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - if (!await _accountService.HasChangeRestrictionRole(user)) return BadRequest("You do not have permission"); + if (!await _accountService.HasChangeRestrictionRole(user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); user.AgeRestriction = isAdmin ? AgeRating.NotApplicable : dto.AgeRating; user.AgeRestrictionIncludeUnknowns = isAdmin || dto.IncludeUnknowns; @@ -410,7 +413,7 @@ public class AccountController : BaseApiController catch (Exception ex) { _logger.LogError(ex, "There was an error updating the age restriction"); - return BadRequest("There was an error updating the age restriction"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "age-restriction-update")); } await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName!), user.Id); @@ -429,17 +432,17 @@ public class AccountController : BaseApiController { var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (adminUser == null) return Unauthorized(); - if (!await _unitOfWork.UserRepository.IsUserAdminAsync(adminUser)) return Unauthorized("You do not have permission"); + if (!await _unitOfWork.UserRepository.IsUserAdminAsync(adminUser)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId); - if (user == null) return BadRequest("User does not exist"); + if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user")); // Check if username is changing if (!user.UserName!.Equals(dto.Username)) { // Validate username change var errors = await _accountService.ValidateUsername(dto.Username); - if (errors.Any()) return BadRequest("Username already taken"); + if (errors.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "username-taken")); user.UserName = dto.Username; _unitOfWork.UserRepository.Update(user); } @@ -504,7 +507,7 @@ public class AccountController : BaseApiController } await _unitOfWork.RollbackAsync(); - return BadRequest("There was an exception when updating the user"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-user-update")); } /// @@ -520,9 +523,9 @@ public class AccountController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null) return Unauthorized(); if (user.EmailConfirmed) - return BadRequest("User is already confirmed"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-confirmed")); if (string.IsNullOrEmpty(user.ConfirmationToken)) - return BadRequest("Manual setup is unable to be completed. Please cancel and recreate the invite."); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "manual-setup-fail")); return await _accountService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email", user.Email!, withBaseUrl); } @@ -539,7 +542,7 @@ public class AccountController : BaseApiController public async Task> InviteUser(InviteUserDto dto) { var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (adminUser == null) return Unauthorized("You are not permitted"); + if (adminUser == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); _logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email); @@ -552,8 +555,8 @@ public class AccountController : BaseApiController { var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); if (await _userManager.IsEmailConfirmedAsync(invitedUser!)) - return BadRequest($"User is already registered as {invitedUser!.UserName}"); - return BadRequest("User is already invited under this email and has yet to accepted invite."); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-registered", invitedUser!.UserName)); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-invited")); } } @@ -608,7 +611,7 @@ public class AccountController : BaseApiController if (string.IsNullOrEmpty(token)) { _logger.LogError("There was an issue generating a token for the email"); - return BadRequest("There was an creating the invite user"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-invite-user")); } user.ConfirmationToken = token; @@ -650,7 +653,7 @@ public class AccountController : BaseApiController _logger.LogError(ex, "There was an error during invite user flow, unable to send an email"); } - return BadRequest("There was an error setting up your account. Please check the logs"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-invite-user")); } /// @@ -667,7 +670,7 @@ public class AccountController : BaseApiController if (user == null) { _logger.LogInformation("confirm-email failed from invalid registered email: {Email}", dto.Email); - return BadRequest("Invalid email confirmation"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-email-confirmation")); } // Validate Password and Username @@ -688,7 +691,7 @@ public class AccountController : BaseApiController if (!await ConfirmEmailToken(dto.Token, user)) { _logger.LogInformation("confirm-email failed from invalid token: {Token}", dto.Token); - return BadRequest("Invalid email confirmation"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-email-confirmation")); } user.UserName = dto.Username; @@ -731,13 +734,13 @@ public class AccountController : BaseApiController if (user == null) { _logger.LogInformation("confirm-email failed from invalid registered email: {Email}", dto.Email); - return BadRequest("Invalid email confirmation"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-email-confirmation")); } if (!await ConfirmEmailToken(dto.Token, user)) { _logger.LogInformation("confirm-email failed from invalid token: {Token}", dto.Token); - return BadRequest("Invalid email confirmation"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-email-confirmation")); } _logger.LogInformation("User is updating email from {OldEmail} to {NewEmail}", user.Email, dto.Email); @@ -745,7 +748,7 @@ public class AccountController : BaseApiController if (!result.Succeeded) { _logger.LogError("Unable to update email for users: {Errors}", result.Errors.Select(e => e.Description)); - return BadRequest("Unable to update email for user. Check logs"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-user-email-update")); } user.ConfirmationToken = null; await _unitOfWork.CommitAsync(); @@ -768,7 +771,7 @@ public class AccountController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); if (user == null) { - return BadRequest("Invalid credentials"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "bad-credentials")); } var result = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, @@ -776,16 +779,16 @@ public class AccountController : BaseApiController if (!result) { _logger.LogInformation("Unable to reset password, your email token is not correct: {@Dto}", dto); - return BadRequest("Invalid credentials"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "bad-credentials")); } var errors = await _accountService.ChangeUserPassword(user, dto.Password); - return errors.Any() ? BadRequest(errors) : Ok("Password updated"); + return errors.Any() ? BadRequest(errors) : Ok(await _localizationService.Translate(User.GetUserId(), "password-updated")); } catch (Exception ex) { _logger.LogError(ex, "There was an unexpected error when confirming new password"); - return BadRequest("There was an unexpected error when confirming new password"); + return BadRequest("generic-password-update"); } } @@ -804,15 +807,15 @@ public class AccountController : BaseApiController if (user == null) { _logger.LogError("There are no users with email: {Email} but user is requesting password reset", email); - return Ok("An email will be sent to the email if it exists in our database"); + return Ok(await _localizationService.Translate(User.GetUserId(), "forgot-password-generic")); } var roles = await _userManager.GetRolesAsync(user); if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole)) - return Unauthorized("You are not permitted to this operation."); + return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); if (string.IsNullOrEmpty(user.Email) || !user.EmailConfirmed) - return BadRequest("You do not have an email on account or it has not been confirmed"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "confirm-email")); var token = await _userManager.GeneratePasswordResetTokenAsync(user); var emailLink = await _accountService.GenerateEmailLink(Request, token, "confirm-reset-password", user.Email); @@ -825,10 +828,10 @@ public class AccountController : BaseApiController ServerConfirmationLink = emailLink, InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value }); - return Ok("Email sent"); + return Ok(await _localizationService.Translate(User.GetUserId(), "email-sent")); } - return Ok("Your server is not accessible. The Link to reset your password is in the logs."); + return Ok(await _localizationService.Translate(User.GetUserId(), "not-accessible-password")); } [HttpGet("email-confirmed")] @@ -845,12 +848,12 @@ public class AccountController : BaseApiController public async Task> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto) { var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); - if (user == null) return BadRequest("Invalid credentials"); + if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "bad-credentials")); if (!await ConfirmEmailToken(dto.Token, user)) { _logger.LogInformation("confirm-migration-email email token is invalid"); - return BadRequest("Invalid credentials"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "bad-credentials")); } await _unitOfWork.CommitAsync(); @@ -881,12 +884,12 @@ public class AccountController : BaseApiController public async Task> ResendConfirmationSendEmail([FromQuery] int userId) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - if (user == null) return BadRequest("User does not exist"); + if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user")); if (string.IsNullOrEmpty(user.Email)) return BadRequest( - "This user needs to migrate. Have them log out and login to trigger a migration flow"); - if (user.EmailConfirmed) return BadRequest("User already confirmed"); + await _localizationService.Translate(User.GetUserId(), "user-migration-needed")); + if (user.EmailConfirmed) return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-confirmed")); var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); var emailLink = await _accountService.GenerateEmailLink(Request, token, "confirm-email", user.Email); @@ -907,12 +910,12 @@ public class AccountController : BaseApiController catch (Exception ex) { _logger.LogError(ex, "There was an issue resending invite email"); - return BadRequest("There was an issue resending invite email"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-invite-email")); } return Ok(emailLink); } - return Ok("The server is not accessible externally"); + return Ok(await _localizationService.Translate(User.GetUserId(), "not-accessible")); } /// @@ -926,7 +929,7 @@ public class AccountController : BaseApiController { // If there is an admin account already, return var users = await _unitOfWork.UserRepository.GetAdminUsersAsync(); - if (users.Any()) return BadRequest("Admin already exists"); + if (users.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "admin-already-exists")); // Check if there is an existing invite var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); @@ -934,27 +937,27 @@ public class AccountController : BaseApiController { var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); if (await _userManager.IsEmailConfirmedAsync(invitedUser!)) - return BadRequest($"User is already registered as {invitedUser!.UserName}"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-registered", invitedUser!.UserName)); _logger.LogInformation("A user is attempting to login, but hasn't accepted email invite"); - return BadRequest("User is already invited under this email and has yet to accepted invite."); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-invited")); } var user = await _userManager.Users .Include(u => u.UserPreferences) .SingleOrDefaultAsync(x => x.NormalizedUserName == dto.Username.ToUpper()); - if (user == null) return BadRequest("Invalid username"); + if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-username")); var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, dto.Password); - if (!validPassword) return BadRequest("Your credentials are not correct"); + if (!validPassword) return BadRequest(await _localizationService.Translate(User.GetUserId(), "bad-credentials")); try { var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); user.Email = dto.Email; - if (!await ConfirmEmailToken(token, user)) return BadRequest("There was a critical error during migration"); + if (!await ConfirmEmailToken(token, user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "critical-email-migration")); _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); @@ -968,7 +971,7 @@ public class AccountController : BaseApiController await _unitOfWork.CommitAsync(); } - return BadRequest("There was an error setting up your account. Please check the logs"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "critical-email-migration")); } diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index f64bc2d55..81065960a 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs.Reader; using API.Entities.Enums; +using API.Extensions; using API.Services; using Kavita.Common; using Microsoft.AspNetCore.Authorization; @@ -18,13 +19,16 @@ public class BookController : BaseApiController private readonly IBookService _bookService; private readonly IUnitOfWork _unitOfWork; private readonly ICacheService _cacheService; + private readonly ILocalizationService _localizationService; public BookController(IBookService bookService, - IUnitOfWork unitOfWork, ICacheService cacheService) + IUnitOfWork unitOfWork, ICacheService cacheService, + ILocalizationService localizationService) { _bookService = bookService; _unitOfWork = unitOfWork; _cacheService = cacheService; + _localizationService = localizationService; } /// @@ -37,7 +41,7 @@ public class BookController : BaseApiController public async Task> GetBookInfo(int chapterId) { var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); - if (dto == null) return BadRequest("Chapter does not exist"); + if (dto == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var bookTitle = string.Empty; switch (dto.SeriesFormat) { @@ -92,14 +96,14 @@ public class BookController : BaseApiController [AllowAnonymous] public async Task GetBookPageResources(int chapterId, [FromQuery] string file) { - if (chapterId <= 0) return BadRequest("Chapter is not valid"); + if (chapterId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - if (chapter == null) return BadRequest("Chapter is not valid"); + if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions); var key = BookService.CoalesceKeyForAnyFile(book, file); - if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest("File was not found in book"); + if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "file-missing")); var bookFile = book.Content.AllFiles.GetLocalFileRefByKey(key); var content = await bookFile.ReadContentAsBytesAsync(); @@ -118,9 +122,9 @@ public class BookController : BaseApiController [HttpGet("{chapterId}/chapters")] public async Task>> GetBookChapters(int chapterId) { - if (chapterId <= 0) return BadRequest("Chapter is not valid"); + if (chapterId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - if (chapter == null) return BadRequest("Chapter is not valid"); + if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); try { @@ -144,7 +148,7 @@ public class BookController : BaseApiController public async Task> GetBookPage(int chapterId, [FromQuery] int page) { var chapter = await _cacheService.Ensure(chapterId); - if (chapter == null) return BadRequest("Could not find Chapter"); + if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var path = _cacheService.GetCachedFile(chapter); var baseUrl = "//" + Request.Host + Request.PathBase + "/api/"; @@ -155,7 +159,7 @@ public class BookController : BaseApiController } catch (KavitaException ex) { - return BadRequest(ex.Message); + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } } } diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index e6dc91f5b..8d3abb3ad 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -20,12 +20,15 @@ public class CollectionController : BaseApiController { private readonly IUnitOfWork _unitOfWork; private readonly ICollectionTagService _collectionService; + private readonly ILocalizationService _localizationService; /// - public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService) + public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService, + ILocalizationService localizationService) { _unitOfWork = unitOfWork; _collectionService = collectionService; + _localizationService = localizationService; } /// @@ -87,14 +90,14 @@ public class CollectionController : BaseApiController { try { - if (await _collectionService.UpdateTag(updatedTag)) return Ok("Tag updated successfully"); + if (await _collectionService.UpdateTag(updatedTag)) return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully")); } catch (KavitaException ex) { - return BadRequest(ex.Message); + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } - return BadRequest("Something went wrong, please try again"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); } /// @@ -111,7 +114,7 @@ public class CollectionController : BaseApiController if (await _collectionService.AddTagToSeries(tag, dto.SeriesIds)) return Ok(); - return BadRequest("There was an issue updating series with collection tag"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); } /// @@ -126,18 +129,17 @@ public class CollectionController : BaseApiController try { var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updateSeriesForTagDto.Tag.Id, CollectionTagIncludes.SeriesMetadata); - if (tag == null) return BadRequest("Not a valid Tag"); + if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); tag.SeriesMetadatas ??= new List(); if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove)) - return Ok("Tag updated"); + return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated")); } catch (Exception) { await _unitOfWork.RollbackAsync(); } - - return BadRequest("Something went wrong. Please try again."); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); } } diff --git a/API/Controllers/DeviceController.cs b/API/Controllers/DeviceController.cs index ac209593f..90b3723d7 100644 --- a/API/Controllers/DeviceController.cs +++ b/API/Controllers/DeviceController.cs @@ -21,13 +21,16 @@ public class DeviceController : BaseApiController private readonly IDeviceService _deviceService; private readonly IEmailService _emailService; private readonly IEventHub _eventHub; + private readonly ILocalizationService _localizationService; - public DeviceController(IUnitOfWork unitOfWork, IDeviceService deviceService, IEmailService emailService, IEventHub eventHub) + public DeviceController(IUnitOfWork unitOfWork, IDeviceService deviceService, + IEmailService emailService, IEventHub eventHub, ILocalizationService localizationService) { _unitOfWork = unitOfWork; _deviceService = deviceService; _emailService = emailService; _eventHub = eventHub; + _localizationService = localizationService; } @@ -36,9 +39,19 @@ public class DeviceController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices); if (user == null) return Unauthorized(); - var device = await _deviceService.Create(dto, user); + try + { + var device = await _deviceService.Create(dto, user); + if (device == null) + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-device-create")); + } + catch (KavitaException ex) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); + } + + - if (device == null) return BadRequest("There was an error when creating the device"); return Ok(); } @@ -50,7 +63,7 @@ public class DeviceController : BaseApiController if (user == null) return Unauthorized(); var device = await _deviceService.Update(dto, user); - if (device == null) return BadRequest("There was an error when updating the device"); + if (device == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-device-update")); return Ok(); } @@ -63,12 +76,12 @@ public class DeviceController : BaseApiController [HttpDelete] public async Task DeleteDevice(int deviceId) { - if (deviceId <= 0) return BadRequest("Not a valid deviceId"); + if (deviceId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "device-doesnt-exist")); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices); if (user == null) return Unauthorized(); if (await _deviceService.Delete(user, deviceId)) return Ok(); - return BadRequest("Could not delete device"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-device-delete")); } [HttpGet] @@ -81,15 +94,16 @@ public class DeviceController : BaseApiController [HttpPost("send-to")] public async Task SendToDevice(SendToDeviceDto dto) { - if (dto.ChapterIds.Any(i => i < 0)) return BadRequest("ChapterIds must be greater than 0"); - if (dto.DeviceId < 0) return BadRequest("DeviceId must be greater than 0"); + if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "ChapterIds")); + if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId")); if (await _emailService.IsDefaultEmailService()) - return BadRequest("Send to device cannot be used with Kavita's email service. Please configure your own."); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email")); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, - MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "started"), userId); + MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"), + "started"), userId); try { var success = await _deviceService.SendTo(dto.ChapterIds, dto.DeviceId); @@ -97,15 +111,16 @@ public class DeviceController : BaseApiController } catch (KavitaException ex) { - return BadRequest(ex.Message); + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } finally { await _eventHub.SendMessageToAsync(MessageFactory.SendingToDevice, - MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "ended"), userId); + MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"), + "ended"), userId); } - return BadRequest("There was an error sending the file to the device"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-send-to")); } @@ -113,19 +128,21 @@ public class DeviceController : BaseApiController [HttpPost("send-series-to")] public async Task SendSeriesToDevice(SendSeriesToDeviceDto dto) { - if (dto.SeriesId <= 0) return BadRequest("SeriesId must be greater than 0"); - if (dto.DeviceId < 0) return BadRequest("DeviceId must be greater than 0"); + if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "SeriesId")); + if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId")); if (await _emailService.IsDefaultEmailService()) - return BadRequest("Send to device cannot be used with Kavita's email service. Please configure your own."); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email")); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "started"), userId); + await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, + MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"), + "started"), userId); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, SeriesIncludes.Volumes | SeriesIncludes.Chapters); - if (series == null) return BadRequest("Series doesn't Exist"); + if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist")); var chapterIds = series.Volumes.SelectMany(v => v.Chapters.Select(c => c.Id)).ToList(); try { @@ -134,14 +151,16 @@ public class DeviceController : BaseApiController } catch (KavitaException ex) { - return BadRequest(ex.Message); + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } finally { - await _eventHub.SendMessageToAsync(MessageFactory.SendingToDevice, MessageFactory.SendingToDeviceEvent($"Transferring files to your device", "ended"), userId); + await _eventHub.SendMessageToAsync(MessageFactory.SendingToDevice, + MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"), + "ended"), userId); } - return BadRequest("There was an error sending the file(s) to the device"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-send-to")); } diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index 419bd3d97..edfda64f6 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -30,11 +30,12 @@ public class DownloadController : BaseApiController private readonly ILogger _logger; private readonly IBookmarkService _bookmarkService; private readonly IAccountService _accountService; + private readonly ILocalizationService _localizationService; private const string DefaultContentType = "application/octet-stream"; public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, IDownloadService downloadService, IEventHub eventHub, ILogger logger, IBookmarkService bookmarkService, - IAccountService accountService) + IAccountService accountService, ILocalizationService localizationService) { _unitOfWork = unitOfWork; _archiveService = archiveService; @@ -44,6 +45,7 @@ public class DownloadController : BaseApiController _logger = logger; _bookmarkService = bookmarkService; _accountService = accountService; + _localizationService = localizationService; } /// @@ -92,9 +94,9 @@ public class DownloadController : BaseApiController [HttpGet("volume")] public async Task DownloadVolume(int volumeId) { - if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); + if (!await HasDownloadPermission()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId); - if (volume == null) return BadRequest("Volume doesn't exist"); + if (volume == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "volume-doesnt-exist")); var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); try @@ -128,10 +130,10 @@ public class DownloadController : BaseApiController [HttpGet("chapter")] public async Task DownloadChapter(int chapterId) { - if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); + if (!await HasDownloadPermission()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - if (chapter == null) return BadRequest("Invalid chapter"); + if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapter.VolumeId); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume!.SeriesId); try @@ -178,7 +180,7 @@ public class DownloadController : BaseApiController [HttpGet("series")] public async Task DownloadSeries(int seriesId) { - if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); + if (!await HasDownloadPermission()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); if (series == null) return BadRequest("Invalid Series"); var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); @@ -200,8 +202,8 @@ public class DownloadController : BaseApiController [HttpPost("bookmarks")] public async Task DownloadBookmarkPages(DownloadBookmarkDto downloadBookmarkDto) { - if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); - if (!downloadBookmarkDto.Bookmarks.Any()) return BadRequest("Bookmarks cannot be empty"); + if (!await HasDownloadPermission()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (!downloadBookmarkDto.Bookmarks.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmarks-empty")); // We know that all bookmarks will be for one single seriesId var userId = User.GetUserId()!; diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 5de2c95cc..a8c9ebd88 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -22,13 +22,16 @@ public class ImageController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IDirectoryService _directoryService; private readonly IImageService _imageService; + private readonly ILocalizationService _localizationService; /// - public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService, IImageService imageService) + public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService, + IImageService imageService, ILocalizationService localizationService) { _unitOfWork = unitOfWork; _directoryService = directoryService; _imageService = imageService; + _localizationService = localizationService; } /// @@ -42,7 +45,7 @@ public class ImageController : BaseApiController { if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest(); var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId)); - if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-cover-image")); var format = _directoryService.FileSystem.Path.GetExtension(path); return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path)); @@ -59,7 +62,7 @@ public class ImageController : BaseApiController { if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest(); var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.LibraryRepository.GetLibraryCoverImageAsync(libraryId)); - if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-cover-image")); var format = _directoryService.FileSystem.Path.GetExtension(path); return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path)); @@ -76,7 +79,7 @@ public class ImageController : BaseApiController { if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest(); var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId)); - if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-cover-image")); var format = _directoryService.FileSystem.Path.GetExtension(path); return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path)); @@ -93,7 +96,7 @@ public class ImageController : BaseApiController { if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest(); var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.SeriesRepository.GetSeriesCoverImageAsync(seriesId)); - if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-cover-image")); var format = _directoryService.FileSystem.Path.GetExtension(path); Response.AddCacheHeader(path); @@ -115,7 +118,7 @@ public class ImageController : BaseApiController if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) { var destFile = await GenerateCollectionCoverImage(collectionTagId); - if (string.IsNullOrEmpty(destFile)) return BadRequest("No cover image"); + if (string.IsNullOrEmpty(destFile)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-cover-image")); return PhysicalFile(destFile, MimeTypeMap.GetMimeType(_directoryService.FileSystem.Path.GetExtension(destFile)), _directoryService.FileSystem.Path.GetFileName(destFile)); } var format = _directoryService.FileSystem.Path.GetExtension(path); @@ -137,7 +140,7 @@ public class ImageController : BaseApiController if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) { var destFile = await GenerateReadingListCoverImage(readingListId); - if (string.IsNullOrEmpty(destFile)) return BadRequest("No cover image"); + if (string.IsNullOrEmpty(destFile)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-cover-image")); return PhysicalFile(destFile, MimeTypeMap.GetMimeType(_directoryService.FileSystem.Path.GetExtension(destFile)), _directoryService.FileSystem.Path.GetFileName(destFile)); } @@ -199,7 +202,7 @@ public class ImageController : BaseApiController var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); if (userId == 0) return BadRequest(); var bookmark = await _unitOfWork.UserRepository.GetBookmarkForPage(pageNum, chapterId, userId); - if (bookmark == null) return BadRequest("Bookmark does not exist"); + if (bookmark == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-doesnt-exist")); var bookmarkDirectory = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; @@ -220,7 +223,7 @@ public class ImageController : BaseApiController { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); if (userId == 0) return BadRequest(); - if (string.IsNullOrEmpty(url)) return BadRequest("Url cannot be null"); + if (string.IsNullOrEmpty(url)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "must-be-defined", "Url")); var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs; // Check if the domain exists @@ -235,7 +238,7 @@ public class ImageController : BaseApiController } catch (Exception) { - return BadRequest("There was an issue fetching favicon for domain"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-favicon")); } } @@ -256,10 +259,11 @@ public class ImageController : BaseApiController public async Task GetCoverUploadImage(string filename, string apiKey) { if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest(); - if (filename.Contains("..")) return BadRequest("Invalid Filename"); + if (filename.Contains("..")) return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-filename")); var path = Path.Join(_directoryService.TempDirectory, filename); - if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"File does not exist"); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) + return BadRequest(await _localizationService.Translate(User.GetUserId(), "file-doesnt-exist")); var format = _directoryService.FileSystem.Path.GetExtension(path); return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path)); diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 16b8f948d..3c49de62b 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -36,13 +36,14 @@ public class LibraryController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; private readonly ILibraryWatcher _libraryWatcher; + private readonly ILocalizationService _localizationService; private readonly IEasyCachingProvider _libraryCacheProvider; private const string CacheKey = "library_"; public LibraryController(IDirectoryService directoryService, ILogger logger, IMapper mapper, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IEventHub eventHub, ILibraryWatcher libraryWatcher, - IEasyCachingProviderFactory cachingProviderFactory) + IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService) { _directoryService = directoryService; _logger = logger; @@ -51,6 +52,7 @@ public class LibraryController : BaseApiController _unitOfWork = unitOfWork; _eventHub = eventHub; _libraryWatcher = libraryWatcher; + _localizationService = localizationService; _libraryCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.Library); } @@ -66,7 +68,7 @@ public class LibraryController : BaseApiController { if (await _unitOfWork.LibraryRepository.LibraryExists(dto.Name)) { - return BadRequest("Library name already exists. Please choose a unique name to the server."); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-name-exists")); } var library = new LibraryBuilder(dto.Name, dto.Type) @@ -96,7 +98,7 @@ public class LibraryController : BaseApiController } - if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical issue. Please try again."); + if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library")); _logger.LogInformation("Created a new library: {LibraryName}", library.Name); await _libraryWatcher.RestartWatching(); @@ -160,7 +162,8 @@ public class LibraryController : BaseApiController public async Task>> GetJumpBar(int libraryId) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - if (!await _unitOfWork.UserRepository.HasAccessToLibrary(libraryId, userId)) return BadRequest("User does not have access to library"); + if (!await _unitOfWork.UserRepository.HasAccessToLibrary(libraryId, userId)) + return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-library-access")); return Ok(_unitOfWork.LibraryRepository.GetJumpBarAsync(libraryId)); } @@ -175,9 +178,9 @@ public class LibraryController : BaseApiController public async Task> UpdateUserLibraries(UpdateLibraryForUserDto updateLibraryForUserDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(updateLibraryForUserDto.Username); - if (user == null) return BadRequest("Could not validate user"); + if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-doesnt-exist")); - var libraryString = string.Join(",", updateLibraryForUserDto.SelectedLibraries.Select(x => x.Name)); + var libraryString = string.Join(',', updateLibraryForUserDto.SelectedLibraries.Select(x => x.Name)); _logger.LogInformation("Granting user {UserName} access to: {Libraries}", updateLibraryForUserDto.Username, libraryString); var allLibraries = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); @@ -195,7 +198,6 @@ public class LibraryController : BaseApiController { library.AppUsers.Add(user); } - } if (!_unitOfWork.HasChanges()) @@ -213,7 +215,7 @@ public class LibraryController : BaseApiController } - return BadRequest("There was a critical issue. Please try again."); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library")); } /// @@ -224,9 +226,9 @@ public class LibraryController : BaseApiController /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("scan")] - public ActionResult Scan(int libraryId, bool force = false) + public async Task Scan(int libraryId, bool force = false) { - if (libraryId <= 0) return BadRequest("Invalid libraryId"); + if (libraryId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "libraryId")); _taskScheduler.ScanLibrary(libraryId, force); return Ok(); } @@ -277,7 +279,7 @@ public class LibraryController : BaseApiController var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); if (!isAdmin) return BadRequest("API key must belong to an admin"); - if (dto.FolderPath.Contains("..")) return BadRequest("Invalid Path"); + if (dto.FolderPath.Contains("..")) return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-path")); dto.FolderPath = Services.Tasks.Scanner.Parser.Parser.NormalizePath(dto.FolderPath); @@ -310,12 +312,11 @@ public class LibraryController : BaseApiController if (TaskScheduler.HasScanTaskRunningForLibrary(libraryId)) { _logger.LogInformation("User is attempting to delete a library while a scan is in progress"); - return BadRequest( - "You cannot delete a library while a scan is in progress. Please wait for scan to complete or restart Kavita then try to delete"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "delete-library-while-scan")); } var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); - if (library == null) return BadRequest("Library no longer exists"); + if (library == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-doesnt-exist")); // Due to a bad schema that I can't figure out how to fix, we need to erase all RelatedSeries before we delete the library // Aka SeriesRelation has an invalid foreign key @@ -354,7 +355,7 @@ public class LibraryController : BaseApiController } catch (Exception ex) { - _logger.LogError(ex, "There was a critical error trying to delete the library"); + _logger.LogError(ex, await _localizationService.Translate(User.GetUserId(), "generic-library")); await _unitOfWork.RollbackAsync(); return Ok(false); } @@ -384,11 +385,11 @@ public class LibraryController : BaseApiController public async Task UpdateLibrary(UpdateLibraryDto dto) { var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(dto.Id, LibraryIncludes.Folders); - if (library == null) return BadRequest("Library doesn't exist"); + if (library == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-doesnt-exist")); var newName = dto.Name.Trim(); if (await _unitOfWork.LibraryRepository.LibraryExists(newName) && !library.Name.Equals(newName)) - return BadRequest("Library name already exists"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-name-exists")); var originalFolders = library.Folders.Select(x => x.Path).ToList(); @@ -416,7 +417,7 @@ public class LibraryController : BaseApiController _unitOfWork.LibraryRepository.Update(library); - if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical issue updating the library."); + if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library-update")); if (originalFolders.Count != dto.Folders.Count() || typeUpdate) { await _libraryWatcher.RestartWatching(); diff --git a/API/Controllers/LicenseController.cs b/API/Controllers/LicenseController.cs index e0280f78c..e02a16b48 100644 --- a/API/Controllers/LicenseController.cs +++ b/API/Controllers/LicenseController.cs @@ -5,6 +5,8 @@ using API.Data; using API.DTOs.Account; using API.DTOs.License; using API.Entities.Enums; +using API.Extensions; +using API.Services; using API.Services.Plus; using Kavita.Common; using Microsoft.AspNetCore.Authorization; @@ -18,13 +20,15 @@ public class LicenseController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly ILicenseService _licenseService; + private readonly ILocalizationService _localizationService; public LicenseController(IUnitOfWork unitOfWork, ILogger logger, - ILicenseService licenseService) + ILicenseService licenseService, ILocalizationService localizationService) { _unitOfWork = unitOfWork; _logger = logger; _licenseService = licenseService; + _localizationService = localizationService; } /// @@ -73,7 +77,14 @@ public class LicenseController : BaseApiController [HttpPost] public async Task UpdateLicense(UpdateLicenseDto dto) { - await _licenseService.AddLicense(dto.License.Trim(), dto.Email.Trim()); + try + { + await _licenseService.AddLicense(dto.License.Trim(), dto.Email.Trim()); + } + catch (Exception ex) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); + } return Ok(); } } diff --git a/API/Controllers/LocaleController.cs b/API/Controllers/LocaleController.cs new file mode 100644 index 000000000..a9ca35ce9 --- /dev/null +++ b/API/Controllers/LocaleController.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using API.DTOs.Filtering; +using API.Services; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +public class LocaleController : BaseApiController +{ + private readonly ILocalizationService _localizationService; + + public LocaleController(ILocalizationService localizationService) + { + _localizationService = localizationService; + } + + [HttpGet] + public ActionResult> GetAllLocales() + { + var languages = _localizationService.GetLocales().Select(c => new CultureInfo(c)).Select(c => + new LanguageDto() + { + Title = c.DisplayName, + IsoCode = c.IetfLanguageTag + }).Where(l => !string.IsNullOrEmpty(l.IsoCode)); + return Ok(languages); + } +} diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index b5479dee0..a5d4996ff 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -10,6 +10,7 @@ using API.DTOs.Filtering; using API.DTOs.Metadata; using API.Entities.Enums; using API.Extensions; +using API.Services; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Mvc; @@ -19,10 +20,12 @@ namespace API.Controllers; public class MetadataController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; - public MetadataController(IUnitOfWork unitOfWork) + public MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService) { _unitOfWork = unitOfWork; + _localizationService = localizationService; } /// @@ -35,7 +38,7 @@ public class MetadataController : BaseApiController public async Task>> GetAllGenres(string? libraryIds) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var ids = libraryIds?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); if (ids != null && ids.Count > 0) { return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, userId)); @@ -56,7 +59,7 @@ public class MetadataController : BaseApiController public async Task>> GetAllPeople(string? libraryIds) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var ids = libraryIds?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); if (ids != null && ids.Count > 0) { return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, userId)); @@ -74,7 +77,7 @@ public class MetadataController : BaseApiController public async Task>> GetAllTags(string? libraryIds) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var ids = libraryIds?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); if (ids != null && ids.Count > 0) { return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, userId)); @@ -92,7 +95,7 @@ public class MetadataController : BaseApiController [HttpGet("age-ratings")] public async Task>> GetAllAgeRatings(string? libraryIds) { - var ids = libraryIds?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); if (ids != null && ids.Count > 0) { return Ok(await _unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids)); @@ -115,7 +118,7 @@ public class MetadataController : BaseApiController [HttpGet("publication-status")] public ActionResult> GetAllPublicationStatus(string? libraryIds) { - var ids = libraryIds?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); if (ids is {Count: > 0}) { return Ok(_unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids)); @@ -138,7 +141,7 @@ public class MetadataController : BaseApiController [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})] public async Task>> GetAllLanguages(string? libraryIds) { - var ids = libraryIds?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); if (ids is {Count: > 0}) { return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids)); @@ -168,9 +171,9 @@ public class MetadataController : BaseApiController [HttpGet("chapter-summary")] public async Task> GetChapterSummary(int chapterId) { - if (chapterId <= 0) return BadRequest("Chapter does not exist"); + if (chapterId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - if (chapter == null) return BadRequest("Chapter does not exist"); + if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); return Ok(chapter.Summary); } } diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 5eaec3040..814ce7843 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -17,7 +17,6 @@ using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.Services; -using EasyCaching.Core; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -37,6 +36,7 @@ public class OpdsController : BaseApiController private readonly IReaderService _readerService; private readonly ISeriesService _seriesService; private readonly IAccountService _accountService; + private readonly ILocalizationService _localizationService; private readonly XmlSerializer _xmlSerializer; @@ -71,7 +71,7 @@ public class OpdsController : BaseApiController public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService, IDirectoryService directoryService, ICacheService cacheService, IReaderService readerService, ISeriesService seriesService, - IAccountService accountService, IEasyCachingProvider provider) + IAccountService accountService, ILocalizationService localizationService) { _unitOfWork = unitOfWork; _downloadService = downloadService; @@ -80,6 +80,7 @@ public class OpdsController : BaseApiController _readerService = readerService; _seriesService = seriesService; _accountService = accountService; + _localizationService = localizationService; _xmlSerializer = new XmlSerializer(typeof(Feed)); _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); @@ -90,8 +91,9 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task Get(string apiKey) { + var userId = await GetUser(apiKey); if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); var (baseUrl, prefix) = await GetPrefix(); @@ -100,10 +102,10 @@ public class OpdsController : BaseApiController feed.Entries.Add(new FeedEntry() { Id = "onDeck", - Title = "On Deck", + Title = await _localizationService.Translate(userId, "on-deck"), Content = new FeedEntryContent() { - Text = "Browse by On Deck" + Text = await _localizationService.Translate(userId, "browse-on-deck") }, Links = new List() { @@ -113,10 +115,10 @@ public class OpdsController : BaseApiController feed.Entries.Add(new FeedEntry() { Id = "recentlyAdded", - Title = "Recently Added", + Title = await _localizationService.Translate(userId, "recently-added"), Content = new FeedEntryContent() { - Text = "Browse by Recently Added" + Text = await _localizationService.Translate(userId, "browse-recently-added") }, Links = new List() { @@ -126,10 +128,10 @@ public class OpdsController : BaseApiController feed.Entries.Add(new FeedEntry() { Id = "readingList", - Title = "Reading Lists", + Title = await _localizationService.Translate(userId, "reading-lists"), Content = new FeedEntryContent() { - Text = "Browse by Reading Lists" + Text = await _localizationService.Translate(userId, "browse-reading-lists") }, Links = new List() { @@ -139,10 +141,10 @@ public class OpdsController : BaseApiController feed.Entries.Add(new FeedEntry() { Id = "allLibraries", - Title = "All Libraries", + Title = await _localizationService.Translate(userId, "libraries"), Content = new FeedEntryContent() { - Text = "Browse by Libraries" + Text = await _localizationService.Translate(userId, "browse-libraries") }, Links = new List() { @@ -152,10 +154,10 @@ public class OpdsController : BaseApiController feed.Entries.Add(new FeedEntry() { Id = "allCollections", - Title = "All Collections", + Title = await _localizationService.Translate(userId, "collections"), Content = new FeedEntryContent() { - Text = "Browse by Collections" + Text = await _localizationService.Translate(userId, "browse-collections") }, Links = new List() { @@ -183,12 +185,12 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetLibraries(string apiKey) { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var (baseUrl, prefix) = await GetPrefix(); var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId); - var feed = CreateFeed("All Libraries", $"{prefix}{apiKey}/libraries", apiKey, prefix); + var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{prefix}{apiKey}/libraries", apiKey, prefix); SetFeedId(feed, "libraries"); foreach (var library in libraries) { @@ -210,10 +212,10 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetCollections(string apiKey) { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var (baseUrl, prefix) = await GetPrefix(); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null) return Unauthorized(); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); @@ -222,7 +224,7 @@ public class OpdsController : BaseApiController : (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId)); - var feed = CreateFeed("All Collections", $"{prefix}{apiKey}/collections", apiKey, prefix); + var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix); SetFeedId(feed, "collections"); foreach (var tag in tags) { @@ -248,10 +250,10 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 0) { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var (baseUrl, prefix) = await GetPrefix(); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null) return Unauthorized(); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); @@ -292,10 +294,10 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetReadingLists(string apiKey, [FromQuery] int pageNumber = 0) { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var (baseUrl, prefix) = await GetPrefix(); var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, true, GetUserParams(pageNumber), false); @@ -333,10 +335,10 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetReadingListItems(int readingListId, string apiKey) { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var (baseUrl, prefix) = await GetPrefix(); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); var userWithLists = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user!.UserName!, AppUserIncludes.ReadingListsWithItems); @@ -344,10 +346,10 @@ public class OpdsController : BaseApiController var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId); if (readingList == null) { - return BadRequest("Reading list does not exist or you don't have access"); + return BadRequest(await _localizationService.Translate(userId, "reading-list-restricted")); } - var feed = CreateFeed(readingList.Title + " Reading List", $"{prefix}{apiKey}/reading-list/{readingListId}", apiKey, prefix); + var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{prefix}{apiKey}/reading-list/{readingListId}", apiKey, prefix); SetFeedId(feed, $"reading-list-{readingListId}"); var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList(); @@ -364,16 +366,16 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 0) { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var (baseUrl, prefix) = await GetPrefix(); var library = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).SingleOrDefault(l => l.Id == libraryId); if (library == null) { - return BadRequest("User does not have access to this library"); + return BadRequest(await _localizationService.Translate(userId, "no-library-access")); } var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, GetUserParams(pageNumber), _filterDto); @@ -395,14 +397,14 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = 1) { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var (baseUrl, prefix) = await GetPrefix(); var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, userId, GetUserParams(pageNumber), _filterDto); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id)); - var feed = CreateFeed("Recently Added", $"{prefix}{apiKey}/recently-added", apiKey, prefix); + var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{prefix}{apiKey}/recently-added", apiKey, prefix); SetFeedId(feed, "recently-added"); AddPagination(feed, recentlyAdded, $"{prefix}{apiKey}/recently-added"); @@ -418,19 +420,19 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1) { + var userId = await GetUser(apiKey); if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); var (baseUrl, prefix) = await GetPrefix(); - var userId = await GetUser(apiKey); var userParams = GetUserParams(pageNumber); var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, 0, userParams, _filterDto); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(pagedList.Select(s => s.Id)); Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); - var feed = CreateFeed("On Deck", $"{prefix}{apiKey}/on-deck", apiKey, prefix); + var feed = CreateFeed(await _localizationService.Translate(userId, "on-deck"), $"{prefix}{apiKey}/on-deck", apiKey, prefix); SetFeedId(feed, "on-deck"); AddPagination(feed, pagedList, $"{prefix}{apiKey}/on-deck"); @@ -446,20 +448,20 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task SearchSeries(string apiKey, [FromQuery] string query) { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var (baseUrl, prefix) = await GetPrefix(); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (string.IsNullOrEmpty(query)) { - return BadRequest("You must pass a query parameter"); + return BadRequest(await _localizationService.Translate(userId, "query-required")); } query = query.Replace(@"%", string.Empty); // Get libraries user has access to var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList(); - if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); + if (!libraries.Any()) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted")); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); @@ -518,13 +520,14 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetSearchDescriptor(string apiKey) { + var userId = await GetUser(apiKey); if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); var (_, prefix) = await GetPrefix(); var feed = new OpenSearchDescription() { - ShortName = "Search", - Description = "Search for Series, Collections, or Reading Lists", + ShortName = await _localizationService.Translate(userId, "search"), + Description = await _localizationService.Translate(userId, "search-description"), Url = new SearchLink() { Type = FeedLinkType.AtomAcquisition, @@ -542,13 +545,13 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetSeries(string apiKey, int seriesId) { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var (baseUrl, prefix) = await GetPrefix(); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - var feed = CreateFeed(series.Name + " - Storyline", $"{prefix}{apiKey}/series/{series.Id}", apiKey, prefix); + var feed = CreateFeed(series!.Name + " - Storyline", $"{prefix}{apiKey}/series/{series.Id}", apiKey, prefix); SetFeedId(feed, $"series-{series.Id}"); feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}")); @@ -564,7 +567,7 @@ public class OpdsController : BaseApiController var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id); foreach (var mangaFile in files) { - feed.Entries.Add(await CreateChapterWithFile(seriesId, volume.Id, chapter.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapter.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); } } @@ -576,7 +579,7 @@ public class OpdsController : BaseApiController var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(storylineChapter.Id); foreach (var mangaFile in files) { - feed.Entries.Add(await CreateChapterWithFile(seriesId, storylineChapter.VolumeId, storylineChapter.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, storylineChapter.VolumeId, storylineChapter.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); } } @@ -586,7 +589,7 @@ public class OpdsController : BaseApiController var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(special.Id); foreach (var mangaFile in files) { - feed.Entries.Add(await CreateChapterWithFile(seriesId, special.VolumeId, special.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); } } @@ -597,26 +600,26 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetVolume(string apiKey, int seriesId, int volumeId) { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var (baseUrl, prefix) = await GetPrefix(); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); var chapters = (await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number), _chapterSortComparer); - var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {SeriesService.FormatChapterName(libraryType)}s ", + var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ", $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix); - SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{SeriesService.FormatChapterName(libraryType)}s"); + SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s"); foreach (var chapter in chapters) { var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id); var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id); foreach (var mangaFile in files) { - feed.Entries.Add(await CreateChapterWithFile(seriesId, volumeId, chapter.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapter.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); } } @@ -627,23 +630,23 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetChapter(string apiKey, int seriesId, int volumeId, int chapterId) { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var (baseUrl, prefix) = await GetPrefix(); var userId = await GetUser(apiKey); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var (baseUrl, prefix) = await GetPrefix(); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); - if (chapter == null) return BadRequest("Chapter doesn't exist"); + if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist")); var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); - var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {SeriesService.FormatChapterName(libraryType)}s", + var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s", $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix); - SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{SeriesService.FormatChapterName(libraryType)}-{chapterId}-files"); + SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{_seriesService.FormatChapterName(userId, libraryType)}-{chapterId}-files"); foreach (var mangaFile in files) { - feed.Entries.Add(await CreateChapterWithFile(seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey, prefix, baseUrl)); + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey, prefix, baseUrl)); } return CreateXmlResult(SerializeXml(feed)); @@ -661,8 +664,9 @@ public class OpdsController : BaseApiController [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}")] public async Task DownloadFile(string apiKey, int seriesId, int volumeId, int chapterId, string filename) { + var userId = await GetUser(apiKey); if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(await GetUser(apiKey)); if (!await _accountService.HasDownloadPermission(user)) { @@ -781,7 +785,7 @@ public class OpdsController : BaseApiController }; } - private async Task CreateChapterWithFile(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl) + private async Task CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl) { var fileSize = mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) : @@ -797,7 +801,8 @@ public class OpdsController : BaseApiController if (volume!.Chapters.Count == 1) { - SeriesService.RenameVolumeName(volume.Chapters.First(), volume, libraryType); + var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty); + SeriesService.RenameVolumeName(volume.Chapters.First(), volume, libraryType, volumeLabel); if (volume.Name != "0") { title += $" - {volume.Name}"; @@ -805,11 +810,11 @@ public class OpdsController : BaseApiController } else if (volume.Number != 0) { - title = $"{series.Name} - Volume {volume.Name} - {SeriesService.FormatChapterTitle(chapter, libraryType)}"; + title = $"{series.Name} - Volume {volume.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}"; } else { - title = $"{series.Name} - {SeriesService.FormatChapterTitle(chapter, libraryType)}"; + title = $"{series.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}"; } // Chunky requires a file at the end. Our API ignores this @@ -857,14 +862,16 @@ public class OpdsController : BaseApiController [HttpGet("{apiKey}/image")] public async Task GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber) { - if (pageNumber < 0) return BadRequest("Page cannot be less than 0"); + var userId = await GetUser(apiKey); + if (pageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "Page")); var chapter = await _cacheService.Ensure(chapterId); - if (chapter == null) return BadRequest("There was an issue finding image file for reading"); + if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "cache-file-find")); try { var path = _cacheService.GetCachedPagePath(chapter.Id, pageNumber); - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {pageNumber}"); + if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) + return BadRequest(await _localizationService.Translate(userId, "no-image-for-page", pageNumber)); var content = await _directoryService.ReadFileAsync(path); var format = Path.GetExtension(path); @@ -895,8 +902,9 @@ public class OpdsController : BaseApiController [ResponseCache(Duration = 60 * 60, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetFavicon(string apiKey) { + var userId = await GetUser(apiKey); var files = _directoryService.GetFilesWithExtension(Path.Join(Directory.GetCurrentDirectory(), ".."), @"\.ico"); - if (files.Length == 0) return BadRequest("Cannot find icon"); + if (files.Length == 0) return BadRequest(await _localizationService.Translate(userId, "favicon-doesnt-exist")); var path = files[0]; var content = await _directoryService.ReadFileAsync(path); var format = Path.GetExtension(path); @@ -919,7 +927,7 @@ public class OpdsController : BaseApiController { /* Do nothing */ } - throw new KavitaException("User does not exist"); + throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist")); } private async Task CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey, string prefix) diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index ca9d736ad..f14602444 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -16,6 +16,7 @@ using API.Services; using API.Services.Plus; using API.SignalR; using Hangfire; +using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -37,13 +38,15 @@ public class ReaderController : BaseApiController private readonly IAccountService _accountService; private readonly IEventHub _eventHub; private readonly IScrobblingService _scrobblingService; + private readonly ILocalizationService _localizationService; /// public ReaderController(ICacheService cacheService, IUnitOfWork unitOfWork, ILogger logger, IReaderService readerService, IBookmarkService bookmarkService, IAccountService accountService, IEventHub eventHub, - IScrobblingService scrobblingService) + IScrobblingService scrobblingService, + ILocalizationService localizationService) { _cacheService = cacheService; _unitOfWork = unitOfWork; @@ -53,6 +56,7 @@ public class ReaderController : BaseApiController _accountService = accountService; _eventHub = eventHub; _scrobblingService = scrobblingService; + _localizationService = localizationService; } /// @@ -71,13 +75,13 @@ public class ReaderController : BaseApiController // Validate the user has access to the PDF var series = await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapter.Id, await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername())); - if (series == null) return BadRequest("Invalid Access"); + if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-access")); try { var path = _cacheService.GetCachedFile(chapter); - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"Pdf doesn't exist when it should."); + if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "pdf-doesnt-exist")); return PhysicalFile(path, MimeTypeMap.GetMimeType(Path.GetExtension(path)), Path.GetFileName(path), true); } @@ -110,7 +114,8 @@ public class ReaderController : BaseApiController try { var path = _cacheService.GetCachedPagePath(chapter.Id, page); - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}. Try refreshing to allow re-cache."); + if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) + return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-image-for-page", page)); var format = Path.GetExtension(path); return PhysicalFile(path, MimeTypeMap.GetMimeType(format), Path.GetFileName(path), true); @@ -170,7 +175,7 @@ public class ReaderController : BaseApiController try { var path = _cacheService.GetCachedBookmarkPagePath(seriesId, page); - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}"); + if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-image-for-page", page)); var format = Path.GetExtension(path); return PhysicalFile(path, MimeTypeMap.GetMimeType(format), Path.GetFileName(path)); @@ -217,7 +222,7 @@ public class ReaderController : BaseApiController if (chapter == null) return NoContent(); var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); - if (dto == null) return BadRequest("Please perform a scan on this series or library and try again"); + if (dto == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "perform-scan")); var mangaFile = chapter.Files.First(); var info = new ChapterInfoDto() @@ -256,7 +261,8 @@ public class ReaderController : BaseApiController } else { - info.Subtitle = "Volume " + info.VolumeNumber; + //info.Subtitle = await _localizationService.Translate(User.GetUserId(), "volume-num", info.VolumeNumber); + info.Subtitle = $"Volume {info.VolumeNumber}"; if (!info.ChapterNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultChapter)) { info.Subtitle += " " + ReaderService.FormatChapterName(info.LibraryType, true, true) + @@ -309,9 +315,16 @@ public class ReaderController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); if (user == null) return Unauthorized(); - await _readerService.MarkSeriesAsRead(user, markReadDto.SeriesId); + try + { + await _readerService.MarkSeriesAsRead(user, markReadDto.SeriesId); + } + catch (KavitaException ex) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); + } - if (!await _unitOfWork.CommitAsync()) return BadRequest("There was an issue saving progress"); + if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress")); BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, markReadDto.SeriesId)); BackgroundJob.Enqueue(() => _unitOfWork.SeriesRepository.ClearOnDeckRemoval(markReadDto.SeriesId, user.Id)); @@ -331,7 +344,7 @@ public class ReaderController : BaseApiController if (user == null) return Unauthorized(); await _readerService.MarkSeriesAsUnread(user, markReadDto.SeriesId); - if (!await _unitOfWork.CommitAsync()) return BadRequest("There was an issue saving progress"); + if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress")); BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, markReadDto.SeriesId)); return Ok(); @@ -357,7 +370,7 @@ public class ReaderController : BaseApiController return Ok(); } - return BadRequest("Could not save progress"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress")); } /// @@ -372,12 +385,19 @@ public class ReaderController : BaseApiController var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); if (user == null) return Unauthorized(); - await _readerService.MarkChaptersAsRead(user, markVolumeReadDto.SeriesId, chapters); + try + { + await _readerService.MarkChaptersAsRead(user, markVolumeReadDto.SeriesId, chapters); + } + catch (KavitaException ex) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); + } await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate, MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName!, markVolumeReadDto.SeriesId, markVolumeReadDto.VolumeId, 0, chapters.Sum(c => c.Pages))); - if (!await _unitOfWork.CommitAsync()) return BadRequest("Could not save progress"); + if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress")); BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, markVolumeReadDto.SeriesId)); BackgroundJob.Enqueue(() => _unitOfWork.SeriesRepository.ClearOnDeckRemoval(markVolumeReadDto.SeriesId, user.Id)); @@ -405,7 +425,7 @@ public class ReaderController : BaseApiController var chapters = await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds); await _readerService.MarkChaptersAsRead(user, dto.SeriesId, chapters.ToList()); - if (!await _unitOfWork.CommitAsync()) return BadRequest("Could not save progress"); + if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress")); BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, dto.SeriesId)); BackgroundJob.Enqueue(() => _unitOfWork.SeriesRepository.ClearOnDeckRemoval(dto.SeriesId, user.Id)); return Ok(); @@ -439,7 +459,7 @@ public class ReaderController : BaseApiController return Ok(); } - return BadRequest("Could not save progress"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress")); } /// @@ -460,7 +480,7 @@ public class ReaderController : BaseApiController await _readerService.MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters); } - if (!await _unitOfWork.CommitAsync()) return BadRequest("Could not save progress"); + if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress")); foreach (var sId in dto.SeriesIds) { @@ -497,7 +517,7 @@ public class ReaderController : BaseApiController return Ok(); } - return BadRequest("Could not save progress"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress")); } /// @@ -529,7 +549,7 @@ public class ReaderController : BaseApiController { var userId = User.GetUserId(); if (!await _readerService.SaveReadingProgress(progressDto, userId)) - return BadRequest("Could not save progress"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress")); return Ok(true); @@ -589,7 +609,7 @@ public class ReaderController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); if (user == null) return Unauthorized(); - if (user.Bookmarks == null) return Ok("Nothing to remove"); + if (user.Bookmarks == null) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); try { @@ -616,7 +636,7 @@ public class ReaderController : BaseApiController await _unitOfWork.RollbackAsync(); } - return BadRequest("Could not clear bookmarks"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-clear-bookmarks")); } /// @@ -629,7 +649,7 @@ public class ReaderController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); if (user == null) return Unauthorized(); - if (user.Bookmarks == null) return Ok("Nothing to remove"); + if (user.Bookmarks == null) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); try { @@ -653,7 +673,7 @@ public class ReaderController : BaseApiController await _unitOfWork.RollbackAsync(); } - return BadRequest("Could not clear bookmarks"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-clear-bookmarks")); } /// @@ -692,15 +712,16 @@ public class ReaderController : BaseApiController if (user == null) return new UnauthorizedResult(); if (!await _accountService.HasBookmarkPermission(user)) - return BadRequest("You do not have permission to bookmark"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission")); var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId); - if (chapter == null) return BadRequest("Could not find cached image. Reload and try again."); + if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find")); bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page); var path = _cacheService.GetCachedPagePath(chapter.Id, bookmarkDto.Page); - if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) return BadRequest("Could not save bookmark"); + if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) + return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-save")); BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); return Ok(); @@ -719,10 +740,10 @@ public class ReaderController : BaseApiController if (user.Bookmarks.IsNullOrEmpty()) return Ok(); if (!await _accountService.HasBookmarkPermission(user)) - return BadRequest("You do not have permission to unbookmark"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission")); if (!await _bookmarkService.RemoveBookmarkPage(user, bookmarkDto)) - return BadRequest("Could not remove bookmark"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-save")); BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); return Ok(); } @@ -806,9 +827,10 @@ public class ReaderController : BaseApiController [HttpDelete("ptoc")] public async Task DeletePersonalToc([FromQuery] int chapterId, [FromQuery] int pageNum, [FromQuery] string title) { - if (string.IsNullOrWhiteSpace(title)) return BadRequest("Name cannot be empty"); - if (pageNum < 0) return BadRequest("Must be valid page number"); - var toc = await _unitOfWork.UserTableOfContentRepository.Get(User.GetUserId(), chapterId, pageNum, title); + var userId = User.GetUserId(); + if (string.IsNullOrWhiteSpace(title)) return BadRequest(await _localizationService.Translate(userId, "name-required")); + if (pageNum < 0) return BadRequest(await _localizationService.Translate(userId, "valid-number")); + var toc = await _unitOfWork.UserTableOfContentRepository.Get(userId, chapterId, pageNum, title); if (toc == null) return Ok(); _unitOfWork.UserTableOfContentRepository.Remove(toc); await _unitOfWork.CommitAsync(); @@ -825,13 +847,13 @@ public class ReaderController : BaseApiController public async Task CreatePersonalToC(CreatePersonalToCDto dto) { // Validate there isn't already an existing page title combo? - if (string.IsNullOrWhiteSpace(dto.Title)) return BadRequest("Name cannot be empty"); - if (dto.PageNumber < 0) return BadRequest("Must be valid page number"); var userId = User.GetUserId(); + if (string.IsNullOrWhiteSpace(dto.Title)) return BadRequest(await _localizationService.Translate(userId, "name-required")); + if (dto.PageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "valid-number")); if (await _unitOfWork.UserTableOfContentRepository.IsUnique(userId, dto.ChapterId, dto.PageNumber, dto.Title.Trim())) { - return BadRequest("Duplicate ToC entry already exists"); + return BadRequest(await _localizationService.Translate(userId, "duplicate-bookmark")); } _unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent() diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 2ef5dd726..0e8899783 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -21,11 +21,14 @@ public class ReadingListController : BaseApiController { private readonly IUnitOfWork _unitOfWork; private readonly IReadingListService _readingListService; + private readonly ILocalizationService _localizationService; - public ReadingListController(IUnitOfWork unitOfWork, IReadingListService readingListService) + public ReadingListController(IUnitOfWork unitOfWork, IReadingListService readingListService, + ILocalizationService localizationService) { _unitOfWork = unitOfWork; _readingListService = readingListService; + _localizationService = localizationService; } /// @@ -99,13 +102,13 @@ public class ReadingListController : BaseApiController var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-permission")); } - if (await _readingListService.UpdateReadingListItemPosition(dto)) return Ok("Updated"); + if (await _readingListService.UpdateReadingListItemPosition(dto)) return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated")); - return BadRequest("Couldn't update position"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-position")); } /// @@ -119,15 +122,15 @@ public class ReadingListController : BaseApiController var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-permission")); } if (await _readingListService.DeleteReadingListItem(dto)) { - return Ok("Updated"); + return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated")); } - return BadRequest("Couldn't delete item"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-item-delete")); } /// @@ -141,15 +144,15 @@ public class ReadingListController : BaseApiController var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); if (user == null) { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-permission")); } if (await _readingListService.RemoveFullyReadItems(readingListId, user)) { - return Ok("Updated"); + return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated")); } - return BadRequest("Could not remove read items"); + return BadRequest("Couldn't delete item(s)"); } /// @@ -163,12 +166,13 @@ public class ReadingListController : BaseApiController var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); if (user == null) { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-permission")); } - if (await _readingListService.DeleteReadingList(readingListId, user)) return Ok("List was deleted"); + if (await _readingListService.DeleteReadingList(readingListId, user)) + return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-deleted")); - return BadRequest("There was an issue deleting reading list"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-reading-list-delete")); } /// @@ -188,7 +192,7 @@ public class ReadingListController : BaseApiController } catch (KavitaException ex) { - return BadRequest(ex.Message); + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByTitleAsync(user.Id, dto.Title)); @@ -203,12 +207,12 @@ public class ReadingListController : BaseApiController public async Task UpdateList(UpdateReadingListDto dto) { var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); - if (readingList == null) return BadRequest("List does not exist"); + if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); var user = await _readingListService.UserHasReadingListAccess(readingList.Id, User.GetUsername()); if (user == null) { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-permission")); } try @@ -217,10 +221,10 @@ public class ReadingListController : BaseApiController } catch (KavitaException ex) { - return BadRequest(ex.Message); + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } - return Ok("Updated"); + return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated")); } /// @@ -234,11 +238,11 @@ public class ReadingListController : BaseApiController var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-permission")); } var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); - if (readingList == null) return BadRequest("Reading List does not exist"); + if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); var chapterIdsForSeries = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new [] {dto.SeriesId}); @@ -253,7 +257,7 @@ public class ReadingListController : BaseApiController if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); - return Ok("Updated"); + return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated")); } } catch @@ -261,7 +265,7 @@ public class ReadingListController : BaseApiController await _unitOfWork.RollbackAsync(); } - return Ok("Nothing to do"); + return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); } @@ -276,10 +280,10 @@ public class ReadingListController : BaseApiController var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-permission")); } var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); - if (readingList == null) return BadRequest("Reading List does not exist"); + if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds); foreach (var chapterId in dto.ChapterIds) @@ -298,7 +302,7 @@ public class ReadingListController : BaseApiController if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); - return Ok("Updated"); + return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated")); } } catch @@ -306,7 +310,7 @@ public class ReadingListController : BaseApiController await _unitOfWork.RollbackAsync(); } - return Ok("Nothing to do"); + return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); } /// @@ -320,10 +324,10 @@ public class ReadingListController : BaseApiController var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-permission")); } var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); - if (readingList == null) return BadRequest("Reading List does not exist"); + if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); var ids = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(dto.SeriesIds.ToArray()); @@ -341,7 +345,7 @@ public class ReadingListController : BaseApiController if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); - return Ok("Updated"); + return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated")); } } catch @@ -349,7 +353,7 @@ public class ReadingListController : BaseApiController await _unitOfWork.RollbackAsync(); } - return Ok("Nothing to do"); + return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); } [HttpPost("update-by-volume")] @@ -358,10 +362,10 @@ public class ReadingListController : BaseApiController var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-permission")); } var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); - if (readingList == null) return BadRequest("Reading List does not exist"); + if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); var chapterIdsForVolume = (await _unitOfWork.ChapterRepository.GetChaptersAsync(dto.VolumeId)).Select(c => c.Id).ToList(); @@ -377,7 +381,7 @@ public class ReadingListController : BaseApiController if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); - return Ok("Updated"); + return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated")); } } catch @@ -385,7 +389,7 @@ public class ReadingListController : BaseApiController await _unitOfWork.RollbackAsync(); } - return Ok("Nothing to do"); + return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); } [HttpPost("update-by-chapter")] @@ -394,10 +398,10 @@ public class ReadingListController : BaseApiController var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-permission")); } var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); - if (readingList == null) return BadRequest("Reading List does not exist"); + if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); // If there are adds, tell tracking this has been modified if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, new List() { dto.ChapterId }, readingList)) @@ -410,7 +414,7 @@ public class ReadingListController : BaseApiController if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); - return Ok("Updated"); + return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated")); } } catch @@ -418,7 +422,7 @@ public class ReadingListController : BaseApiController await _unitOfWork.RollbackAsync(); } - return Ok("Nothing to do"); + return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); } /// @@ -446,7 +450,7 @@ public class ReadingListController : BaseApiController { var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).ToList(); var readingListItem = items.SingleOrDefault(rl => rl.ChapterId == currentChapterId); - if (readingListItem == null) return BadRequest("Id does not exist"); + if (readingListItem == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var index = items.IndexOf(readingListItem) + 1; if (items.Count > index) { @@ -467,7 +471,7 @@ public class ReadingListController : BaseApiController { var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).ToList(); var readingListItem = items.SingleOrDefault(rl => rl.ChapterId == currentChapterId); - if (readingListItem == null) return BadRequest("Id does not exist"); + if (readingListItem == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var index = items.IndexOf(readingListItem) - 1; if (0 <= index) { diff --git a/API/Controllers/RecommendedController.cs b/API/Controllers/RecommendedController.cs index efadb6d60..062b87bad 100644 --- a/API/Controllers/RecommendedController.cs +++ b/API/Controllers/RecommendedController.cs @@ -8,6 +8,7 @@ using API.DTOs; using API.DTOs.Recommendation; using API.Extensions; using API.Helpers; +using API.Services; using API.Services.Plus; using EasyCaching.Core; using Microsoft.AspNetCore.Mvc; @@ -21,15 +22,18 @@ public class RecommendedController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IRecommendationService _recommendationService; private readonly ILicenseService _licenseService; + private readonly ILocalizationService _localizationService; private readonly IEasyCachingProvider _cacheProvider; public const string CacheKey = "recommendation_"; public RecommendedController(IUnitOfWork unitOfWork, IRecommendationService recommendationService, - ILicenseService licenseService, IEasyCachingProviderFactory cachingProviderFactory) + ILicenseService licenseService, IEasyCachingProviderFactory cachingProviderFactory, + ILocalizationService localizationService) { _unitOfWork = unitOfWork; _recommendationService = recommendationService; _licenseService = licenseService; + _localizationService = localizationService; _cacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations); } @@ -50,7 +54,7 @@ public class RecommendedController : BaseApiController if (!await _unitOfWork.UserRepository.HasAccessToSeries(userId, seriesId)) { - return BadRequest("User does not have access to this Series"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-restricted")); } var cacheKey = $"{CacheKey}-{seriesId}-{userId}"; diff --git a/API/Controllers/ReviewController.cs b/API/Controllers/ReviewController.cs index 27508f1ce..fa2d7b843 100644 --- a/API/Controllers/ReviewController.cs +++ b/API/Controllers/ReviewController.cs @@ -14,9 +14,7 @@ using AutoMapper; using EasyCaching.Core; using Hangfire; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace API.Controllers; @@ -65,7 +63,6 @@ public class ReviewController : BaseApiController var cacheKey = CacheKey + seriesId; IEnumerable externalReviews; - var setCache = false; var result = await _cacheProvider.GetAsync>(cacheKey); if (result.HasValue) @@ -74,35 +71,15 @@ public class ReviewController : BaseApiController } else { - externalReviews = await _reviewService.GetReviewsForSeries(userId, seriesId); - setCache = true; - } - // if (_cache.TryGetValue(cacheKey, out string cachedData)) - // { - // externalReviews = JsonConvert.DeserializeObject>(cachedData); - // } - // else - // { - // externalReviews = await _reviewService.GetReviewsForSeries(userId, seriesId); - // setCache = true; - // } - - // Fetch external reviews and splice them in - foreach (var r in externalReviews) - { - userRatings.Add(r); - } - - if (setCache) - { - // var cacheEntryOptions = new MemoryCacheEntryOptions() - // .SetSize(userRatings.Count) - // .SetAbsoluteExpiration(TimeSpan.FromHours(10)); - //_cache.Set(cacheKey, JsonConvert.SerializeObject(externalReviews), cacheEntryOptions); + externalReviews = (await _reviewService.GetReviewsForSeries(userId, seriesId)).ToList(); await _cacheProvider.SetAsync(cacheKey, externalReviews, TimeSpan.FromHours(10)); _logger.LogDebug("Caching external reviews for {Key}", cacheKey); } + + // Fetch external reviews and splice them in + userRatings.AddRange(externalReviews); + return Ok(userRatings.Take(10)); } diff --git a/API/Controllers/ScrobblingController.cs b/API/Controllers/ScrobblingController.cs index b7e7394ae..caf49d6db 100644 --- a/API/Controllers/ScrobblingController.cs +++ b/API/Controllers/ScrobblingController.cs @@ -10,6 +10,7 @@ using API.Entities.Scrobble; using API.Extensions; using API.Helpers; using API.Helpers.Builders; +using API.Services; using API.Services.Plus; using Hangfire; using Microsoft.AspNetCore.Authorization; @@ -26,12 +27,15 @@ public class ScrobblingController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IScrobblingService _scrobblingService; private readonly ILogger _logger; + private readonly ILocalizationService _localizationService; - public ScrobblingController(IUnitOfWork unitOfWork, IScrobblingService scrobblingService, ILogger logger) + public ScrobblingController(IUnitOfWork unitOfWork, IScrobblingService scrobblingService, + ILogger logger, ILocalizationService localizationService) { _unitOfWork = unitOfWork; _scrobblingService = scrobblingService; _logger = logger; + _localizationService = localizationService; } [HttpGet("anilist-token")] @@ -153,7 +157,8 @@ public class ScrobblingController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ScrobbleHolds); if (user == null) return Unauthorized(); - if (user.ScrobbleHolds.Any(s => s.SeriesId == seriesId)) return Ok("Nothing to do"); + if (user.ScrobbleHolds.Any(s => s.SeriesId == seriesId)) + return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); var seriesHold = new ScrobbleHoldBuilder().WithSeriesId(seriesId).Build(); user.ScrobbleHolds.Add(seriesHold); @@ -181,7 +186,8 @@ public class ScrobblingController : BaseApiController { // Handle other exceptions or log the error _logger.LogError(ex, "An error occurred while adding the hold"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while adding the hold"); + return StatusCode(StatusCodes.Status500InternalServerError, + await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); } } diff --git a/API/Controllers/SearchController.cs b/API/Controllers/SearchController.cs index 84d2bbf3b..03ba05bed 100644 --- a/API/Controllers/SearchController.cs +++ b/API/Controllers/SearchController.cs @@ -5,6 +5,7 @@ using API.Data.Repositories; using API.DTOs; using API.DTOs.Search; using API.Extensions; +using API.Services; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; @@ -15,10 +16,12 @@ namespace API.Controllers; public class SearchController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; - public SearchController(IUnitOfWork unitOfWork) + public SearchController(IUnitOfWork unitOfWork, ILocalizationService localizationService) { _unitOfWork = unitOfWork; + _localizationService = localizationService; } /// @@ -55,7 +58,7 @@ public class SearchController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(); var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList(); - if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); + if (!libraries.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted")); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 6c3b7ced8..8c2a0ced0 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -15,6 +15,7 @@ using API.Helpers; using API.Services; using API.Services.Plus; using EasyCaching.Core; +using Kavita.Common; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; @@ -30,6 +31,7 @@ public class SeriesController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly ISeriesService _seriesService; private readonly ILicenseService _licenseService; + private readonly ILocalizationService _localizationService; private readonly IEasyCachingProvider _ratingCacheProvider; private readonly IEasyCachingProvider _reviewCacheProvider; private readonly IEasyCachingProvider _recommendationCacheProvider; @@ -37,13 +39,14 @@ public class SeriesController : BaseApiController public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, ISeriesService seriesService, ILicenseService licenseService, - IEasyCachingProviderFactory cachingProviderFactory) + IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService) { _logger = logger; _taskScheduler = taskScheduler; _unitOfWork = unitOfWork; _seriesService = seriesService; _licenseService = licenseService; + _localizationService = localizationService; _ratingCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings); _reviewCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews); @@ -58,7 +61,7 @@ public class SeriesController : BaseApiController await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest("Could not get series for library"); + if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series")); await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); @@ -101,7 +104,7 @@ public class SeriesController : BaseApiController if (await _seriesService.DeleteMultipleSeries(dto.SeriesIds)) return Ok(); - return BadRequest("There was an issue deleting the series requested"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-series-delete")); } /// @@ -149,7 +152,8 @@ public class SeriesController : BaseApiController public async Task UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings); - if (!await _seriesService.UpdateRating(user!, updateSeriesRatingDto)) return BadRequest("There was a critical error."); + if (!await _seriesService.UpdateRating(user!, updateSeriesRatingDto)) + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); return Ok(); } @@ -162,8 +166,8 @@ public class SeriesController : BaseApiController public async Task UpdateSeries(UpdateSeriesDto updateSeries) { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(updateSeries.Id); - - if (series == null) return BadRequest("Series does not exist"); + if (series == null) + return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist")); series.NormalizedName = series.Name.ToNormalized(); if (!string.IsNullOrEmpty(updateSeries.SortName?.Trim())) @@ -199,7 +203,7 @@ public class SeriesController : BaseApiController return Ok(); } - return BadRequest("There was an error with updating the series"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-series-update")); } /// @@ -218,7 +222,7 @@ public class SeriesController : BaseApiController await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, userId, userParams, filterDto); // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest("Could not get series"); + if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series")); await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); @@ -254,7 +258,7 @@ public class SeriesController : BaseApiController await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest("Could not get series"); + if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series")); await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); @@ -370,10 +374,10 @@ public class SeriesController : BaseApiController } } - return Ok("Successfully updated"); + return Ok(await _localizationService.Translate(User.GetUserId(), "series-updated")); } - return BadRequest("Could not update metadata"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "update-metadata-fail")); } /// @@ -390,7 +394,7 @@ public class SeriesController : BaseApiController await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, userParams); // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest("Could not get series for collection"); + if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series-collection")); await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); @@ -407,7 +411,7 @@ public class SeriesController : BaseApiController [HttpPost("series-by-ids")] public async Task>> GetAllSeriesById(SeriesByIdsDto dto) { - if (dto.SeriesIds == null) return BadRequest("Must pass seriesIds"); + if (dto.SeriesIds == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-payload")); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, userId)); } @@ -420,10 +424,11 @@ public class SeriesController : BaseApiController /// This is cached for an hour [ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = new [] {"ageRating"})] [HttpGet("age-rating")] - public ActionResult GetAgeRating(int ageRating) + public async Task> GetAgeRating(int ageRating) { var val = (AgeRating) ageRating; - if (val == AgeRating.NotApplicable) return "No Restriction"; + if (val == AgeRating.NotApplicable) + return await _localizationService.Translate(User.GetUserId(), "age-restriction-not-applicable"); return Ok(val.ToDescription()); } @@ -439,7 +444,14 @@ public class SeriesController : BaseApiController public async Task> GetSeriesDetailBreakdown(int seriesId) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return await _seriesService.GetSeriesDetail(seriesId, userId); + try + { + return await _seriesService.GetSeriesDetail(seriesId, userId); + } + catch (KavitaException ex) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); + } } @@ -485,7 +497,7 @@ public class SeriesController : BaseApiController return Ok(); } - return BadRequest("There was an issue updating relationships"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-relationship")); } diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 3417d9732..6a74e8b30 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -41,11 +41,13 @@ public class ServerController : BaseApiController private readonly ITaskScheduler _taskScheduler; private readonly IUnitOfWork _unitOfWork; private readonly IEasyCachingProviderFactory _cachingProviderFactory; + private readonly ILocalizationService _localizationService; public ServerController(ILogger logger, IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService, ICleanupService cleanupService, IScannerService scannerService, IAccountService accountService, - ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IEasyCachingProviderFactory cachingProviderFactory) + ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IEasyCachingProviderFactory cachingProviderFactory, + ILocalizationService localizationService) { _logger = logger; _backupService = backupService; @@ -58,6 +60,7 @@ public class ServerController : BaseApiController _taskScheduler = taskScheduler; _unitOfWork = unitOfWork; _cachingProviderFactory = cachingProviderFactory; + _localizationService = localizationService; } /// @@ -103,12 +106,12 @@ public class ServerController : BaseApiController /// /// [HttpPost("analyze-files")] - public ActionResult AnalyzeFiles() + public async Task AnalyzeFiles() { _logger.LogInformation("{UserName} is performing file analysis from admin dashboard", User.GetUsername()); if (TaskScheduler.HasAlreadyEnqueuedTask(ScannerService.Name, "AnalyzeFiles", Array.Empty(), TaskScheduler.DefaultQueue, true)) - return Ok("Job already running"); + return Ok(await _localizationService.Translate(User.GetUserId(), "job-already-running")); BackgroundJob.Enqueue(() => _scannerService.AnalyzeFiles()); return Ok(); @@ -127,7 +130,7 @@ public class ServerController : BaseApiController /// /// Returns non-sensitive information about the current system /// - /// This is just for the UI and is extremly lightweight + /// This is just for the UI and is extremely lightweight /// [HttpGet("server-info-slim")] public async Task> GetSlimVersion() @@ -146,8 +149,7 @@ public class ServerController : BaseApiController var encoding = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs; if (encoding == EncodeFormat.PNG) { - return BadRequest( - "You cannot convert to PNG. For covers, use Refresh Covers. Bookmarks and favicons cannot be encoded back."); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "encode-as-warning")); } _taskScheduler.CovertAllCoversToEncoding(); @@ -160,7 +162,7 @@ public class ServerController : BaseApiController /// /// [HttpGet("logs")] - public ActionResult GetLogs() + public async Task GetLogs() { var files = _backupService.GetLogFiles(); try @@ -171,7 +173,7 @@ public class ServerController : BaseApiController } catch (KavitaException ex) { - return BadRequest(ex.Message); + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 9466fe6dd..f1849a7ff 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -33,9 +33,11 @@ public class SettingsController : BaseApiController private readonly IMapper _mapper; private readonly IEmailService _emailService; private readonly ILibraryWatcher _libraryWatcher; + private readonly ILocalizationService _localizationService; public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, - IDirectoryService directoryService, IMapper mapper, IEmailService emailService, ILibraryWatcher libraryWatcher) + IDirectoryService directoryService, IMapper mapper, IEmailService emailService, ILibraryWatcher libraryWatcher, + ILocalizationService localizationService) { _logger = logger; _unitOfWork = unitOfWork; @@ -44,6 +46,7 @@ public class SettingsController : BaseApiController _mapper = mapper; _emailService = emailService; _libraryWatcher = libraryWatcher; + _localizationService = localizationService; } [HttpGet("base-url")] @@ -224,7 +227,7 @@ public class SettingsController : BaseApiController foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) { if (!IPAddress.TryParse(ipAddress.Trim(), out _)) { - return BadRequest($"IP Address '{ipAddress}' is invalid"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "ip-address-invalid", ipAddress)); } } @@ -279,7 +282,7 @@ public class SettingsController : BaseApiController // Validate new directory can be used if (!await _directoryService.CheckWriteAccess(bookmarkDirectory)) { - return BadRequest("Bookmark Directory does not have correct permissions for Kavita to use"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-dir-permissions")); } originalBookmarkDirectory = setting.Value; @@ -308,7 +311,7 @@ public class SettingsController : BaseApiController { if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1) { - return BadRequest("Total Backups must be between 1 and 30"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-backups")); } setting.Value = updateSettingsDto.TotalBackups + string.Empty; _unitOfWork.SettingsRepository.Update(setting); @@ -318,7 +321,7 @@ public class SettingsController : BaseApiController { if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1) { - return BadRequest("Total Logs must be between 1 and 30"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-logs")); } setting.Value = updateSettingsDto.TotalLogs + string.Empty; _unitOfWork.SettingsRepository.Update(setting); @@ -366,7 +369,7 @@ public class SettingsController : BaseApiController { _logger.LogError(ex, "There was an exception when updating server settings"); await _unitOfWork.RollbackAsync(); - return BadRequest("There was a critical issue. Please try again."); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); } diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index ec8588d56..d711d5f47 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -19,12 +19,15 @@ public class StatsController : BaseApiController private readonly IStatisticService _statService; private readonly IUnitOfWork _unitOfWork; private readonly UserManager _userManager; + private readonly ILocalizationService _localizationService; - public StatsController(IStatisticService statService, IUnitOfWork unitOfWork, UserManager userManager) + public StatsController(IStatisticService statService, IUnitOfWork unitOfWork, + UserManager userManager, ILocalizationService localizationService) { _statService = statService; _unitOfWork = unitOfWork; _userManager = userManager; + _localizationService = localizationService; } [HttpGet("user/{userId}/read")] @@ -33,7 +36,7 @@ public class StatsController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user!.Id != userId && !await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole)) - return Unauthorized("You are not authorized to view another user's statistics"); + return Unauthorized(await _localizationService.Translate(User.GetUserId(), "stats-permission-denied")); return Ok(await _statService.GetUserReadStatistics(userId, new List())); } diff --git a/API/Controllers/TachiyomiController.cs b/API/Controllers/TachiyomiController.cs index ef24d05ff..900783097 100644 --- a/API/Controllers/TachiyomiController.cs +++ b/API/Controllers/TachiyomiController.cs @@ -16,11 +16,14 @@ public class TachiyomiController : BaseApiController { private readonly IUnitOfWork _unitOfWork; private readonly ITachiyomiService _tachiyomiService; + private readonly ILocalizationService _localizationService; - public TachiyomiController(IUnitOfWork unitOfWork, ITachiyomiService tachiyomiService) + public TachiyomiController(IUnitOfWork unitOfWork, ITachiyomiService tachiyomiService, + ILocalizationService localizationService) { _unitOfWork = unitOfWork; _tachiyomiService = tachiyomiService; + _localizationService = localizationService; } /// @@ -31,7 +34,7 @@ public class TachiyomiController : BaseApiController [HttpGet("latest-chapter")] public async Task> GetLatestChapter(int seriesId) { - if (seriesId < 1) return BadRequest("seriesId must be greater than 0"); + if (seriesId < 1) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "SeriesId")); return Ok(await _tachiyomiService.GetLatestChapter(seriesId, User.GetUserId())); } diff --git a/API/Controllers/ThemeController.cs b/API/Controllers/ThemeController.cs index dc697a89d..15eea11ea 100644 --- a/API/Controllers/ThemeController.cs +++ b/API/Controllers/ThemeController.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs.Theme; +using API.Extensions; using API.Services; using API.Services.Tasks; using Kavita.Common; @@ -15,12 +16,15 @@ public class ThemeController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IThemeService _themeService; private readonly ITaskScheduler _taskScheduler; + private readonly ILocalizationService _localizationService; - public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService, ITaskScheduler taskScheduler) + public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService, ITaskScheduler taskScheduler, + ILocalizationService localizationService) { _unitOfWork = unitOfWork; _themeService = themeService; _taskScheduler = taskScheduler; + _localizationService = localizationService; } [ResponseCache(CacheProfileName = "10Minute")] @@ -43,7 +47,15 @@ public class ThemeController : BaseApiController [HttpPost("update-default")] public async Task UpdateDefault(UpdateDefaultThemeDto dto) { - await _themeService.UpdateDefault(dto.ThemeId); + try + { + await _themeService.UpdateDefault(dto.ThemeId); + } + catch (KavitaException ex) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), "theme-doesnt-exist")); + } + return Ok(); } @@ -61,7 +73,7 @@ public class ThemeController : BaseApiController } catch (KavitaException ex) { - return BadRequest(ex.Message); + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } } } diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index fcedf2c75..ab01d7abb 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -25,10 +25,12 @@ public class UploadController : BaseApiController private readonly IDirectoryService _directoryService; private readonly IEventHub _eventHub; private readonly IReadingListService _readingListService; + private readonly ILocalizationService _localizationService; /// public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, - ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub, IReadingListService readingListService) + ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub, IReadingListService readingListService, + ILocalizationService localizationService) { _unitOfWork = unitOfWork; _imageService = imageService; @@ -37,6 +39,7 @@ public class UploadController : BaseApiController _directoryService = directoryService; _eventHub = eventHub; _readingListService = readingListService; + _localizationService = localizationService; } /// @@ -57,9 +60,9 @@ public class UploadController : BaseApiController .DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}"); if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) - return BadRequest($"Could not download file"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-not-valid")); - if (!await _imageService.IsImage(path)) return BadRequest("Url does not return a valid image"); + if (!await _imageService.IsImage(path)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-not-valid")); return $"coverupload_{dateString}.{format}"; } @@ -67,10 +70,10 @@ public class UploadController : BaseApiController { // Unauthorized if (ex.StatusCode == 401) - return BadRequest("The server requires authentication to load the url externally"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-not-valid")); } - return BadRequest("Unable to download image, please use another url or upload by file"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-not-valid")); } /// @@ -87,13 +90,13 @@ public class UploadController : BaseApiController // See if we can do this all in memory without touching underlying system if (string.IsNullOrEmpty(uploadFileDto.Url)) { - return BadRequest("You must pass a url to use"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); } try { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id); - if (series == null) return BadRequest("Invalid Series"); + if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist")); var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}"); if (!string.IsNullOrEmpty(filePath)) @@ -118,7 +121,7 @@ public class UploadController : BaseApiController await _unitOfWork.RollbackAsync(); } - return BadRequest("Unable to save cover image to Series"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-series-save")); } /// @@ -135,13 +138,13 @@ public class UploadController : BaseApiController // See if we can do this all in memory without touching underlying system if (string.IsNullOrEmpty(uploadFileDto.Url)) { - return BadRequest("You must pass a url to use"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); } try { var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id); - if (tag == null) return BadRequest("Invalid Tag id"); + if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}"); if (!string.IsNullOrEmpty(filePath)) @@ -166,7 +169,7 @@ public class UploadController : BaseApiController await _unitOfWork.RollbackAsync(); } - return BadRequest("Unable to save cover image to Collection Tag"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-collection-save")); } /// @@ -183,16 +186,16 @@ public class UploadController : BaseApiController // See if we can do this all in memory without touching underlying system if (string.IsNullOrEmpty(uploadFileDto.Url)) { - return BadRequest("You must pass a url to use"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); } if (_readingListService.UserHasReadingListAccess(uploadFileDto.Id, User.GetUsername()) == null) - return Unauthorized("You do not have access"); + return Unauthorized(await _localizationService.Translate(User.GetUserId(), "access-denied")); try { var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id); - if (readingList == null) return BadRequest("Reading list is not valid"); + if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}"); if (!string.IsNullOrEmpty(filePath)) @@ -217,7 +220,7 @@ public class UploadController : BaseApiController await _unitOfWork.RollbackAsync(); } - return BadRequest("Unable to save cover image to Reading List"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-reading-list-save")); } private async Task CreateThumbnail(UploadFileDto uploadFileDto, string filename, int thumbnailSize = 0) @@ -247,13 +250,13 @@ public class UploadController : BaseApiController // See if we can do this all in memory without touching underlying system if (string.IsNullOrEmpty(uploadFileDto.Url)) { - return BadRequest("You must pass a url to use"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); } try { var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); - if (chapter == null) return BadRequest("Invalid Chapter"); + if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}"); if (!string.IsNullOrEmpty(filePath)) @@ -286,7 +289,7 @@ public class UploadController : BaseApiController await _unitOfWork.RollbackAsync(); } - return BadRequest("Unable to save cover image to Chapter"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-chapter-save")); } /// @@ -345,7 +348,7 @@ public class UploadController : BaseApiController await _unitOfWork.RollbackAsync(); } - return BadRequest("Unable to save cover image to Library"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-library-save")); } /// @@ -360,7 +363,7 @@ public class UploadController : BaseApiController try { var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); - if (chapter == null) return BadRequest("Chapter no longer exists"); + if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var originalFile = chapter.CoverImage; chapter.CoverImage = string.Empty; chapter.CoverImageLocked = false; @@ -385,7 +388,7 @@ public class UploadController : BaseApiController await _unitOfWork.RollbackAsync(); } - return BadRequest("Unable to resetting cover lock for Chapter"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock")); } } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index f39bfd3f3..755646c33 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -5,6 +5,7 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.Extensions; +using API.Services; using API.SignalR; using AutoMapper; using Microsoft.AspNetCore.Authorization; @@ -18,12 +19,15 @@ public class UsersController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly IEventHub _eventHub; + private readonly ILocalizationService _localizationService; - public UsersController(IUnitOfWork unitOfWork, IMapper mapper, IEventHub eventHub) + public UsersController(IUnitOfWork unitOfWork, IMapper mapper, IEventHub eventHub, + ILocalizationService localizationService) { _unitOfWork = unitOfWork; _mapper = mapper; _eventHub = eventHub; + _localizationService = localizationService; } [Authorize(Policy = "RequireAdminRole")] @@ -38,7 +42,7 @@ public class UsersController : BaseApiController if (await _unitOfWork.CommitAsync()) return Ok(); - return BadRequest("Could not delete the user."); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-user-delete")); } /// @@ -66,7 +70,7 @@ public class UsersController : BaseApiController { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); - if (library == null) return BadRequest("Library does not exist"); + if (library == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-doesnt-exist")); return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, userId)); } @@ -113,16 +117,17 @@ public class UsersController : BaseApiController existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate; existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships; existingPreferences.ShareReviews = preferencesDto.ShareReviews; + if (_localizationService.GetLocales().Contains(preferencesDto.Locale)) + { + existingPreferences.Locale = preferencesDto.Locale; + } _unitOfWork.UserRepository.Update(existingPreferences); - if (await _unitOfWork.CommitAsync()) - { - await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName!), user.Id); - return Ok(preferencesDto); - } + if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-user-pref")); - return BadRequest("There was an issue saving preferences."); + await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName!), user.Id); + return Ok(preferencesDto); } /// diff --git a/API/Controllers/WantToReadController.cs b/API/Controllers/WantToReadController.cs index 26b440976..563a55995 100644 --- a/API/Controllers/WantToReadController.cs +++ b/API/Controllers/WantToReadController.cs @@ -7,6 +7,7 @@ using API.DTOs.Filtering; using API.DTOs.WantToRead; using API.Extensions; using API.Helpers; +using API.Services; using API.Services.Plus; using Hangfire; using Microsoft.AspNetCore.Mvc; @@ -21,11 +22,14 @@ public class WantToReadController : BaseApiController { private readonly IUnitOfWork _unitOfWork; private readonly IScrobblingService _scrobblingService; + private readonly ILocalizationService _localizationService; - public WantToReadController(IUnitOfWork unitOfWork, IScrobblingService scrobblingService) + public WantToReadController(IUnitOfWork unitOfWork, IScrobblingService scrobblingService, + ILocalizationService localizationService) { _unitOfWork = unitOfWork; _scrobblingService = scrobblingService; + _localizationService = localizationService; } /// @@ -85,7 +89,7 @@ public class WantToReadController : BaseApiController return Ok(); } - return BadRequest("There was an issue updating Read List"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-reading-list-update")); } /// @@ -113,6 +117,6 @@ public class WantToReadController : BaseApiController return Ok(); } - return BadRequest("There was an issue updating Read List"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-reading-list-update")); } } diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 7a42ae62d..41160e362 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -147,4 +147,9 @@ public class UserPreferencesDto /// [Required] public bool ShareReviews { get; set; } = false; + /// + /// UI Site Global Setting: The language locale that should be used for the user + /// + [Required] + public string Locale { get; set; } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 5faec1cde..e63549c6c 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -100,6 +100,10 @@ public sealed class DataContext : IdentityDbContext() .Property(b => b.BookReaderWritingStyle) .HasDefaultValue(WritingStyle.Horizontal); + builder.Entity() + .Property(b => b.Locale) + .IsRequired(true) + .HasDefaultValue("en"); builder.Entity() .Property(b => b.AllowScrobbling) diff --git a/API/Data/Migrations/20230727175518_AddLocaleOnPrefs.Designer.cs b/API/Data/Migrations/20230727175518_AddLocaleOnPrefs.Designer.cs new file mode 100644 index 000000000..fb1afbeb9 --- /dev/null +++ b/API/Data/Migrations/20230727175518_AddLocaleOnPrefs.Designer.cs @@ -0,0 +1,2275 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20230727175518_AddLocaleOnPrefs")] + partial class AddLocaleOnPrefs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.9"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20230727175518_AddLocaleOnPrefs.cs b/API/Data/Migrations/20230727175518_AddLocaleOnPrefs.cs new file mode 100644 index 000000000..6b8d01bfe --- /dev/null +++ b/API/Data/Migrations/20230727175518_AddLocaleOnPrefs.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AddLocaleOnPrefs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Locale", + table: "AppUserPreferences", + type: "TEXT", + nullable: false, + defaultValue: "en"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Locale", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 6efb397b4..48c49423c 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -272,6 +272,12 @@ namespace API.Data.Migrations b.Property("LayoutMode") .HasColumnType("INTEGER"); + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + b.Property("NoTransitions") .HasColumnType("INTEGER"); diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index e93bcc753..132aa9310 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -28,7 +28,7 @@ public interface IAppUserProgressRepository Task> GetUserProgressForSeriesAsync(int seriesId, int userId); Task> GetAllProgress(); Task GetLatestProgress(); - Task GetUserProgressDtoAsync(int chapterId, int userId); + Task GetUserProgressDtoAsync(int chapterId, int userId); Task AnyUserProgressForSeriesAsync(int seriesId, int userId); Task GetHighestFullyReadChapterForSeries(int seriesId, int userId); Task GetHighestFullyReadVolumeForSeries(int seriesId, int userId); @@ -143,7 +143,7 @@ public class AppUserProgressRepository : IAppUserProgressRepository .FirstOrDefaultAsync(); } - public async Task GetUserProgressDtoAsync(int chapterId, int userId) + public async Task GetUserProgressDtoAsync(int chapterId, int userId) { return await _context.AppUserProgresses .Where(p => p.AppUserId == userId && p.ChapterId == chapterId) diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index ef525a8cf..2db949794 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -73,7 +73,7 @@ public interface IUserRepository Task> GetSeriesWithReviews(int userId); Task HasHoldOnSeries(int userId, int seriesId); Task> GetHolds(int userId); - + Task GetLocale(int userId); } public class UserRepository : IUserRepository @@ -291,6 +291,13 @@ public class UserRepository : IUserRepository .ToListAsync(); } + public async Task GetLocale(int userId) + { + return await _context.AppUserPreferences.Where(p => p.AppUserId == userId) + .Select(p => p.Locale) + .SingleAsync(); + } + public async Task> GetAdminUsersAsync() { return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index d3a980dc7..640ecc1ea 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -127,6 +127,10 @@ public class AppUserPreferences /// UI Site Global Setting: Should series reviews be shared with all users in the server /// public bool ShareReviews { get; set; } = false; + /// + /// UI Site Global Setting: The language locale that should be used for the user + /// + public string Locale { get; set; } public AppUser AppUser { get; set; } = null!; public int AppUserId { get; set; } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index a020bc35d..f6c2844d9 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -66,6 +66,8 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/API/Helpers/Builders/AppUserBuilder.cs b/API/Helpers/Builders/AppUserBuilder.cs index d837b3ff5..fd8ae45e6 100644 --- a/API/Helpers/Builders/AppUserBuilder.cs +++ b/API/Helpers/Builders/AppUserBuilder.cs @@ -37,4 +37,11 @@ public class AppUserBuilder : IEntityBuilder _appUser.Libraries.Add(library); return this; } + + public AppUserBuilder WithLocale(string locale) + { + _appUser.UserPreferences.Locale = locale; + return this; + } + } diff --git a/API/I18N/en.json b/API/I18N/en.json new file mode 100644 index 000000000..29babf0bb --- /dev/null +++ b/API/I18N/en.json @@ -0,0 +1,185 @@ +{ + "confirm-email": "You must confirm your email first", + "bad-credentials": "Your credentials are not correct", + "locked-out": "You've been locked out from too many authorization attempts. Please wait 10 minutes.", + "disabled-account": "Your account is disabled. Contact the server admin.", + "register-user": "Something went wrong when registering user", + "validate-email": "There was an issue validating your email: {0}", + "confirm-token-gen": "There was an issue generating a confirmation token", + "denied": "Not allowed", + "permission-denied": "You are not permitted to this operation", + "password-required": "You must enter your existing password to change your account unless you're an admin", + "invalid-password": "Invalid Password", + "invalid-token": "Invalid token", + "unable-to-reset-key": "Something went wrong, unable to reset key", + "invalid-payload": "Invalid payload", + "nothing-to-do": "Nothing to do", + "share-multiple-emails": "You cannot share emails across multiple accounts", + "generate-token": "There was an issue generating a confirmation email token. See logs", + "age-restriction-update": "There was an error updating the age restriction", + "no-user": "User does not exist", + "username-taken": "Username already taken", + "user-already-confirmed": "User is already confirmed", + "generic-user-update": "There was an exception when updating the user", + "manual-setup-fail": "Manual setup is unable to be completed. Please cancel and recreate the invite", + "user-already-registered": "User is already registered as {0}", + "user-already-invited": "User is already invited under this email and has yet to accepted invite.", + "generic-invite-user": "There was an issue inviting the user. Please check logs.", + "invalid-email-confirmation": "Invalid email confirmation", + "generic-user-email-update": "Unable to update email for user. Check logs.", + "generic-password-update": "There was an unexpected error when confirming new password", + "password-updated": "Password Updated", + "forgot-password-generic": "An email will be sent to the email if it exists in our database", + "not-accessible-password": "Your server is not accessible. The link to reset your password is in the logs", + "not-accessible": "Your server is not accessible externally", + "email-sent": "Email sent", + "user-migration-needed": "This user needs to migrate. Have them log out and login to trigger a migration flow", + "generic-invite-email": "There was an issue resending invite email", + "admin-already-exists": "Admin already exists", + "invalid-username": "Invalid username", + "critical-email-migration": "There was an issue during email migration. Contact support", + + "chapter-doesnt-exist": "Chapter does not exist", + "file-missing": "File was not found in book", + + "collection-updated": "Collection updated successfully", + "generic-error": "Something went wrong, please try again", + "collection-doesnt-exist": "Collection does not exist", + + "device-doesnt-exist": "Device does not exist", + "generic-device-create": "There was an error when creating the device", + "generic-device-update": "There was an error when updating the device", + "generic-device-delete": "There was an error when deleting the device", + "greater-0": "{0} must be greater than 0", + "send-to-kavita-email": "Send to device cannot be used with Kavita's email service. Please configure your own.", + "send-to-device-status": "Transferring files to your device", + "generic-send-to": "There was an error sending the file(s) to the device", + "series-doesnt-exist": "Series does not exist", + + "volume-doesnt-exist": "Volume does not exist", + "bookmarks-empty": "Bookmarks cannot be empty", + + "no-cover-image": "No cover image", + "bookmark-doesnt-exist": "Bookmark does not exist", + "must-be-defined": "{0} must be defined", + "generic-favicon": "There was an issue fetching favicon for domain", + "invalid-filename": "Invalid Filename", + "file-doesnt-exist": "File does not exist", + + "library-name-exists": "Library name already exists. Please choose a unique name to the server.", + "generic-library": "There was a critical issue. Please try again.", + "no-library-access": "User does not have access to this library", + "user-doesnt-exist": "User does not exist", + "library-doesnt-exist": "Library does not exist", + "invalid-path": "Invalid Path", + "delete-library-while-scan": "You cannot delete a library while a scan is in progress. Please wait for scan to complete or restart Kavita then try to delete", + "generic-library-update": "There was a critical issue updating the library.", + + "pdf-doesnt-exist": "PDF does not exist when it should", + "invalid-access": "Invalid Access", + "no-image-for-page": "No such image for page {0}. Try refreshing to allow re-cache.", + "perform-scan": "Please perform a scan on this series or library and try again", + "generic-read-progress": "There was an issue saving progress", + "generic-clear-bookmarks": "Could not clear bookmarks", + "bookmark-permission": "You do not have permission to bookmark/unbookmark", + "bookmark-save": "Could not save bookmark", + "cache-file-find": "Could not find cached image. Reload and try again.", + "name-required": "Name cannot be empty", + "valid-number": "Must be valid page number", + "duplicate-bookmark": "Duplicate bookmark entry already exists", + + "reading-list-permission": "You do not have permissions on this reading list or the list doesn't exist", + "reading-list-position": "Couldn't update position", + "reading-list-updated": "Updated", + "reading-list-item-delete": "Couldn't delete item(s)", + "reading-list-deleted": "Reading List was deleted", + "generic-reading-list-delete": "There was an issue deleting the reading list", + "generic-reading-list-update": "There was an issue updating the reading list", + "generic-reading-list-create": "There was an issue creating the reading list", + "reading-list-doesnt-exist": "Reading list does not exist", + + "series-restricted": "User does not have access to this Series", + + "generic-scrobble-hold": "An error occurred while adding the hold", + + "libraries-restricted": "User does not have access to any libraries", + + "no-series": "Could not get series for Library", + "no-series-collection": "Could not get series for Collection", + "generic-series-delete": "There was an issue deleting the series", + "generic-series-update": "There was an error with updating the series", + "series-updated": "Successfully updated", + "update-metadata-fail": "Could not update metadata", + "age-restriction-not-applicable": "No Restriction", + "generic-relationship": "There was an issue updating relationships", + + "job-already-running": "Job already running", + "encode-as-warning": "You cannot convert to PNG. For covers, use Refresh Covers. Bookmarks and favicons cannot be encoded back.", + + "ip-address-invalid": "IP Address '{0}' is invalid", + "bookmark-dir-permissions": "Bookmark Directory does not have correct permissions for Kavita to use", + "total-backups": "Total Backups must be between 1 and 30", + "total-logs": "Total Logs must be between 1 and 30", + + "stats-permission-denied": "You are not authorized to view another user's statistics", + + "url-not-valid": "Url does not return a valid image or requires authorization", + "url-required": "You must pass a url to use", + "generic-cover-series-save": "Unable to save cover image to Series", + "generic-cover-collection-save": "Unable to save cover image to Collection", + "generic-cover-reading-list-save": "Unable to save cover image to Reading List", + "generic-cover-chapter-save": "Unable to save cover image to Chapter", + "generic-cover-library-save": "Unable to save cover image to Library", + "access-denied": "You do not have access", + "reset-chapter-lock": "Unable to resetting cover lock for Chapter", + + "generic-user-delete": "Could not delete the user", + "generic-user-pref": "There was an issue saving preferences", + + "opds-disabled": "OPDS is not enabled on this server", + "on-deck": "On Deck", + "browse-on-deck": "Browse On Deck", + "recently-added": "Recently Added", + "browse-recently-added": "Browse Recently Added", + "reading-lists": "Reading Lists", + "browse-reading-lists": "Browse by Reading Lists", + "libraries": "All Libraries", + "browse-libraries": "Browse by Libraries", + "collections": "All Collections", + "browse-collections": "Browse by Collections", + "reading-list-restricted": "Reading list does not exist or you don't have access", + "query-required": "You must pass a query parameter", + "search": "Search", + "search-description": "Search for Series, Collections, or Reading Lists", + "favicon-doesnt-exist": "Favicon does not exist", + + "not-authenticated": "User is not authenticated", + "unable-to-register-k+": "Unable to register license due to error. Reach out to Kavita+ Support", + "anilist-cred-expired": "AniList Credentials have expired or not set", + "scrobble-bad-payload": "Bad payload from Scrobble Provider", + "theme-doesnt-exist": "Theme file missing or invalid", + "bad-copy-files-for-download": "Unable to copy files to temp directory archive download.", + "generic-create-temp-archive": "There was an issue creating temp archive", + "epub-malformed": "The file is malformed! Cannot read.", + "epub-html-missing": "Could not find the appropriate html for that page", + "collection-tag-title-required": "Collection Title cannot be empty", + "reading-list-title-required": "Reading List Title cannot be empty", + "collection-tag-duplicate": "A collection with this name already exists", + "device-duplicate": "A device with this name already exists", + "device-not-created": "This device doesn't exist yet. Please create first", + "send-to-permission": "Cannot Send non-EPUB or PDF to devices as not supported on Kindle", + "progress-must-exist": "Progress must exist on user", + "reading-list-name-exists": "A reading list of this name already exists", + "user-no-access-library-from-series": "User does not have access to the library this series belongs to", + "series-restricted-age-restriction": "User is not allowed to view this series due to age restrictions", + + + + "volume-num": "Volume {0}", + "book-num": "Book {0}", + "issue-num": "Issue {0}{1}", + "chapter-num": "Chapter {0}" + + + +} diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 671eab1a6..a4a9d3ccb 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -301,7 +301,7 @@ public class ArchiveService : IArchiveService if (!_directoryService.CopyFilesToDirectory(files, tempLocation)) { - throw new KavitaException("Unable to copy files to temp directory archive download."); + throw new KavitaException("bad-copy-files-for-download"); } var zipPath = Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip"); @@ -314,7 +314,7 @@ public class ArchiveService : IArchiveService catch (AggregateException ex) { _logger.LogError(ex, "There was an issue creating temp archive"); - throw new KavitaException("There was an issue creating temp archive"); + throw new KavitaException("generic-create-temp-archive"); } return zipPath; diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 95863e263..d03a44daa 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -1121,7 +1121,7 @@ public class BookService : IBookService if (doc.ParseErrors.Any()) { LogBookErrors(book, contentFileRef, doc); - throw new KavitaException("The file is malformed! Cannot read."); + throw new KavitaException("epub-malformed"); } _logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath); doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("")); @@ -1137,7 +1137,7 @@ public class BookService : IBookService "There was an issue reading one of the pages for", ex); } - throw new KavitaException("Could not find the appropriate html for that page"); + throw new KavitaException("epub-html-missing"); } private static void CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, IList nestedChapters, diff --git a/API/Services/CollectionTagService.cs b/API/Services/CollectionTagService.cs index f625223b7..7c5aeaa71 100644 --- a/API/Services/CollectionTagService.cs +++ b/API/Services/CollectionTagService.cs @@ -52,12 +52,12 @@ public class CollectionTagService : ICollectionTagService public async Task UpdateTag(CollectionTagDto dto) { var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(dto.Id); - if (existingTag == null) throw new KavitaException("This tag does not exist"); + if (existingTag == null) throw new KavitaException("collection-doesnt-exist"); var title = dto.Title.Trim(); - if (string.IsNullOrEmpty(title)) throw new KavitaException("Title cannot be empty"); + if (string.IsNullOrEmpty(title)) throw new KavitaException("collection-tag-title-required"); if (!title.Equals(existingTag.Title) && await TagExistsByName(dto.Title)) - throw new KavitaException("A tag with this name already exists"); + throw new KavitaException("collection-tag-duplicate"); existingTag.SeriesMetadatas ??= new List(); existingTag.Title = title; diff --git a/API/Services/DeviceService.cs b/API/Services/DeviceService.cs index 97baa36ff..02cdddd62 100644 --- a/API/Services/DeviceService.cs +++ b/API/Services/DeviceService.cs @@ -42,7 +42,7 @@ public class DeviceService : IDeviceService { userWithDevices.Devices ??= new List(); var existingDevice = userWithDevices.Devices.SingleOrDefault(d => d.Name!.Equals(dto.Name)); - if (existingDevice != null) throw new KavitaException("A device with this name already exists"); + if (existingDevice != null) throw new KavitaException("device-duplicate"); existingDevice = new DeviceBuilder(dto.Name) .WithPlatform(dto.Platform) @@ -70,7 +70,7 @@ public class DeviceService : IDeviceService try { var existingDevice = userWithDevices.Devices.SingleOrDefault(d => d.Id == dto.Id); - if (existingDevice == null) throw new KavitaException("This device doesn't exist yet. Please create first"); + if (existingDevice == null) throw new KavitaException("device-not-created"); existingDevice.Name = dto.Name; existingDevice.Platform = dto.Platform; @@ -108,11 +108,11 @@ public class DeviceService : IDeviceService public async Task SendTo(IReadOnlyList chapterIds, int deviceId) { var device = await _unitOfWork.DeviceRepository.GetDeviceById(deviceId); - if (device == null) throw new KavitaException("Device doesn't exist"); + if (device == null) throw new KavitaException("device-doesnt-exist"); var files = await _unitOfWork.ChapterRepository.GetFilesForChaptersAsync(chapterIds); if (files.Any(f => f.Format is not (MangaFormat.Epub or MangaFormat.Pdf)) && device.Platform == DevicePlatform.Kindle) - throw new KavitaException("Cannot Send non Epub or Pdf to devices as not supported on Kindle"); + throw new KavitaException("send-to-permission"); device.UpdateLastUsed(); diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index f4b18339e..280f1cbb4 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -25,6 +25,7 @@ public interface IDirectoryService string ConfigDirectory { get; } string SiteThemeDirectory { get; } string FaviconDirectory { get; } + string LocalizationDirectory { get; } /// /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. /// @@ -79,6 +80,7 @@ public class DirectoryService : IDirectoryService public string BookmarkDirectory { get; } public string SiteThemeDirectory { get; } public string FaviconDirectory { get; } + public string LocalizationDirectory { get; } private readonly ILogger _logger; private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase; @@ -95,22 +97,23 @@ public class DirectoryService : IDirectoryService { _logger = logger; FileSystem = fileSystem; - CoverImageDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "covers"); - CacheDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "cache"); - LogDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "logs"); - TempDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "temp"); ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config"); - BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks"); - SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes"); - FaviconDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "favicons"); - - ExistOrCreate(SiteThemeDirectory); + ExistOrCreate(ConfigDirectory); + CoverImageDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "covers"); ExistOrCreate(CoverImageDirectory); + CacheDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "cache"); ExistOrCreate(CacheDirectory); + LogDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "logs"); ExistOrCreate(LogDirectory); + TempDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "temp"); ExistOrCreate(TempDirectory); + BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks"); ExistOrCreate(BookmarkDirectory); + SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes"); + ExistOrCreate(SiteThemeDirectory); + FaviconDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "favicons"); ExistOrCreate(FaviconDirectory); + LocalizationDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "I18N"); } /// diff --git a/API/Services/LocalizationService.cs b/API/Services/LocalizationService.cs new file mode 100644 index 000000000..273bf8141 --- /dev/null +++ b/API/Services/LocalizationService.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using API.Data; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Hosting; + +namespace API.Services; +#nullable enable + + +public interface ILocalizationService +{ + Task Get(string locale, string key, params object[] args); + Task Translate(int userId, string key, params object[] args); + IEnumerable GetLocales(); +} + +public class LocalizationService : ILocalizationService +{ + private readonly IDirectoryService _directoryService; + private readonly IMemoryCache _cache; + private readonly IUnitOfWork _unitOfWork; + + /// + /// The locales for the UI + /// + private readonly string _localizationDirectoryUi; + + private readonly MemoryCacheEntryOptions _cacheOptions; + + + public LocalizationService(IDirectoryService directoryService, + IHostEnvironment environment, IMemoryCache cache, IUnitOfWork unitOfWork) + { + _directoryService = directoryService; + _cache = cache; + _unitOfWork = unitOfWork; + if (environment.IsDevelopment()) + { + _localizationDirectoryUi = directoryService.FileSystem.Path.Join( + directoryService.FileSystem.Directory.GetCurrentDirectory(), + "UI/Web/src/assets/langs"); + } else if (environment.EnvironmentName.Equals("Testing", StringComparison.OrdinalIgnoreCase)) + { + _localizationDirectoryUi = directoryService.FileSystem.Path.Join( + directoryService.FileSystem.Directory.GetCurrentDirectory(), + "/../../../../../UI/Web/src/assets/langs"); + } + else + { + _localizationDirectoryUi = directoryService.FileSystem.Path.Join( + directoryService.FileSystem.Directory.GetCurrentDirectory(), + "wwwroot", "assets/langs"); + } + + _cacheOptions = new MemoryCacheEntryOptions() + .SetSize(1) + .SetAbsoluteExpiration(TimeSpan.FromMinutes(15)); + } + + /// + /// Loads a language, if language is blank, falls back to english + /// + /// + /// + public async Task?> LoadLanguage(string languageCode) + { + if (string.IsNullOrWhiteSpace(languageCode)) languageCode = "en"; + var languageFile = _directoryService.FileSystem.Path.Join(_directoryService.LocalizationDirectory, languageCode + ".json"); + if (!_directoryService.FileSystem.FileInfo.New(languageFile).Exists) + throw new ArgumentException($"Language {languageCode} does not exist"); + + var json = await _directoryService.FileSystem.File.ReadAllTextAsync(languageFile); + return JsonSerializer.Deserialize>(json); + } + + public async Task Get(string locale, string key, params object[] args) + { + + // Check if the translation for the given locale is cached + var cacheKey = $"{locale}_{key}"; + if (!_cache.TryGetValue(cacheKey, out string? translatedString)) + { + // Load the locale JSON file + var translationData = await LoadLanguage(locale); + + // Find the translation for the given key + if (translationData != null && translationData.TryGetValue(key, out var value)) + { + translatedString = value; + + // Cache the translation for subsequent requests + _cache.Set(cacheKey, translatedString, _cacheOptions); + } + } + + + if (string.IsNullOrEmpty(translatedString)) + { + if (!locale.Equals("en")) + { + return await Get("en", key, args); + } + return key; + } + + // Format the translated string with arguments + if (args.Length > 0) + { + translatedString = string.Format(translatedString, args); + } + + return translatedString; + } + + /// + /// Returns a translated string for a given user's locale, falling back to english or the key if missing + /// + /// + /// + /// + /// + public async Task Translate(int userId, string key, params object[] args) + { + var userLocale = await _unitOfWork.UserRepository.GetLocale(userId); + return await Get(userLocale, key, args); + } + + + /// + /// Returns all available locales that exist on both the Frontend and the Backend + /// + /// + public IEnumerable GetLocales() + { + return + _directoryService.GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json") + .Select(f => _directoryService.FileSystem.Path.GetFileName(f).Replace(".json", string.Empty)) + .Union(_directoryService.GetFilesWithExtension(_directoryService.LocalizationDirectory, @"\.json") + .Select(f => _directoryService.FileSystem.Path.GetFileName(f).Replace(".json", string.Empty))) + .Distinct(); + } +} diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index 0a2b25cea..ccbdd133f 100644 --- a/API/Services/Plus/LicenseService.cs +++ b/API/Services/Plus/LicenseService.cs @@ -164,7 +164,7 @@ public class LicenseService : ILicenseService var serverSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); var lic = await RegisterLicense(license, email); if (string.IsNullOrWhiteSpace(lic)) - throw new KavitaException("Unable to register license due to error. Reach out to Kavita+ Support"); + throw new KavitaException("unable-to-register-k+"); serverSetting.Value = lic; _unitOfWork.SettingsRepository.Update(serverSetting); await _unitOfWork.CommitAsync(); diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index c2f3477c0..5ee8e9dc5 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -60,6 +60,7 @@ public class ScrobblingService : IScrobblingService private readonly IEventHub _eventHub; private readonly ILogger _logger; private readonly ILicenseService _licenseService; + private readonly ILocalizationService _localizationService; public const string AniListWeblinkWebsite = "https://anilist.co/manga/"; public const string MalWeblinkWebsite = "https://myanimelist.net/manga/"; @@ -87,13 +88,15 @@ public class ScrobblingService : IScrobblingService public ScrobblingService(IUnitOfWork unitOfWork, ITokenService tokenService, - IEventHub eventHub, ILogger logger, ILicenseService licenseService) + IEventHub eventHub, ILogger logger, ILicenseService licenseService, + ILocalizationService localizationService) { _unitOfWork = unitOfWork; _tokenService = tokenService; _eventHub = eventHub; _logger = logger; _licenseService = licenseService; + _localizationService = localizationService; FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); @@ -184,11 +187,11 @@ public class ScrobblingService : IScrobblingService var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList); if (await HasTokenExpired(token, ScrobbleProvider.AniList)) { - throw new KavitaException("AniList Credentials have expired or not set"); + throw new KavitaException(await _localizationService.Translate(userId, "unable-to-register-k+")); } var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); - if (series == null) throw new KavitaException("Series not found"); + if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); if (library is not {AllowScrobbling: true}) return; if (library.Type == LibraryType.Comic) return; @@ -229,11 +232,11 @@ public class ScrobblingService : IScrobblingService var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList); if (await HasTokenExpired(token, ScrobbleProvider.AniList)) { - throw new KavitaException("AniList Credentials have expired or not set"); + throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired")); } var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); - if (series == null) throw new KavitaException("Series not found"); + if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); if (library is not {AllowScrobbling: true}) return; if (library.Type == LibraryType.Comic) return; @@ -273,11 +276,11 @@ public class ScrobblingService : IScrobblingService var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList); if (await HasTokenExpired(token, ScrobbleProvider.AniList)) { - throw new KavitaException("AniList Credentials have expired or not set"); + throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired")); } var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); - if (series == null) throw new KavitaException("Series not found"); + if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId)) { _logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name, userId); @@ -338,11 +341,11 @@ public class ScrobblingService : IScrobblingService var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList); if (await HasTokenExpired(token, ScrobbleProvider.AniList)) { - throw new KavitaException("AniList Credentials have expired or not set"); + throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired")); } var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); - if (series == null) throw new KavitaException("Series not found"); + if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); if (library is not {AllowScrobbling: true}) return; if (library.Type == LibraryType.Comic) return; diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index c1847bf8a..b4874b498 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -117,7 +117,7 @@ public class ReaderService : IReaderService { var seenVolume = new Dictionary(); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); - if (series == null) throw new KavitaException("Series suddenly doesn't exist, cannot mark as read"); + if (series == null) throw new KavitaException("series-doesnt-exist"); foreach (var chapter in chapters) { var userProgress = GetUserProgressForChapter(user, chapter); @@ -202,8 +202,9 @@ public class ReaderService : IReaderService if (user.Progresses == null) { - throw new KavitaException("Progresses must exist on user"); + throw new KavitaException("progress-must-exist"); } + try { userProgress = diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index 7bc811172..a9d839069 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -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 /// /// Methods responsible for management of Reading Lists /// -/// If called from API layer, expected for to be called beforehand +/// If called from API layer, expected for to be called beforehand 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 /// /// Removes all entries that are fully read from the reading list. This commits /// - /// If called from API layer, expected for to be called beforehand + /// If called from API layer, expected for to be called beforehand /// Reading List Id /// User /// @@ -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 /// public async Task 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() }; - 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(); 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 FindCblImportConflicts(IEnumerable userSeries) { var dict = new HashSet(); - 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(); diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index fcc6846d4..df27791fe 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -30,6 +30,12 @@ public interface ISeriesService Task DeleteMultipleSeries(IList seriesIds); Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto); Task GetRelatedSeries(int userId, int seriesId); + Task FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true); + Task FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true); + + Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string? chapterTitle, + bool withHash); + Task FormatChapterName(int userId, LibraryType libraryType, bool withHash = false); } public class SeriesService : ISeriesService @@ -39,15 +45,17 @@ public class SeriesService : ISeriesService private readonly ITaskScheduler _taskScheduler; private readonly ILogger _logger; private readonly IScrobblingService _scrobblingService; + private readonly ILocalizationService _localizationService; public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler, - ILogger logger, IScrobblingService scrobblingService) + ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService) { _unitOfWork = unitOfWork; _eventHub = eventHub; _taskScheduler = taskScheduler; _logger = logger; _scrobblingService = scrobblingService; + _localizationService = localizationService; } /// @@ -382,16 +390,17 @@ public class SeriesService : ISeriesService var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var libraryIds = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId); if (!libraryIds.Contains(series.LibraryId)) - throw new UnauthorizedAccessException("User does not have access to the library this series belongs to"); + throw new UnauthorizedAccessException("user-no-access-library-from-series"); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user!.AgeRestriction != AgeRating.NotApplicable) { var seriesMetadata = await _unitOfWork.SeriesRepository.GetSeriesMetadata(seriesId); if (seriesMetadata!.AgeRating > user.AgeRestriction) - throw new UnauthorizedAccessException("User is not allowed to view this series due to age restrictions"); + throw new UnauthorizedAccessException("series-restricted-age-restriction"); } + var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)) .OrderBy(v => Tasks.Scanner.Parser.Parser.MinNumberFromRange(v.Name)) @@ -401,13 +410,14 @@ public class SeriesService : ISeriesService var processedVolumes = new List(); if (libraryType == LibraryType.Book) { + var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty); foreach (var volume in volumes) { volume.Chapters = volume.Chapters.OrderBy(d => double.Parse(d.Number), ChapterSortComparer.Default).ToList(); var firstChapter = volume.Chapters.First(); // On Books, skip volumes that are specials, since these will be shown if (firstChapter.IsSpecial) continue; - RenameVolumeName(firstChapter, volume, libraryType); + RenameVolumeName(firstChapter, volume, libraryType, volumeLabel); processedVolumes.Add(volume); } } @@ -431,7 +441,7 @@ public class SeriesService : ISeriesService foreach (var chapter in chapters) { - chapter.Title = FormatChapterTitle(chapter, libraryType); + chapter.Title = await FormatChapterTitle(userId, chapter, libraryType); if (!chapter.IsSpecial) continue; if (!string.IsNullOrEmpty(chapter.TitleName)) chapter.Title = chapter.TitleName; @@ -481,7 +491,7 @@ public class SeriesService : ISeriesService return !chapter.IsSpecial && !chapter.Number.Equals(Tasks.Scanner.Parser.Parser.DefaultChapter); } - public static void RenameVolumeName(ChapterDto firstChapter, VolumeDto volume, LibraryType libraryType) + public static void RenameVolumeName(ChapterDto firstChapter, VolumeDto volume, LibraryType libraryType, string volumeLabel = "Volume") { if (libraryType == LibraryType.Book) { @@ -496,19 +506,19 @@ public class SeriesService : ISeriesService { volume.Name += $" - {firstChapter.TitleName}"; } - else - { - volume.Name += $""; - } + // else + // { + // volume.Name += $""; + // } return; } - volume.Name = $"Volume {volume.Name}"; + volume.Name = $"{volumeLabel} {volume.Name}".Trim(); } - private static string FormatChapterTitle(bool isSpecial, LibraryType libraryType, string? chapterTitle, bool withHash) + public async Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string? chapterTitle, bool withHash) { if (string.IsNullOrEmpty(chapterTitle)) throw new ArgumentException("Chapter Title cannot be null"); @@ -520,32 +530,33 @@ public class SeriesService : ISeriesService var hashSpot = withHash ? "#" : string.Empty; return libraryType switch { - LibraryType.Book => $"Book {chapterTitle}", - LibraryType.Comic => $"Issue {hashSpot}{chapterTitle}", - LibraryType.Manga => $"Chapter {chapterTitle}", - _ => "Chapter " + LibraryType.Book => await _localizationService.Translate(userId, "book-num", chapterTitle), + LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", hashSpot, chapterTitle), + LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", chapterTitle), + _ => await _localizationService.Translate(userId, "chapter-num", ' ') }; } - public static string FormatChapterTitle(ChapterDto chapter, LibraryType libraryType, bool withHash = true) + public async Task FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true) { - return FormatChapterTitle(chapter.IsSpecial, libraryType, chapter.Title, withHash); + return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Title, withHash); } - public static string FormatChapterTitle(Chapter chapter, LibraryType libraryType, bool withHash = true) + public async Task FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true) { - return FormatChapterTitle(chapter.IsSpecial, libraryType, chapter.Title, withHash); + return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Title, withHash); } - public static string FormatChapterName(LibraryType libraryType, bool withHash = false) + public async Task FormatChapterName(int userId, LibraryType libraryType, bool withHash = false) { - return libraryType switch + var hashSpot = withHash ? "#" : string.Empty; + return (libraryType switch { - LibraryType.Manga => "Chapter", - LibraryType.Comic => withHash ? "Issue #" : "Issue", - LibraryType.Book => "Book", - _ => "Chapter" - }; + LibraryType.Book => await _localizationService.Translate(userId, "book-num", string.Empty), + LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", hashSpot, string.Empty), + LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", string.Empty), + _ => await _localizationService.Translate(userId, "chapter-num", ' ') + }).Trim(); } /// diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/Tasks/SiteThemeService.cs index 734d2545a..7dc1ce01b 100644 --- a/API/Services/Tasks/SiteThemeService.cs +++ b/API/Services/Tasks/SiteThemeService.cs @@ -40,10 +40,10 @@ public class ThemeService : IThemeService public async Task GetContent(int themeId) { var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId); - if (theme == null) throw new KavitaException("Theme file missing or invalid"); + if (theme == null) throw new KavitaException("theme-doesnt-exist"); var themeFile = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, theme.FileName); if (string.IsNullOrEmpty(themeFile) || !_directoryService.FileSystem.File.Exists(themeFile)) - throw new KavitaException("Theme file missing or invalid"); + throw new KavitaException("theme-doesnt-exist"); return await _directoryService.FileSystem.File.ReadAllTextAsync(themeFile); } @@ -151,7 +151,7 @@ public class ThemeService : IThemeService try { var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId); - if (theme == null) throw new KavitaException("Theme file missing or invalid"); + if (theme == null) throw new KavitaException("theme-doesnt-exist"); foreach (var siteTheme in await _unitOfWork.SiteThemeRepository.GetThemes()) { diff --git a/Kavita.sln.DotSettings b/Kavita.sln.DotSettings index 7fa4d2cb2..92adaa72f 100644 --- a/Kavita.sln.DotSettings +++ b/Kavita.sln.DotSettings @@ -7,6 +7,7 @@ True True True + True True True True diff --git a/README.md b/README.md index 34aa61b17..ecacc940a 100644 --- a/README.md +++ b/README.md @@ -14,20 +14,22 @@ your reading collection with your friends and family! [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Kareadita_Kavita&metric=security_rating)](https://sonarcloud.io/dashboard?id=Kareadita_Kavita) [![Backers on Open Collective](https://opencollective.com/kavita/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/kavita/sponsors/badge.svg)](#sponsors) + +Translation status + -## Goals -- [x] Serve up Manga/Webtoons/Comics (cbr, cbz, zip/rar/rar5, 7zip, raw images) and Books (epub, pdf) -- [x] First class responsive readers that work great on any device (phone, tablet, desktop) -- [x] Dark mode and customizable theming support -- [x] External metadata integration and scrobbling for read status, ratings, and reviews (available via Kavita+) -- [x] Rich Metadata support with filtering and searching -- [x] Ways to group reading material: Collections, Reading Lists, Want to Read -- [x] Ability to manage users, access, and ratings -- [x] Fully Accessible with active accessibility audits -- [x] Dedicated webtoon reading mode -- [ ] Full localization support -- [ ] And so much [more...](https://github.com/Kareadita/Kavita/projects) +## What Kavita Provides +- Serve up Manga/Webtoons/Comics (cbr, cbz, zip/rar/rar5, 7zip, raw images) and Books (epub, pdf) +- First class responsive readers that work great on any device (phone, tablet, desktop) +- Dark mode and customizable theming support +- External metadata integration and scrobbling for read status, ratings, and reviews (available via Kavita+) +- Rich Metadata support with filtering and searching +- Ways to group reading material: Collections, Reading Lists (CBL Import), Want to Read +- Ability to manage users with rich Role-based management for age restrictions, abilities within the app, etc +- Rich web readers supporting webtoon, continuous reading mode (continue without leaving the reader), virtual pages (epub), etc +- Full Localization Support + ## Support [![Reddit](https://img.shields.io/badge/reddit-discussion-FF4500.svg?maxAge=60)](https://www.reddit.com/r/KavitaManga/) @@ -101,6 +103,9 @@ Thank you to [ JetBrains](http: ## Palace-Designs We would like to extend a big thank you to [](https://www.palace-designs.com/) who hosts our infrastructure pro-bono. +## Weblate +Thank you to [Weblate](https://hosted.weblate.org/engage/kavita/) who hosts our localization infrastructure pro-bono. If you want to help localize Kavita, please visit them. + ## Huntr We would like to extend a big thank you to [Huntr](https://huntr.dev/repos/kareadita/kavita) who has worked with Kavita in reporting security vulnerabilities. If you are interested in being paid to help secure Kavita, please give them a try. diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index e3ba7a6c9..8b8b16066 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -23,6 +23,11 @@ "@iplab/ngx-file-upload": "^16.0.1", "@microsoft/signalr": "^7.0.9", "@ng-bootstrap/ng-bootstrap": "^15.1.0", + "@ngneat/transloco": "^4.3.0", + "@ngneat/transloco-locale": "^4.1.0", + "@ngneat/transloco-persist-lang": "^4.0.0", + "@ngneat/transloco-persist-translations": "^4.0.0", + "@ngneat/transloco-preload-langs": "^4.0.1", "@popperjs/core": "^2.11.7", "@swimlane/ngx-charts": "^20.1.2", "@tweenjs/tween.js": "^21.0.0", @@ -53,6 +58,7 @@ "@angular-eslint/template-parser": "^16.1.0", "@angular/cli": "^16.1.5", "@angular/compiler-cli": "^16.1.6", + "@ngneat/transloco-optimize": "^3.0.2", "@types/d3": "^7.4.0", "@types/node": "^20.4.4", "@typescript-eslint/eslint-plugin": "^6.1.0", @@ -3169,6 +3175,120 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@ngneat/transloco": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@ngneat/transloco/-/transloco-4.3.0.tgz", + "integrity": "sha512-KUhGvp1ki+jvrM2PO27Tgzme1HkFmvDgS+7VyGxHta35wZEyoH6/r/EAXvfurPeYgaP6IaEMhUvAVT1WDgYwUg==", + "dependencies": { + "@ngneat/transloco-utils": "3.0.5", + "flat": "5.0.2", + "lodash.kebabcase": "^4.1.1", + "ora": "^5.4.1", + "replace-in-file": "^6.2.0", + "tslib": "^2.2.0" + }, + "peerDependencies": { + "@angular/core": ">=13.0.0", + "fs-extra": ">=9.1.0", + "glob": ">=7.1.7", + "rxjs": ">=6.0.0" + } + }, + "node_modules/@ngneat/transloco-locale": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ngneat/transloco-locale/-/transloco-locale-4.1.0.tgz", + "integrity": "sha512-xtev6RXEPXh3kJ/xR/aq52z3ZVlv2wCGfc2kT+LQhLsW045zT0e5PJPHjO/xGNmp+T/Z8Axu1h08KwRQUW4Jyg==", + "dependencies": { + "tslib": "^2.2.0" + }, + "peerDependencies": { + "@angular/core": ">=13.0.0", + "@ngneat/transloco": ">=4.0.0", + "rxjs": ">=6.0.0" + } + }, + "node_modules/@ngneat/transloco-optimize": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@ngneat/transloco-optimize/-/transloco-optimize-3.0.2.tgz", + "integrity": "sha512-yCRJGofjIZSCcEf38UEmYkQ3Ez38PKtx88CC2FgbcLgmkdkUB2/xVeRtUN6f0nNt2bJSgMtJvwm7BrttVIACDg==", + "dev": true, + "dependencies": { + "command-line-args": "^5.2.0", + "flat": "^5.0.2", + "glob": "^7.1.7" + }, + "bin": { + "transloco-optimize": "src/index.js" + } + }, + "node_modules/@ngneat/transloco-optimize/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@ngneat/transloco-persist-lang": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@ngneat/transloco-persist-lang/-/transloco-persist-lang-4.0.0.tgz", + "integrity": "sha512-OpYph1obkcB9clC5JGUMkD/Bb8n/jLYkqYUEOHf7/Y/0LcJpLiSTydN99+UhTND03uKAzDcP0WaQvAZaPnXxrw==", + "dependencies": { + "tslib": "^2.2.0" + }, + "peerDependencies": { + "@angular/core": ">=13.0.0", + "@ngneat/transloco": ">=4.0.0", + "rxjs": ">=6.0.0" + } + }, + "node_modules/@ngneat/transloco-persist-translations": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@ngneat/transloco-persist-translations/-/transloco-persist-translations-4.0.0.tgz", + "integrity": "sha512-PeU8JLZbxdmD4JCjRWTbtL26P2yXhjwFs7Tol4sKXsHYBU1VAf7hLshxcN5PWDpkF9rVjcroOZHFNHyRcqR5jA==", + "dependencies": { + "tslib": "^2.2.0" + }, + "peerDependencies": { + "@angular/core": ">=13.0.0", + "@ngneat/transloco": ">=4.0.0", + "rxjs": ">=6.0.0" + } + }, + "node_modules/@ngneat/transloco-preload-langs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@ngneat/transloco-preload-langs/-/transloco-preload-langs-4.0.1.tgz", + "integrity": "sha512-CcFQSHs/cU6PQ8vq5EVR9XuMS824KsSuNK3jkNLBGrciP09d59yRbRhhNQDWqynY5lNQ1qUtN7djv+9bNa/phQ==", + "dependencies": { + "tslib": "^2.2.0" + }, + "peerDependencies": { + "@angular/core": ">=13.0.0", + "@ngneat/transloco": ">=4.0.0", + "rxjs": ">=6.0.0" + } + }, + "node_modules/@ngneat/transloco-utils": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@ngneat/transloco-utils/-/transloco-utils-3.0.5.tgz", + "integrity": "sha512-Xn9GaLUocXSPMhErNHbUyoloDm9sb+JaYszZJFL9F8em6frPQDSJxcYk9pV0caWpAU8INlksJSYgx1LXAH18mw==", + "dependencies": { + "cosmiconfig": "^8.1.3", + "tslib": "^2.3.0" + } + }, "node_modules/@ngtools/webpack": { "version": "16.1.5", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-16.1.5.tgz", @@ -5180,6 +5300,15 @@ "dequal": "^2.0.3" } }, + "node_modules/array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", @@ -5360,14 +5489,12 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -5411,7 +5538,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -5506,7 +5632,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5558,7 +5683,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -5651,7 +5775,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -5761,7 +5884,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, "dependencies": { "restore-cursor": "^3.1.0" }, @@ -5773,7 +5895,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz", "integrity": "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==", - "dev": true, "engines": { "node": ">=6" }, @@ -5805,7 +5926,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, "engines": { "node": ">=0.8" } @@ -5864,6 +5984,21 @@ "node": ">= 0.8" } }, + "node_modules/command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "dev": true, + "dependencies": { + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -5930,8 +6065,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/connect-history-api-fallback": { "version": "2.0.0", @@ -6119,7 +6253,6 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.2.0.tgz", "integrity": "sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==", - "dev": true, "dependencies": { "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -6136,14 +6269,12 @@ "node_modules/cosmiconfig/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/cosmiconfig/node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -6586,7 +6717,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, "dependencies": { "clone": "^1.0.2" }, @@ -6966,7 +7096,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -7800,6 +7929,18 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, + "node_modules/find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "dev": true, + "dependencies": { + "array-back": "^3.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -7817,7 +7958,6 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, "bin": { "flat": "cli.js" } @@ -7972,8 +8112,7 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.2", @@ -8575,7 +8714,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -8659,7 +8797,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -8675,7 +8812,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "engines": { "node": ">=4" } @@ -8702,7 +8838,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -8711,8 +8846,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "4.1.1", @@ -8889,8 +9023,7 @@ "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, "node_modules/is-bigint": { "version": "1.0.4", @@ -9017,7 +9150,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, "engines": { "node": ">=8" } @@ -9195,7 +9327,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, "engines": { "node": ">=10" }, @@ -9554,8 +9685,7 @@ "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "node_modules/json-schema-traverse": { "version": "1.0.0", @@ -9821,6 +9951,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -9832,6 +9968,11 @@ "resolved": "https://registry.npmjs.org/lodash.deburr/-/lodash.deburr-4.1.0.tgz", "integrity": "sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==" }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9842,7 +9983,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" @@ -9858,7 +9998,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -9873,7 +10012,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -9889,7 +10027,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -9900,14 +10037,12 @@ "node_modules/log-symbols/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/log-symbols/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -9916,7 +10051,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10108,7 +10242,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, "engines": { "node": ">=6" } @@ -10142,7 +10275,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -11166,7 +11298,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -11175,7 +11306,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, "dependencies": { "mimic-fn": "^2.1.0" }, @@ -11233,7 +11363,6 @@ "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", @@ -11256,7 +11385,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -11271,7 +11399,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -11287,7 +11414,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -11298,14 +11424,12 @@ "node_modules/ora/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/ora/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -11314,7 +11438,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -11446,7 +11569,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "dependencies": { "callsites": "^3.0.0" }, @@ -11458,7 +11580,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -11475,8 +11596,7 @@ "node_modules/parse-json/node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, "node_modules/parse-node-version": { "version": "1.0.1", @@ -11547,7 +11667,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -11602,7 +11721,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, "engines": { "node": ">=8" } @@ -12022,7 +12140,6 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -12144,6 +12261,105 @@ "jsesc": "bin/jsesc" } }, + "node_modules/replace-in-file": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/replace-in-file/-/replace-in-file-6.3.5.tgz", + "integrity": "sha512-arB9d3ENdKva2fxRnSjwBEXfK1npgyci7ZZuwysgAp7ORjHSyxz6oqIjTEv8R0Ydl4Ll7uOAZXL4vbkhGIizCg==", + "dependencies": { + "chalk": "^4.1.2", + "glob": "^7.2.0", + "yargs": "^17.2.1" + }, + "bin": { + "replace-in-file": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/replace-in-file/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/replace-in-file/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/replace-in-file/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/replace-in-file/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/replace-in-file/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/replace-in-file/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/replace-in-file/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -12235,7 +12451,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" @@ -12247,8 +12462,7 @@ "node_modules/restore-cursor/node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, "node_modules/retry": { "version": "0.12.0", @@ -12367,7 +12581,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -13033,7 +13246,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -13667,6 +13879,15 @@ "node": ">=14.17" } }, + "node_modules/typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -13799,8 +14020,7 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/utils-merge": { "version": "1.0.1", @@ -13937,7 +14157,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, "dependencies": { "defaults": "^1.0.3" } @@ -14555,8 +14774,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { "version": "7.5.9", diff --git a/UI/Web/package.json b/UI/Web/package.json index ffa564f00..3551b0963 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -5,7 +5,8 @@ "ng": "ng", "start": "ng serve", "build": "ng build", - "prod": "ng build --configuration production --aot --output-hashing=all", + "transloco:optimize": "transloco-optimize dist/assets/langs", + "prod": "ng build --configuration production --aot --output-hashing=all && npm run transloco:optimize", "explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json", "lint": "ng lint", "e2e": "ng e2e" @@ -27,6 +28,11 @@ "@iplab/ngx-file-upload": "^16.0.1", "@microsoft/signalr": "^7.0.9", "@ng-bootstrap/ng-bootstrap": "^15.1.0", + "@ngneat/transloco": "^4.3.0", + "@ngneat/transloco-locale": "^4.1.0", + "@ngneat/transloco-persist-lang": "^4.0.0", + "@ngneat/transloco-persist-translations": "^4.0.0", + "@ngneat/transloco-preload-langs": "^4.0.1", "@popperjs/core": "^2.11.7", "@swimlane/ngx-charts": "^20.1.2", "@tweenjs/tween.js": "^21.0.0", @@ -57,6 +63,7 @@ "@angular-eslint/template-parser": "^16.1.0", "@angular/cli": "^16.1.5", "@angular/compiler-cli": "^16.1.6", + "@ngneat/transloco-optimize": "^3.0.2", "@types/d3": "^7.4.0", "@types/node": "^20.4.4", "@typescript-eslint/eslint-plugin": "^6.1.0", diff --git a/UI/Web/src/app/_guards/admin.guard.ts b/UI/Web/src/app/_guards/admin.guard.ts index 29ca3795e..9e34ff5eb 100644 --- a/UI/Web/src/app/_guards/admin.guard.ts +++ b/UI/Web/src/app/_guards/admin.guard.ts @@ -4,22 +4,24 @@ import { ToastrService } from 'ngx-toastr'; import { Observable } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { AccountService } from '../_services/account.service'; +import {TranslocoService} from "@ngneat/transloco"; @Injectable({ providedIn: 'root' }) export class AdminGuard implements CanActivate { - constructor(private accountService: AccountService, private toastr: ToastrService) {} + constructor(private accountService: AccountService, private toastr: ToastrService, + private translocoService: TranslocoService) {} canActivate(): Observable { - // this automaticallys subs due to being router guard + // this automatically subs due to being router guard return this.accountService.currentUser$.pipe(take(1), map((user) => { if (user && this.accountService.hasAdminRole(user)) { return true; } - - this.toastr.error('You are not authorized to view this page.'); + + this.toastr.error(this.translocoService.translate('toasts.unauthorized-1')); return false; }) ); diff --git a/UI/Web/src/app/_guards/auth.guard.ts b/UI/Web/src/app/_guards/auth.guard.ts index c9b773c65..3df2861b5 100644 --- a/UI/Web/src/app/_guards/auth.guard.ts +++ b/UI/Web/src/app/_guards/auth.guard.ts @@ -4,13 +4,17 @@ import { ToastrService } from 'ngx-toastr'; import { Observable } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { AccountService } from '../_services/account.service'; +import {TranslocoService} from "@ngneat/transloco"; @Injectable({ providedIn: 'root' }) export class AuthGuard implements CanActivate { public urlKey: string = 'kavita--auth-intersection-url'; - constructor(private accountService: AccountService, private router: Router, private toastr: ToastrService) {} + constructor(private accountService: AccountService, + private router: Router, + private toastr: ToastrService, + private translocoService: TranslocoService) {} canActivate(): Observable { return this.accountService.currentUser$.pipe(take(1), @@ -18,8 +22,10 @@ export class AuthGuard implements CanActivate { if (user) { return true; } - if (this.toastr.toasts.filter(toast => toast.message === 'Unauthorized' || toast.message === 'You are not authorized to view this page.').length === 0) { - this.toastr.error('You are not authorized to view this page.'); + const errorMessage = this.translocoService.translate('toasts.unauthorized-1'); + const errorMessage2 = this.translocoService.translate('toasts.unauthorized-2'); + if (this.toastr.toasts.filter(toast => toast.message === errorMessage2 || toast.message === errorMessage).length === 0) { + this.toastr.error(errorMessage); } localStorage.setItem(this.urlKey, window.location.pathname); this.router.navigateByUrl('/login'); diff --git a/UI/Web/src/app/_interceptors/error.interceptor.ts b/UI/Web/src/app/_interceptors/error.interceptor.ts index 02ab1474f..cb16bcb42 100644 --- a/UI/Web/src/app/_interceptors/error.interceptor.ts +++ b/UI/Web/src/app/_interceptors/error.interceptor.ts @@ -1,4 +1,4 @@ -import {inject, Injectable} from '@angular/core'; +import {Injectable} from '@angular/core'; import { HttpRequest, HttpHandler, @@ -10,10 +10,13 @@ import { Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; import { catchError } from 'rxjs/operators'; import { AccountService } from '../_services/account.service'; +import {TranslocoService} from "@ngneat/transloco"; @Injectable() export class ErrorInterceptor implements HttpInterceptor { - constructor(private router: Router, private toastr: ToastrService, private accountService: AccountService) {} + constructor(private router: Router, private toastr: ToastrService, + private accountService: AccountService, + private translocoService: TranslocoService) {} intercept(request: HttpRequest, next: HttpHandler): Observable> { @@ -38,8 +41,8 @@ export class ErrorInterceptor implements HttpInterceptor { break; default: // Don't throw multiple Something unexpected went wrong - if (this.toastr.previousToastMessage !== 'Something unexpected went wrong.') { - this.toastr.error('Something unexpected went wrong.'); + if (this.toastr.previousToastMessage !== 'Something unexpected went wrong.' && this.toastr.previousToastMessage !== 'errors.generic') { + this.toast('errors.generic'); } break; } @@ -81,36 +84,36 @@ export class ErrorInterceptor implements HttpInterceptor { console.error('error:', error); if (error.statusText === 'Bad Request') { if (error.error instanceof Blob) { - this.toastr.error('There was an issue downloading this file or you do not have permissions', error.status); + this.toast('errors.download', error.status); return; } - this.toastr.error(error.error, error.status + ' Error'); + this.toast(error.error, this.translocoService.translate('errors.error-code', {num: error.status})); } else { - this.toastr.error(error.statusText === 'OK' ? error.error : error.statusText, error.status + ' Error'); + this.toast(error.statusText === 'OK' ? error.error : error.statusText, this.translocoService.translate('errors.error-code', {num: error.status})); } } } private handleNotFound(error: any) { - this.toastr.error('That url does not exist.'); + this.toast('errors.not-found'); } private handleServerException(error: any) { const err = error.error; if (err.hasOwnProperty('message') && err.message.trim() !== '') { - if (err.message != 'User is not authenticated') { + if (err.message != 'User is not authenticated' && error.message !== 'errors.user-not-auth') { console.error('500 error: ', error); } - this.toastr.error(err.message); + this.toast(err.message); } else if (error.hasOwnProperty('message') && error.message.trim() !== '') { - if (error.message != 'User is not authenticated') { + if (error.message !== 'User is not authenticated' && error.message !== 'errors.user-not-auth') { console.error('500 error: ', error); } // This just throws duplicate errors for no reason - //this.toastr.error(error.message); + //this.toast(error.message); } else { - this.toastr.error('There was an unknown critical error.'); + this.toast('errors.unknown-crit'); console.error('500 error:', error); } } @@ -125,4 +128,14 @@ export class ErrorInterceptor implements HttpInterceptor { // if statement is due to http/2 spec issue: https://github.com/angular/angular/issues/23334 this.accountService.logout(); } + + // Assume the title is already translated + private toast(message: string, title?: string) { + if (message.startsWith('errors.')) { + this.toastr.error(this.translocoService.translate(message), title); + } else { + this.toastr.error(message, title); + } + } + } diff --git a/UI/Web/src/app/_models/preferences/book-theme.ts b/UI/Web/src/app/_models/preferences/book-theme.ts index 4b487fb12..b6e37f6e4 100644 --- a/UI/Web/src/app/_models/preferences/book-theme.ts +++ b/UI/Web/src/app/_models/preferences/book-theme.ts @@ -23,4 +23,8 @@ * Inner HTML */ content: string; + /** + * Key for translation + */ + translationKey: string; } diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index cde9067c9..22817fd36 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -42,6 +42,7 @@ export interface Preferences { noTransitions: boolean; collapseSeriesRelationships: boolean; shareReviews: boolean; + locale: string; } export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}]; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index e8d5bfb86..9ccb0a8dc 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -33,6 +33,7 @@ export class AccountService { baseUrl = environment.apiUrl; userKey = 'kavita-user'; public lastLoginKey = 'kavita-lastlogin'; + public localeKey = 'kavita-locale'; private currentUser: User | undefined; // Stores values, when someone subscribes gives (1) of last values seen. @@ -147,15 +148,11 @@ export class AccountService { this.currentUser = user; this.currentUserSource.next(user); - if (user) { - this.messageHub.createHubConnection(user, this.hasAdminRole(user)); - } - - this.hasValidLicense().subscribe(); - this.stopRefreshTokenTimer(); - if (this.currentUser !== undefined) { + if (this.currentUser) { + this.messageHub.createHubConnection(this.currentUser, this.hasAdminRole(this.currentUser)); + this.hasValidLicense().subscribe(); this.startRefreshTokenTimer(); } } @@ -270,6 +267,9 @@ export class AccountService { if (this.currentUser !== undefined && this.currentUser !== null) { this.currentUser.preferences = settings; this.setCurrentUser(this.currentUser); + + // Update the locale on disk (for logout only) + localStorage.setItem(this.localeKey, this.currentUser.preferences.locale); } return settings; }), takeUntilDestroyed(this.destroyRef)); diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 0f1128f16..a061729c7 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -19,6 +19,7 @@ import { LibraryService } from './library.service'; import { MemberService } from './member.service'; import { ReaderService } from './reader.service'; import { SeriesService } from './series.service'; +import {translate, TranslocoService} from "@ngneat/transloco"; export type LibraryActionCallback = (library: Partial) => void; export type SeriesActionCallback = (series: Series) => void; @@ -42,7 +43,8 @@ export class ActionService implements OnDestroy { constructor(private libraryService: LibraryService, private seriesService: SeriesService, private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal, - private confirmService: ConfirmService, private memberService: MemberService, private deviceSerivce: DeviceService) { } + private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService, + private translocoService: TranslocoService) { } ngOnDestroy() { this.onDestroy.next(); @@ -64,7 +66,7 @@ export class ActionService implements OnDestroy { const force = false; // await this.promptIfForce(); this.libraryService.scan(library.id, force).pipe(take(1)).subscribe((res: any) => { - this.toastr.info('Scan queued for ' + library.name); + this.toastr.info(this.translocoService.translate('toasts.scan-queued', {name: library.name})); if (callback) { callback(library); } @@ -83,7 +85,7 @@ export class ActionService implements OnDestroy { return; } - if (!await this.confirmService.confirm('Refresh covers will force all cover images to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) { + if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) { if (callback) { callback(library); } @@ -93,7 +95,7 @@ export class ActionService implements OnDestroy { const forceUpdate = true; //await this.promptIfForce(); this.libraryService.refreshMetadata(library?.id, forceUpdate).pipe(take(1)).subscribe((res: any) => { - this.toastr.info('Scan queued for ' + library.name); + this.toastr.info(this.translocoService.translate('toasts.scan-queued', {name: library.name})); if (callback) { callback(library); } @@ -119,7 +121,7 @@ export class ActionService implements OnDestroy { return; } - if (!await this.confirmService.alert('This is a long running process. Please give it the time to complete before invoking again.')) { + if (!await this.confirmService.alert(translate('toasts.alert-long-running'))) { if (callback) { callback(library); } @@ -127,7 +129,7 @@ export class ActionService implements OnDestroy { } this.libraryService.analyze(library?.id).pipe(take(1)).subscribe((res: any) => { - this.toastr.info('Library file analysis queued for ' + library.name); + this.toastr.info(this.translocoService.translate('toasts.library-file-analysis-queued', {name: library.name})); if (callback) { callback(library); } @@ -142,7 +144,7 @@ export class ActionService implements OnDestroy { markSeriesAsRead(series: Series, callback?: SeriesActionCallback) { this.seriesService.markRead(series.id).pipe(take(1)).subscribe(res => { series.pagesRead = series.pages; - this.toastr.success(series.name + ' is now read'); + this.toastr.success(this.translocoService.translate('toasts.entity-read', {name: series.name})); if (callback) { callback(series); } @@ -157,7 +159,7 @@ export class ActionService implements OnDestroy { markSeriesAsUnread(series: Series, callback?: SeriesActionCallback) { this.seriesService.markUnread(series.id).pipe(take(1)).subscribe(res => { series.pagesRead = 0; - this.toastr.success(series.name + ' is now unread'); + this.toastr.success(this.translocoService.translate('toasts.entity-unread', {name: series.name})); if (callback) { callback(series); } @@ -171,7 +173,7 @@ export class ActionService implements OnDestroy { */ async scanSeries(series: Series, callback?: SeriesActionCallback) { this.seriesService.scan(series.libraryId, series.id).pipe(take(1)).subscribe((res: any) => { - this.toastr.info('Scan queued for ' + series.name); + this.toastr.info(this.translocoService.translate('toasts.scan-queued', {name: series.name})); if (callback) { callback(series); } @@ -185,7 +187,7 @@ export class ActionService implements OnDestroy { */ analyzeFilesForSeries(series: Series, callback?: SeriesActionCallback) { this.seriesService.analyzeFiles(series.libraryId, series.id).pipe(take(1)).subscribe((res: any) => { - this.toastr.info('Scan queued for ' + series.name); + this.toastr.info(this.translocoService.translate('toasts.scan-queued', {name: series.name})); if (callback) { callback(series); } @@ -198,7 +200,7 @@ export class ActionService implements OnDestroy { * @param callback Optional callback to perform actions after API completes */ async refreshMetdata(series: Series, callback?: SeriesActionCallback) { - if (!await this.confirmService.confirm('Refresh covers will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) { + if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) { if (callback) { callback(series); } @@ -206,7 +208,7 @@ export class ActionService implements OnDestroy { } this.seriesService.refreshMetadata(series).pipe(take(1)).subscribe((res: any) => { - this.toastr.info('Refresh covers queued for ' + series.name); + this.toastr.info(this.translocoService.translate('toasts.refresh-covers-queued', {name: series.name})); if (callback) { callback(series); } @@ -223,7 +225,7 @@ export class ActionService implements OnDestroy { this.readerService.markVolumeRead(seriesId, volume.id).pipe(take(1)).subscribe(() => { volume.pagesRead = volume.pages; volume.chapters?.forEach(c => c.pagesRead = c.pages); - this.toastr.success('Marked as Read'); + this.toastr.success(this.translocoService.translate('toasts.mark-read')); if (callback) { callback(volume); @@ -241,7 +243,7 @@ export class ActionService implements OnDestroy { this.readerService.markVolumeUnread(seriesId, volume.id).subscribe(() => { volume.pagesRead = 0; volume.chapters?.forEach(c => c.pagesRead = 0); - this.toastr.success('Marked as Unread'); + this.toastr.success(this.translocoService.translate('toasts.mark-unread')); if (callback) { callback(volume); } @@ -257,7 +259,7 @@ export class ActionService implements OnDestroy { markChapterAsRead(libraryId: number, seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) { this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => { chapter.pagesRead = chapter.pages; - this.toastr.success('Marked as Read'); + this.toastr.success(this.translocoService.translate('toasts.mark-read')); if (callback) { callback(chapter); } @@ -273,7 +275,7 @@ export class ActionService implements OnDestroy { markChapterAsUnread(libraryId: number, seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) { this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, 0).pipe(take(1)).subscribe(results => { chapter.pagesRead = 0; - this.toastr.success('Marked as Unread'); + this.toastr.success(this.translocoService.translate('toasts.mark-unread')); if (callback) { callback(chapter); } @@ -294,7 +296,7 @@ export class ActionService implements OnDestroy { volume.chapters?.forEach(c => c.pagesRead = c.pages); }); chapters?.forEach(c => c.pagesRead = c.pages); - this.toastr.success('Marked as Read'); + this.toastr.success(this.translocoService.translate('toasts.mark-read')); if (callback) { callback(); @@ -315,7 +317,7 @@ export class ActionService implements OnDestroy { volume.chapters?.forEach(c => c.pagesRead = 0); }); chapters?.forEach(c => c.pagesRead = 0); - this.toastr.success('Marked as Unread'); + this.toastr.success(this.translocoService.translate('toasts.mark-unread')); if (callback) { callback(); @@ -333,7 +335,7 @@ export class ActionService implements OnDestroy { series.forEach(s => { s.pagesRead = s.pages; }); - this.toastr.success('Marked as Read'); + this.toastr.success(this.translocoService.translate('toasts.mark-read')); if (callback) { callback(); @@ -351,7 +353,7 @@ export class ActionService implements OnDestroy { series.forEach(s => { s.pagesRead = s.pages; }); - this.toastr.success('Marked as Unread'); + this.toastr.success(this.translocoService.translate('toasts.mark-unread')); if (callback) { callback(); @@ -394,7 +396,7 @@ export class ActionService implements OnDestroy { removeMultipleSeriesFromWantToReadList(seriesIds: Array, callback?: VoidActionCallback) { this.memberService.removeSeriesToWantToRead(seriesIds).subscribe(() => { - this.toastr.success('Series removed from Want to Read list'); + this.toastr.success(this.translocoService.translate('toasts.series-removed-want-to-read')); if (callback) { callback(); } @@ -538,14 +540,14 @@ export class ActionService implements OnDestroy { * @param callback Optional callback to perform actions after API completes */ async deleteMultipleSeries(seriesIds: Array, callback?: BooleanActionCallback) { - if (!await this.confirmService.confirm('Are you sure you want to delete ' + seriesIds.length + ' series? It will not modify files on disk.')) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-series', {count: seriesIds.length}))) { if (callback) { callback(false); } return; } this.seriesService.deleteMultipleSeries(seriesIds.map(s => s.id)).pipe(take(1)).subscribe(() => { - this.toastr.success('Series deleted'); + this.toastr.success(this.translocoService.translate('toasts.series-deleted')); if (callback) { callback(true); @@ -554,7 +556,7 @@ export class ActionService implements OnDestroy { } async deleteSeries(series: Series, callback?: BooleanActionCallback) { - if (!await this.confirmService.confirm('Are you sure you want to delete this series? It will not modify files on disk.')) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-series'))) { if (callback) { callback(false); } @@ -563,15 +565,15 @@ export class ActionService implements OnDestroy { this.seriesService.delete(series.id).subscribe((res: boolean) => { if (callback) { - this.toastr.success('Series deleted'); + this.toastr.success(this.translocoService.translate('toasts.series-deleted')); callback(res); } }); } sendToDevice(chapterIds: Array, device: Device, callback?: VoidActionCallback) { - this.deviceSerivce.sendTo(chapterIds, device.id).subscribe(() => { - this.toastr.success('File emailed to ' + device.name); + this.deviceService.sendTo(chapterIds, device.id).subscribe(() => { + this.toastr.success(this.translocoService.translate('toasts.file-send-to', {name: device.name})); if (callback) { callback(); } @@ -579,8 +581,8 @@ export class ActionService implements OnDestroy { } sendSeriesToDevice(seriesId: number, device: Device, callback?: VoidActionCallback) { - this.deviceSerivce.sendSeriesTo(seriesId, device.id).subscribe(() => { - this.toastr.success('File(s) emailed to ' + device.name); + this.deviceService.sendSeriesTo(seriesId, device.id).subscribe(() => { + this.toastr.success(this.translocoService.translate('toasts.file-send-to', {name: device.name})); if (callback) { callback(); } diff --git a/UI/Web/src/app/_services/localization.service.ts b/UI/Web/src/app/_services/localization.service.ts new file mode 100644 index 000000000..23ba213b6 --- /dev/null +++ b/UI/Web/src/app/_services/localization.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import {environment} from "../../environments/environment"; +import {HttpClient} from "@angular/common/http"; +import {Language} from "../_models/metadata/language"; + +@Injectable({ + providedIn: 'root' +}) +export class LocalizationService { + + baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient) { } + + getLocales() { + return this.httpClient.get(this.baseUrl + 'locale'); + } +} diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index e8d1e2b7f..51fe3f025 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -1,5 +1,5 @@ import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import { environment } from 'src/environments/environment'; import { UserReadStatistics } from '../statistics/_models/user-read-statistics'; import { PublicationStatusPipe } from '../pipe/publication-status.pipe'; @@ -13,6 +13,7 @@ import { StatCount } from '../statistics/_models/stat-count'; import { PublicationStatus } from '../_models/metadata/publication-status'; import { MangaFormat } from '../_models/manga-format'; import { TextResonse } from '../_types/text-response'; +import {TranslocoService} from "@ngneat/transloco"; export enum DayOfWeek { @@ -25,15 +26,15 @@ export enum DayOfWeek Saturday = 6, } -const publicationStatusPipe = new PublicationStatusPipe(); -const mangaFormatPipe = new MangaFormatPipe(); - @Injectable({ providedIn: 'root' }) export class StatisticsService { baseUrl = environment.apiUrl; + translocoService = inject(TranslocoService); + publicationStatusPipe = new PublicationStatusPipe(this.translocoService); + mangaFormatPipe = new MangaFormatPipe(this.translocoService); constructor(private httpClient: HttpClient) { } @@ -41,7 +42,7 @@ export class StatisticsService { // TODO: Convert to httpParams object let url = 'stats/user/' + userId + '/read'; if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(','); - + return this.httpClient.get(this.baseUrl + url); } @@ -88,14 +89,14 @@ export class StatisticsService { getPublicationStatus() { return this.httpClient.get[]>(this.baseUrl + 'stats/server/count/publication-status').pipe( map(spreads => spreads.map(spread => { - return {name: publicationStatusPipe.transform(spread.value), value: spread.count}; + return {name: this.publicationStatusPipe.transform(spread.value), value: spread.count}; }))); } getMangaFormat() { return this.httpClient.get[]>(this.baseUrl + 'stats/server/count/manga-format').pipe( map(spreads => spreads.map(spread => { - return {name: mangaFormatPipe.transform(spread.value), value: spread.count}; + return {name: this.mangaFormatPipe.transform(spread.value), value: spread.count}; }))); } diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 654c48ad9..5b7e325b9 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -19,6 +19,7 @@ import { SiteTheme, ThemeProvider } from '../_models/preferences/site-theme'; import { TextResonse } from '../_types/text-response'; import { EVENTS, MessageHubService } from './message-hub.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {translate} from "@ngneat/transloco"; @Injectable({ @@ -98,7 +99,7 @@ export class ThemeService { this.currentTheme$.pipe(take(1)).subscribe(theme => { if (themes.filter(t => t.id === theme.id).length === 0) { this.setTheme(this.defaultTheme); - this.toastr.info('The active theme no longer exists. Please refresh the page.'); + this.toastr.info(translate('toasts.theme-missing')); } }); return themes; @@ -151,7 +152,7 @@ export class ThemeService { // We need to load the styles into the browser this.fetchThemeContent(theme.id).subscribe(async (content) => { if (content === null) { - await this.confirmService.alert('There is invalid or unsafe css in the theme. Please reach out to your admin to have this corrected. Defaulting to dark theme.'); + await this.confirmService.alert(translate('toasts.alert-bad-theme')); this.setTheme('dark'); return; } diff --git a/UI/Web/src/app/_single-module/feature-list-modal/feature-list-modal.component.html b/UI/Web/src/app/_single-module/feature-list-modal/feature-list-modal.component.html deleted file mode 100644 index d3b20d59f..000000000 --- a/UI/Web/src/app/_single-module/feature-list-modal/feature-list-modal.component.html +++ /dev/null @@ -1,32 +0,0 @@ -
- - - -
- - diff --git a/UI/Web/src/app/_single-module/feature-list-modal/feature-list-modal.component.scss b/UI/Web/src/app/_single-module/feature-list-modal/feature-list-modal.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/UI/Web/src/app/_single-module/feature-list-modal/feature-list-modal.component.ts b/UI/Web/src/app/_single-module/feature-list-modal/feature-list-modal.component.ts deleted file mode 100644 index 138a54409..000000000 --- a/UI/Web/src/app/_single-module/feature-list-modal/feature-list-modal.component.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {ChangeDetectionStrategy, Component} from '@angular/core'; -import {CommonModule} from '@angular/common'; -import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; - -@Component({ - selector: 'app-feature-list-modal', - standalone: true, - imports: [CommonModule], - templateUrl: './feature-list-modal.component.html', - styleUrls: ['./feature-list-modal.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class FeatureListModalComponent { - - constructor(private modal: NgbActiveModal) {} - - close() { - this.modal.close(); - } -} diff --git a/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html b/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html index 352be3c90..177e63d11 100644 --- a/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html +++ b/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html @@ -1,18 +1,19 @@ -
- - + diff --git a/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.ts b/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.ts index 19a557ef4..50b52031d 100644 --- a/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.ts +++ b/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.ts @@ -13,11 +13,12 @@ import {ReactiveFormsModule} from "@angular/forms"; import {UserReview} from "../review-card/user-review"; import {SpoilerComponent} from "../spoiler/spoiler.component"; import {SafeHtmlPipe} from "../../pipe/safe-html.pipe"; +import {TranslocoModule} from "@ngneat/transloco"; @Component({ selector: 'app-review-card-modal', standalone: true, - imports: [CommonModule, ReactiveFormsModule, SpoilerComponent, SafeHtmlPipe], + imports: [CommonModule, ReactiveFormsModule, SpoilerComponent, SafeHtmlPipe, TranslocoModule], templateUrl: './review-card-modal.component.html', styleUrls: ['./review-card-modal.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.html b/UI/Web/src/app/_single-module/review-card/review-card.component.html index 5e912aba8..fd888e2a4 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.html +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.html @@ -1,35 +1,37 @@ -
-
-
- -
- - This is your review + +
+
+
+ +
+ + {{t('your-review')}} +
-
-
-
-
- {{review.tagline.substring(0, 29)}}{{review.tagline.length > 29 ? '…' : ''}} - - {{review.isExternal ? 'External Review' : 'Review'}} - -
-

- -

+
+
+
+ {{review.tagline.substring(0, 29)}}{{review.tagline.length > 29 ? '…' : ''}} + + {{review.isExternal ? t('external-review') : t('local-review')}} + +
+

+ +

+
-
-
-
+
diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.ts b/UI/Web/src/app/_single-module/review-card/review-card.component.ts index 154f0cd72..248d0e0e5 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.ts +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.ts @@ -9,11 +9,12 @@ import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; import {DefaultValuePipe} from "../../pipe/default-value.pipe"; import {ImageComponent} from "../../shared/image/image.component"; import {ProviderImagePipe} from "../../pipe/provider-image.pipe"; +import {TranslocoModule} from "@ngneat/transloco"; @Component({ selector: 'app-review-card', standalone: true, - imports: [CommonModule, ReadMoreComponent, DefaultValuePipe, ImageComponent, NgOptimizedImage, ProviderImagePipe], + imports: [CommonModule, ReadMoreComponent, DefaultValuePipe, ImageComponent, NgOptimizedImage, ProviderImagePipe, TranslocoModule], templateUrl: './review-card.component.html', styleUrls: ['./review-card.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.html b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.html index d6d315ce7..00c60cc80 100644 --- a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.html +++ b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.html @@ -1,28 +1,30 @@ -
- - + - -
+
+ +
+ diff --git a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts index 66f70758b..96d3dc1c4 100644 --- a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts +++ b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts @@ -4,11 +4,12 @@ import {NgbActiveModal, NgbRating} from '@ng-bootstrap/ng-bootstrap'; import { SeriesService } from 'src/app/_services/series.service'; import {UserReview} from "../review-card/user-review"; import {CommonModule} from "@angular/common"; +import {TranslocoModule} from "@ngneat/transloco"; @Component({ selector: 'app-review-series-modal', standalone: true, - imports: [CommonModule, NgbRating, ReactiveFormsModule], + imports: [CommonModule, NgbRating, ReactiveFormsModule, TranslocoModule], templateUrl: './review-series-modal.component.html', styleUrls: ['./review-series-modal.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/_single-module/scrobble-event-type.pipe.ts b/UI/Web/src/app/_single-module/scrobble-event-type.pipe.ts index 36da35522..ad860276c 100644 --- a/UI/Web/src/app/_single-module/scrobble-event-type.pipe.ts +++ b/UI/Web/src/app/_single-module/scrobble-event-type.pipe.ts @@ -1,5 +1,6 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {inject, Pipe, PipeTransform} from '@angular/core'; import {ScrobbleEventType} from "../_models/scrobbling/scrobble-event"; +import {TranslocoPipe, TranslocoService} from "@ngneat/transloco"; @Pipe({ name: 'scrobbleEventType', @@ -7,13 +8,20 @@ import {ScrobbleEventType} from "../_models/scrobbling/scrobble-event"; }) export class ScrobbleEventTypePipe implements PipeTransform { + translocoService = inject(TranslocoService); + transform(value: ScrobbleEventType): string { switch (value) { - case ScrobbleEventType.ChapterRead: return 'Reading Progress'; - case ScrobbleEventType.ScoreUpdated: return 'Rating Update'; - case ScrobbleEventType.AddWantToRead: return 'Want To Read: Add'; - case ScrobbleEventType.RemoveWantToRead: return 'Want To Read: Remove'; - case ScrobbleEventType.Review: return 'Review update'; + case ScrobbleEventType.ChapterRead: + return this.translocoService.translate('scrobble-event-type-pipe.chapter-read'); + case ScrobbleEventType.ScoreUpdated: + return this.translocoService.translate('scrobble-event-type-pipe.score-updated'); + case ScrobbleEventType.AddWantToRead: + return this.translocoService.translate('scrobble-event-type-pipe.want-to-read-add'); + case ScrobbleEventType.RemoveWantToRead: + return this.translocoService.translate('scrobble-event-type-pipe.want-to-read-remove'); + case ScrobbleEventType.Review: + return this.translocoService.translate('scrobble-event-type-pipe.review'); } } diff --git a/UI/Web/src/app/_single-module/spoiler/spoiler.component.html b/UI/Web/src/app/_single-module/spoiler/spoiler.component.html index 1e5fd72d3..67b6e2a5e 100644 --- a/UI/Web/src/app/_single-module/spoiler/spoiler.component.html +++ b/UI/Web/src/app/_single-module/spoiler/spoiler.component.html @@ -1,7 +1,8 @@ - -
- Spoiler, click to show - -
-
-
+ +
+ {{t('click-to-show')}} + +
+
+
+
diff --git a/UI/Web/src/app/_single-module/spoiler/spoiler.component.ts b/UI/Web/src/app/_single-module/spoiler/spoiler.component.ts index 6a7d31eed..d75d6b8d3 100644 --- a/UI/Web/src/app/_single-module/spoiler/spoiler.component.ts +++ b/UI/Web/src/app/_single-module/spoiler/spoiler.component.ts @@ -9,11 +9,12 @@ import { } from '@angular/core'; import {CommonModule} from '@angular/common'; import {SafeHtmlPipe} from "../../pipe/safe-html.pipe"; +import {TranslocoModule} from "@ngneat/transloco"; @Component({ selector: 'app-spoiler', standalone: true, - imports: [CommonModule, SafeHtmlPipe], + imports: [CommonModule, SafeHtmlPipe, TranslocoModule], templateUrl: './spoiler.component.html', styleUrls: ['./spoiler.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html index bfbdf78e2..43c0f5c66 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html @@ -1,51 +1,50 @@ -
Scrobble History
-

Here you will find any scrobble events linked with your account. In order for events to exist, you must have an active - scrobble provider configured. All events that have been processed will clear after a month. If there are non-processed events, it - is likely these cannot form matches upstream. Please reach out to your admin to get them corrected.

-
-
-
-
- - -
-
+ +
{{t('title')}}
+

{{t('description')}}

+
+
+
+
+ + +
+
+
+
+ +
-
- -
-
- +
- Created + {{t('created-header')}} - Last Modified + {{t('last-modified-header')}} - Type + {{t('type-header')}} - Series + {{t('series-header')}} - Data + {{t('data-header')}} - Is Processed + {{t('is-processed-header')}}
No Data{{t('no-data')}}/td>
@@ -63,22 +62,24 @@ - Volume {{item.volumeNumber}} Chapter {{item.chapterNumber}} + {{t('volume-and-chapter-num', {v: item.volumeNumber, c: item.chapterNumber})}} - Rating {{item.rating}} + {{t('rating', {r: item.rating})}} - Not Applicable + {{t('not-applicable')}} - {{item.isProcessed ? 'Processed' : 'Not Processed'}} + + {{item.isProcessed ? t('processed') : t('not-processed')}} +
- + diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts index cd8822b74..41268d93c 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts @@ -2,21 +2,21 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, injec import {CommonModule} from '@angular/common'; import {ScrobblingService} from "../../_services/scrobbling.service"; -import {shareReplay} from "rxjs"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event"; import {ScrobbleEventTypePipe} from "../scrobble-event-type.pipe"; import {NgbPagination} from "@ng-bootstrap/ng-bootstrap"; import {ScrobbleEventSortField} from "../../_models/scrobbling/scrobble-event-filter"; -import {debounceTime, map, take, tap} from "rxjs/operators"; +import {debounceTime, take} from "rxjs/operators"; import {PaginatedResult, Pagination} from "../../_models/pagination"; import {SortableHeader, SortEvent} from "../table/_directives/sortable-header.directive"; import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; +import {TranslocoModule} from "@ngneat/transloco"; @Component({ selector: 'app-user-scrobble-history', standalone: true, - imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader], + imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule], templateUrl: './user-scrobble-history.component.html', styleUrls: ['./user-scrobble-history.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html index 330e7b736..56429db9f 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html @@ -1,63 +1,66 @@ - - + + + diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts index a3597f426..70899fc8e 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts @@ -6,6 +6,7 @@ import { DirectoryDto } from 'src/app/_models/system/directory-dto'; import { LibraryService } from '../../../_services/library.service'; import { NgIf, NgFor, NgClass } from '@angular/common'; import { ReactiveFormsModule, FormsModule } from '@angular/forms'; +import {TranslocoModule} from "@ngneat/transloco"; export interface DirectoryPickerResult { @@ -20,13 +21,13 @@ export interface DirectoryPickerResult { templateUrl: './directory-picker.component.html', styleUrls: ['./directory-picker.component.scss'], standalone: true, - imports: [ReactiveFormsModule, NgbTypeahead, FormsModule, NgbHighlight, NgIf, NgFor, NgClass] + imports: [ReactiveFormsModule, NgbTypeahead, FormsModule, NgbHighlight, NgIf, NgFor, NgClass, TranslocoModule] }) export class DirectoryPickerComponent implements OnInit { @Input() startingFolder: string = ''; /** - * Url to give more information about selecting directories. Passing nothing will suppress. + * Url to give more information about selecting directories. Passing nothing will suppress. */ @Input() helpUrl: string = 'https://wiki.kavitareader.com/en/guides/first-time-setup#adding-a-library-to-kavita'; @@ -161,7 +162,7 @@ export class DirectoryPickerComponent implements OnInit { while(this.routeStack.items.length - 1 > index) { this.routeStack.pop(); } - + const fullPath = this.routeStack.items.join('/'); this.path = fullPath; this.loadChildren(fullPath); diff --git a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.html b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.html index b4777b52c..a205bd4f9 100644 --- a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.html +++ b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.html @@ -1,33 +1,36 @@ + + + - + - +
+ + + diff --git a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts index ab1a41117..59be11996 100644 --- a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts +++ b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts @@ -6,13 +6,14 @@ import {LibraryService} from 'src/app/_services/library.service'; import {SelectionModel} from 'src/app/typeahead/_components/typeahead.component'; import {NgFor, NgIf} from '@angular/common'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {TranslocoModule} from "@ngneat/transloco"; @Component({ selector: 'app-library-access-modal', templateUrl: './library-access-modal.component.html', styleUrls: ['./library-access-modal.component.scss'], standalone: true, - imports: [ReactiveFormsModule, FormsModule, NgFor, NgIf], + imports: [ReactiveFormsModule, FormsModule, NgFor, NgIf, TranslocoModule], changeDetection: ChangeDetectionStrategy.OnPush }) export class LibraryAccessModalComponent implements OnInit { diff --git a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html index 580a26e41..716f95e78 100644 --- a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html +++ b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html @@ -1,21 +1,23 @@ -
+ + - \ No newline at end of file + +
diff --git a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.ts b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.ts index b1b39957c..3b8759f94 100644 --- a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.ts +++ b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.ts @@ -5,13 +5,14 @@ import { Member } from 'src/app/_models/auth/member'; import { AccountService } from 'src/app/_services/account.service'; import { SentenceCasePipe } from '../../../pipe/sentence-case.pipe'; import { NgIf } from '@angular/common'; +import {TranslocoModule} from "@ngneat/transloco"; @Component({ selector: 'app-reset-password-modal', templateUrl: './reset-password-modal.component.html', styleUrls: ['./reset-password-modal.component.scss'], standalone: true, - imports: [ReactiveFormsModule, NgIf, SentenceCasePipe] + imports: [ReactiveFormsModule, NgIf, SentenceCasePipe, TranslocoModule] }) export class ResetPasswordModalComponent { diff --git a/UI/Web/src/app/admin/dashboard/dashboard.component.html b/UI/Web/src/app/admin/dashboard/dashboard.component.html index bde8a9633..7523a76a4 100644 --- a/UI/Web/src/app/admin/dashboard/dashboard.component.html +++ b/UI/Web/src/app/admin/dashboard/dashboard.component.html @@ -1,12 +1,13 @@ - -

- Admin Dashboard -

-
-
- +
-
+
+ + diff --git a/UI/Web/src/app/admin/dashboard/dashboard.component.ts b/UI/Web/src/app/admin/dashboard/dashboard.component.ts index 4080a2b42..8e414ed06 100644 --- a/UI/Web/src/app/admin/dashboard/dashboard.component.ts +++ b/UI/Web/src/app/admin/dashboard/dashboard.component.ts @@ -1,23 +1,26 @@ -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute, RouterLink } from '@angular/router'; -import { ToastrService } from 'ngx-toastr'; -import { ServerService } from 'src/app/_services/server.service'; -import { Title } from '@angular/platform-browser'; -import { NavService } from '../../_services/nav.service'; -import { SentenceCasePipe } from '../../pipe/sentence-case.pipe'; -import { LicenseComponent } from '../license/license.component'; -import { ManageTasksSettingsComponent } from '../manage-tasks-settings/manage-tasks-settings.component'; -import { ServerStatsComponent } from '../../statistics/_components/server-stats/server-stats.component'; -import { ManageSystemComponent } from '../manage-system/manage-system.component'; -import { ManageLogsComponent } from '../manage-logs/manage-logs.component'; -import { ManageLibraryComponent } from '../manage-library/manage-library.component'; -import { ManageUsersComponent } from '../manage-users/manage-users.component'; -import { ManageMediaSettingsComponent } from '../manage-media-settings/manage-media-settings.component'; -import { ManageEmailSettingsComponent } from '../manage-email-settings/manage-email-settings.component'; -import { ManageSettingsComponent } from '../manage-settings/manage-settings.component'; -import { NgFor, NgIf } from '@angular/common'; -import { NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'; -import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; +import {ActivatedRoute, RouterLink} from '@angular/router'; +import {ToastrService} from 'ngx-toastr'; +import {ServerService} from 'src/app/_services/server.service'; +import {Title} from '@angular/platform-browser'; +import {NavService} from '../../_services/nav.service'; +import {SentenceCasePipe} from '../../pipe/sentence-case.pipe'; +import {LicenseComponent} from '../license/license.component'; +import {ManageTasksSettingsComponent} from '../manage-tasks-settings/manage-tasks-settings.component'; +import {ServerStatsComponent} from '../../statistics/_components/server-stats/server-stats.component'; +import {ManageSystemComponent} from '../manage-system/manage-system.component'; +import {ManageLogsComponent} from '../manage-logs/manage-logs.component'; +import {ManageLibraryComponent} from '../manage-library/manage-library.component'; +import {ManageUsersComponent} from '../manage-users/manage-users.component'; +import {ManageMediaSettingsComponent} from '../manage-media-settings/manage-media-settings.component'; +import {ManageEmailSettingsComponent} from '../manage-email-settings/manage-email-settings.component'; +import {ManageSettingsComponent} from '../manage-settings/manage-settings.component'; +import {NgFor, NgIf} from '@angular/common'; +import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap'; +import { + SideNavCompanionBarComponent +} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; +import {TranslocoModule, TranslocoService} from "@ngneat/transloco"; enum TabID { General = '', @@ -26,7 +29,6 @@ enum TabID { Users = 'users', Libraries = 'libraries', System = 'system', - Plugins = 'plugins', Tasks = 'tasks', Logs = 'logs', Statistics = 'statistics', @@ -38,24 +40,28 @@ enum TabID { templateUrl: './dashboard.component.html', styleUrls: ['./dashboard.component.scss'], standalone: true, - imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ManageSettingsComponent, ManageEmailSettingsComponent, ManageMediaSettingsComponent, ManageUsersComponent, ManageLibraryComponent, ManageLogsComponent, ManageSystemComponent, ServerStatsComponent, ManageTasksSettingsComponent, LicenseComponent, NgbNavOutlet, SentenceCasePipe] + imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ManageSettingsComponent, ManageEmailSettingsComponent, ManageMediaSettingsComponent, ManageUsersComponent, ManageLibraryComponent, ManageLogsComponent, ManageSystemComponent, ServerStatsComponent, ManageTasksSettingsComponent, LicenseComponent, NgbNavOutlet, SentenceCasePipe, TranslocoModule], + changeDetection: ChangeDetectionStrategy.OnPush }) export class DashboardComponent implements OnInit { tabs: Array<{title: string, fragment: string}> = [ - {title: 'General', fragment: TabID.General}, - {title: 'Users', fragment: TabID.Users}, - {title: 'Libraries', fragment: TabID.Libraries}, - //{title: 'Logs', fragment: TabID.Logs}, - {title: 'Media', fragment: TabID.Media}, - {title: 'Email', fragment: TabID.Email}, - {title: 'Tasks', fragment: TabID.Tasks}, - {title: 'Statistics', fragment: TabID.Statistics}, - {title: 'System', fragment: TabID.System}, - {title: 'Kavita+', fragment: TabID.KavitaPlus}, + {title: 'general-tab', fragment: TabID.General}, + {title: 'users-tab', fragment: TabID.Users}, + {title: 'libraries-tab', fragment: TabID.Libraries}, + //{title: 'logs-tab', fragment: TabID.Logs}, + {title: 'media-tab', fragment: TabID.Media}, + {title: 'email-tab', fragment: TabID.Email}, + {title: 'tasks-tab', fragment: TabID.Tasks}, + {title: 'statistics-tab', fragment: TabID.Statistics}, + {title: 'system-tab', fragment: TabID.System}, + {title: 'kavita+-tab', fragment: TabID.KavitaPlus}, ]; active = this.tabs[0]; + private readonly cdRef = inject(ChangeDetectorRef); + private readonly translocoService = inject(TranslocoService); + get TabID() { return TabID; } @@ -69,11 +75,12 @@ export class DashboardComponent implements OnInit { } else { this.active = this.tabs[0]; // Default to first tab } + this.cdRef.markForCheck(); }); } ngOnInit() { - this.titleService.setTitle('Kavita - Admin Dashboard'); + this.titleService.setTitle('Kavita - ' + this.translocoService.translate('admin-dashboard.title')); } } diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.html b/UI/Web/src/app/admin/edit-user/edit-user.component.html index ff5134e8a..e3ffa68ae 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.html +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.html @@ -1,68 +1,70 @@ -