Localization - First Pass (#2174)

* Started designing the backend localization service

* Worked in Transloco for initial PoC

* Worked in Transloco for initial PoC

* Translated the login screen

* translated dashboard screen

* Started work on the backend

* Fixed a logic bug

* translated edit-user screen

* Hooked up the backend for having a locale property.

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

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

* Fixed up localization switching

* Switched when we check for a license on UI bootstrap

* Tweaked some code

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

* Fixed a bug on dashboard with languagePath

* Converted user-scrobble-history.component.html

* Converted spoiler.component.html

* Converted review-series-modal.component.html

* Converted review-card-modal.component.html

* Updated the readme

* Translated using Weblate (English)

Currently translated at 100.0% (54 of 54 strings)

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

* Converted review-card.component.html

* Deleted dead component

* Converted want-to-read.component.html

* Added translation using Weblate (Korean)

* Translated using Weblate (Spanish)

Currently translated at 40.7% (22 of 54 strings)

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

* Translated using Weblate (Korean)

Currently translated at 62.9% (34 of 54 strings)

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

* Converted user-preferences.component.html

* Translated using Weblate (Korean)

Currently translated at 92.5% (50 of 54 strings)

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

* Converted user-holds.component.html

* Converted theme-manager.component.html

* Converted restriction-selector.component.html

* Converted manage-devices.component.html

* Converted edit-device.component.html

* Converted change-password.component.html

* Converted change-email.component.html

* Converted change-age-restriction.component.html

* Converted api-key.component.html

* Converted anilist-key.component.html

* Converted typeahead.component.html

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

* Converted user-stats.component.html

* Converted top-readers.component.html

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

* Finished all but one pipe for localization

* Converted directory-picker.component.html

* Converted library-access-modal.component.html

* Converted a few components

* Converted a few components

* Converted a few components

* Converted a few components

* Converted a few components

* Merged weblate in

* ... -> … update

* Updated the readme

* Updateded all fonts to be woff2

* Cleaned up some strings to increase re-use

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

* Converted Series detail

* Lots more converted

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

* Lots more converted

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

* Lots more converted

* Lots more converted

* Lots more converted & fixed some bad keys

* Lots more converted

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

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

* Fixed a stupid build issue again

* Started adding errors for interceptor and backend.

* Finished off manga-reader

* More translations

* Few fixes

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

* All components are translated

* All toasts are translated

* All confirm/alerts are translated

* Trying something new for the backend

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

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

* Cleaned up some external reviews code to reduce looping

* Localized AccountController.cs

* 60% done with controllers

* All controllers are done

* All KavitaExceptions are covered

* Some shakeout fixes

* Prep for initial merge

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

* Fixed up the unit tests

* All unit tests are now working

* Removed some quantifier

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

---------

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

View File

@ -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

View File

@ -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);

View File

@ -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<ILogger<DirectoryService>>(), new FileSystem()
{
});
var locService = new LocalizationService(ds, new MockHostingEnvironment(),
Substitute.For<IMemoryCache>(), Substitute.For<IUnitOfWork>());
_seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(),
Substitute.For<IScrobblingService>());
Substitute.For<IScrobblingService>(), 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

View File

@ -191,6 +191,7 @@
<ItemGroup>
<Folder Include="config\themes" />
<Folder Include="I18N\**" />
</ItemGroup>
<ItemGroup>

View File

@ -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;
/// <inheritdoc />
public AccountController(UserManager<AppUser> userManager,
@ -53,7 +51,8 @@ public class AccountController : BaseApiController
ITokenService tokenService, IUnitOfWork unitOfWork,
ILogger<AccountController> 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;
}
/// <summary>
@ -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<ActionResult<UserDto>> 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<ActionResult> 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<ActionResult> 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"));
}
/// <summary>
@ -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<ActionResult<string>> 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"));
}
/// <summary>
@ -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<ActionResult<UserDto>> 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<ActionResult<string>> 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"));
}
/// <summary>
@ -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"));
}

View File

@ -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;
}
/// <summary>
@ -37,7 +41,7 @@ public class BookController : BaseApiController
public async Task<ActionResult<BookInfoDto>> 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<ActionResult> 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<ActionResult<ICollection<BookChapterItem>>> 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<ActionResult<string>> 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));
}
}
}

View File

@ -20,12 +20,15 @@ public class CollectionController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ICollectionTagService _collectionService;
private readonly ILocalizationService _localizationService;
/// <inheritdoc />
public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService)
public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService,
ILocalizationService localizationService)
{
_unitOfWork = unitOfWork;
_collectionService = collectionService;
_localizationService = localizationService;
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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<SeriesMetadata>();
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"));
}
}

View File

@ -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<ActionResult> 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<ActionResult> 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<ActionResult> 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"));
}

View File

@ -30,11 +30,12 @@ public class DownloadController : BaseApiController
private readonly ILogger<DownloadController> _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<DownloadController> 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;
}
/// <summary>
@ -92,9 +94,9 @@ public class DownloadController : BaseApiController
[HttpGet("volume")]
public async Task<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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()!;

View File

@ -22,13 +22,16 @@ public class ImageController : BaseApiController
private readonly IUnitOfWork _unitOfWork;
private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService;
private readonly ILocalizationService _localizationService;
/// <inheritdoc />
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;
}
/// <summary>
@ -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<ActionResult> 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));

View File

@ -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<LibraryController> 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<ActionResult<IEnumerable<JumpKeyDto>>> 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<ActionResult<MemberDto>> 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"));
}
/// <summary>
@ -224,9 +226,9 @@ public class LibraryController : BaseApiController
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("scan")]
public ActionResult Scan(int libraryId, bool force = false)
public async Task<ActionResult> 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<ActionResult> 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();

View File

@ -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<LicenseController> _logger;
private readonly ILicenseService _licenseService;
private readonly ILocalizationService _localizationService;
public LicenseController(IUnitOfWork unitOfWork, ILogger<LicenseController> logger,
ILicenseService licenseService)
ILicenseService licenseService, ILocalizationService localizationService)
{
_unitOfWork = unitOfWork;
_logger = logger;
_licenseService = licenseService;
_localizationService = localizationService;
}
/// <summary>
@ -73,7 +77,14 @@ public class LicenseController : BaseApiController
[HttpPost]
public async Task<ActionResult> 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();
}
}

View File

@ -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<IEnumerable<string>> 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);
}
}

View File

@ -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;
}
/// <summary>
@ -35,7 +38,7 @@ public class MetadataController : BaseApiController
public async Task<ActionResult<IList<GenreTagDto>>> 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<ActionResult<IList<PersonDto>>> 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<ActionResult<IList<TagDto>>> 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<ActionResult<IList<AgeRatingDto>>> 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<IList<AgeRatingDto>> 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<ActionResult<IList<LanguageDto>>> 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<ActionResult<string>> 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);
}
}

View File

@ -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<IActionResult> 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<FeedLink>()
{
@ -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<FeedLink>()
{
@ -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<FeedLink>()
{
@ -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<FeedLink>()
{
@ -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<FeedLink>()
{
@ -183,12 +185,12 @@ public class OpdsController : BaseApiController
[Produces("application/xml")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<ActionResult> 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<FeedEntry> CreateChapterWithFile(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
private async Task<FeedEntry> 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<ActionResult> 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<ActionResult> 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<FeedLink> CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey, string prefix)

View File

@ -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;
/// <inheritdoc />
public ReaderController(ICacheService cacheService,
IUnitOfWork unitOfWork, ILogger<ReaderController> 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;
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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<ActionResult> 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<ActionResult> 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()

View File

@ -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;
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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)");
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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<ActionResult> 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"));
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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<int>() { 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"));
}
/// <summary>
@ -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)
{

View File

@ -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}";

View File

@ -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<UserReviewDto> externalReviews;
var setCache = false;
var result = await _cacheProvider.GetAsync<IEnumerable<UserReviewDto>>(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<IEnumerable<UserReviewDto>>(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));
}

View File

@ -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<ScrobblingController> _logger;
private readonly ILocalizationService _localizationService;
public ScrobblingController(IUnitOfWork unitOfWork, IScrobblingService scrobblingService, ILogger<ScrobblingController> logger)
public ScrobblingController(IUnitOfWork unitOfWork, IScrobblingService scrobblingService,
ILogger<ScrobblingController> 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"));
}
}

View File

@ -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;
}
/// <summary>
@ -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);

View File

@ -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<SeriesController> 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"));
}
/// <summary>
@ -149,7 +152,8 @@ public class SeriesController : BaseApiController
public async Task<ActionResult> 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<ActionResult> 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"));
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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<ActionResult<IEnumerable<SeriesDto>>> 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
/// <remarks>This is cached for an hour</remarks>
[ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = new [] {"ageRating"})]
[HttpGet("age-rating")]
public ActionResult<string> GetAgeRating(int ageRating)
public async Task<ActionResult<string>> 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<ActionResult<SeriesDetailDto>> 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"));
}

View File

@ -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<ServerController> 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;
}
/// <summary>
@ -103,12 +106,12 @@ public class ServerController : BaseApiController
/// </summary>
/// <returns></returns>
[HttpPost("analyze-files")]
public ActionResult AnalyzeFiles()
public async Task<ActionResult> AnalyzeFiles()
{
_logger.LogInformation("{UserName} is performing file analysis from admin dashboard", User.GetUsername());
if (TaskScheduler.HasAlreadyEnqueuedTask(ScannerService.Name, "AnalyzeFiles",
Array.Empty<object>(), 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
/// <summary>
/// Returns non-sensitive information about the current system
/// </summary>
/// <remarks>This is just for the UI and is extremly lightweight</remarks>
/// <remarks>This is just for the UI and is extremely lightweight</remarks>
/// <returns></returns>
[HttpGet("server-info-slim")]
public async Task<ActionResult<ServerInfoDto>> 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
/// </summary>
/// <returns></returns>
[HttpGet("logs")]
public ActionResult GetLogs()
public async Task<ActionResult> 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));
}
}

View File

@ -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<SettingsController> 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"));
}

View File

@ -19,12 +19,15 @@ public class StatsController : BaseApiController
private readonly IStatisticService _statService;
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager<AppUser> _userManager;
private readonly ILocalizationService _localizationService;
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork, UserManager<AppUser> userManager)
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork,
UserManager<AppUser> 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<int>()));
}

View File

@ -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;
}
/// <summary>
@ -31,7 +34,7 @@ public class TachiyomiController : BaseApiController
[HttpGet("latest-chapter")]
public async Task<ActionResult<ChapterDto>> 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()));
}

View File

@ -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<ActionResult> 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));
}
}
}

View File

@ -25,10 +25,12 @@ public class UploadController : BaseApiController
private readonly IDirectoryService _directoryService;
private readonly IEventHub _eventHub;
private readonly IReadingListService _readingListService;
private readonly ILocalizationService _localizationService;
/// <inheritdoc />
public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger<UploadController> 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;
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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<string> 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"));
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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"));
}
}

View File

@ -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"));
}
/// <summary>
@ -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);
}
/// <summary>

View File

@ -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;
}
/// <summary>
@ -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"));
}
/// <summary>
@ -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"));
}
}

View File

@ -147,4 +147,9 @@ public class UserPreferencesDto
/// </summary>
[Required]
public bool ShareReviews { get; set; } = false;
/// <summary>
/// UI Site Global Setting: The language locale that should be used for the user
/// </summary>
[Required]
public string Locale { get; set; }
}

View File

@ -100,6 +100,10 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<AppUserPreferences>()
.Property(b => b.BookReaderWritingStyle)
.HasDefaultValue(WritingStyle.Horizontal);
builder.Entity<AppUserPreferences>()
.Property(b => b.Locale)
.IsRequired(true)
.HasDefaultValue("en");
builder.Entity<Library>()
.Property(b => b.AllowScrobbling)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class AddLocaleOnPrefs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Locale",
table: "AppUserPreferences",
type: "TEXT",
nullable: false,
defaultValue: "en");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Locale",
table: "AppUserPreferences");
}
}
}

View File

@ -272,6 +272,12 @@ namespace API.Data.Migrations
b.Property<int>("LayoutMode")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("en");
b.Property<bool>("NoTransitions")
.HasColumnType("INTEGER");

View File

@ -28,7 +28,7 @@ public interface IAppUserProgressRepository
Task<IEnumerable<AppUserProgress>> GetUserProgressForSeriesAsync(int seriesId, int userId);
Task<IEnumerable<AppUserProgress>> GetAllProgress();
Task<DateTime> GetLatestProgress();
Task<ProgressDto> GetUserProgressDtoAsync(int chapterId, int userId);
Task<ProgressDto?> GetUserProgressDtoAsync(int chapterId, int userId);
Task<bool> AnyUserProgressForSeriesAsync(int seriesId, int userId);
Task<int> GetHighestFullyReadChapterForSeries(int seriesId, int userId);
Task<int> GetHighestFullyReadVolumeForSeries(int seriesId, int userId);
@ -143,7 +143,7 @@ public class AppUserProgressRepository : IAppUserProgressRepository
.FirstOrDefaultAsync();
}
public async Task<ProgressDto> GetUserProgressDtoAsync(int chapterId, int userId)
public async Task<ProgressDto?> GetUserProgressDtoAsync(int chapterId, int userId)
{
return await _context.AppUserProgresses
.Where(p => p.AppUserId == userId && p.ChapterId == chapterId)

View File

@ -73,7 +73,7 @@ public interface IUserRepository
Task<IEnumerable<AppUserRating>> GetSeriesWithReviews(int userId);
Task<bool> HasHoldOnSeries(int userId, int seriesId);
Task<IList<ScrobbleHoldDto>> GetHolds(int userId);
Task<string> GetLocale(int userId);
}
public class UserRepository : IUserRepository
@ -291,6 +291,13 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<string> GetLocale(int userId)
{
return await _context.AppUserPreferences.Where(p => p.AppUserId == userId)
.Select(p => p.Locale)
.SingleAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);

View File

@ -127,6 +127,10 @@ public class AppUserPreferences
/// UI Site Global Setting: Should series reviews be shared with all users in the server
/// </summary>
public bool ShareReviews { get; set; } = false;
/// <summary>
/// UI Site Global Setting: The language locale that should be used for the user
/// </summary>
public string Locale { get; set; }
public AppUser AppUser { get; set; } = null!;
public int AppUserId { get; set; }

View File

@ -66,6 +66,8 @@ public static class ApplicationServiceExtensions
services.AddScoped<IPresenceTracker, PresenceTracker>();
services.AddScoped<IImageService, ImageService>();
services.AddScoped<ILocalizationService, LocalizationService>();
services.AddScoped<IScrobblingService, ScrobblingService>();
services.AddScoped<ILicenseService, LicenseService>();

View File

@ -37,4 +37,11 @@ public class AppUserBuilder : IEntityBuilder<AppUser>
_appUser.Libraries.Add(library);
return this;
}
public AppUserBuilder WithLocale(string locale)
{
_appUser.UserPreferences.Locale = locale;
return this;
}
}

185
API/I18N/en.json Normal file
View File

@ -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}"
}

View File

@ -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;

View File

@ -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("<body></body>"));
@ -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<BookChapterItem> nestedChapters,

View File

@ -52,12 +52,12 @@ public class CollectionTagService : ICollectionTagService
public async Task<bool> 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<SeriesMetadata>();
existingTag.Title = title;

View File

@ -42,7 +42,7 @@ public class DeviceService : IDeviceService
{
userWithDevices.Devices ??= new List<Device>();
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<bool> SendTo(IReadOnlyList<int> 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();

View File

@ -25,6 +25,7 @@ public interface IDirectoryService
string ConfigDirectory { get; }
string SiteThemeDirectory { get; }
string FaviconDirectory { get; }
string LocalizationDirectory { get; }
/// <summary>
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
/// </summary>
@ -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<DirectoryService> _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");
}
/// <summary>

View File

@ -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<string> Get(string locale, string key, params object[] args);
Task<string> Translate(int userId, string key, params object[] args);
IEnumerable<string> GetLocales();
}
public class LocalizationService : ILocalizationService
{
private readonly IDirectoryService _directoryService;
private readonly IMemoryCache _cache;
private readonly IUnitOfWork _unitOfWork;
/// <summary>
/// The locales for the UI
/// </summary>
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));
}
/// <summary>
/// Loads a language, if language is blank, falls back to english
/// </summary>
/// <param name="languageCode"></param>
/// <returns></returns>
public async Task<Dictionary<string, string>?> 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<Dictionary<string, string>>(json);
}
public async Task<string> 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;
}
/// <summary>
/// Returns a translated string for a given user's locale, falling back to english or the key if missing
/// </summary>
/// <param name="userId"></param>
/// <param name="key"></param>
/// <param name="args"></param>
/// <returns></returns>
public async Task<string> Translate(int userId, string key, params object[] args)
{
var userLocale = await _unitOfWork.UserRepository.GetLocale(userId);
return await Get(userLocale, key, args);
}
/// <summary>
/// Returns all available locales that exist on both the Frontend and the Backend
/// </summary>
/// <returns></returns>
public IEnumerable<string> 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();
}
}

View File

@ -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();

View File

@ -60,6 +60,7 @@ public class ScrobblingService : IScrobblingService
private readonly IEventHub _eventHub;
private readonly ILogger<ScrobblingService> _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<ScrobblingService> logger, ILicenseService licenseService)
IEventHub eventHub, ILogger<ScrobblingService> 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;

View File

@ -117,7 +117,7 @@ public class ReaderService : IReaderService
{
var seenVolume = new Dictionary<int, bool>();
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 =

View File

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

View File

@ -30,6 +30,12 @@ public interface ISeriesService
Task<bool> DeleteMultipleSeries(IList<int> seriesIds);
Task<bool> UpdateRelatedSeries(UpdateRelatedSeriesDto dto);
Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
Task<string> FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true);
Task<string> FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true);
Task<string> FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string? chapterTitle,
bool withHash);
Task<string> 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<SeriesService> _logger;
private readonly IScrobblingService _scrobblingService;
private readonly ILocalizationService _localizationService;
public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler,
ILogger<SeriesService> logger, IScrobblingService scrobblingService)
ILogger<SeriesService> logger, IScrobblingService scrobblingService, ILocalizationService localizationService)
{
_unitOfWork = unitOfWork;
_eventHub = eventHub;
_taskScheduler = taskScheduler;
_logger = logger;
_scrobblingService = scrobblingService;
_localizationService = localizationService;
}
/// <summary>
@ -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<VolumeDto>();
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<string> 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<string> 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<string> 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<string> 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();
}
/// <summary>

View File

@ -40,10 +40,10 @@ public class ThemeService : IThemeService
public async Task<string> 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())
{

View File

@ -7,6 +7,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=epubs/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=kavitaignore/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=kavitaignores/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=langs/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=MACOSX/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=noopener/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=noreferrer/@EntryIndexedValue">True</s:Boolean>

View File

@ -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)
<a href="https://hosted.weblate.org/engage/kavita/">
<img src="https://hosted.weblate.org/widgets/kavita/-/ui/svg-badge.svg" alt="Translation status" />
</a>
</div>
## 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 [<img src="/Logo/jetbrains.svg" alt="" width="32"> JetBrains](http:
## Palace-Designs
We would like to extend a big thank you to [<img src="/Logo/hosting-sponsor.png" alt="" width="128">](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.

358
UI/Web/package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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<boolean> {
// 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;
})
);

View File

@ -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<boolean> {
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');

View File

@ -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<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
@ -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);
}
}
}

View File

@ -23,4 +23,8 @@
* Inner HTML
*/
content: string;
/**
* Key for translation
*/
translationKey: string;
}

View File

@ -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}];

View File

@ -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));

View File

@ -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<Library>) => 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<number>, 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<Series>, 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<number>, 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();
}

View File

@ -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<Language[]>(this.baseUrl + 'locale');
}
}

View File

@ -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<UserReadStatistics>(this.baseUrl + url);
}
@ -88,14 +89,14 @@ export class StatisticsService {
getPublicationStatus() {
return this.httpClient.get<StatCount<PublicationStatus>[]>(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<StatCount<MangaFormat>[]>(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};
})));
}

View File

@ -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;
}

View File

@ -1,32 +0,0 @@
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">KavitaPlus Features</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<h5>Current Features</h5>
<ul class="list-group mb-2">
<li class="list-group-item">Scrobble Support</li>
<li class="list-group-item">Series Recommendations</li>
<li class="list-group-item">Series Reviews</li>
<li class="list-group-item">Remove Donation on Side nav</li>
</ul>
<h5>Planned Features</h5>
<ul class="list-group mb-2">
<li class="list-group-item">More external data providers</li>
<li class="list-group-item">Webhooks</li>
<li class="list-group-item">Kobo Progress Syncing</li>
<li class="list-group-item">Trending/External rating integration</li>
<li class="list-group-item">Your ideas upvoted via FeatHub</li>
</ul>
<div class="text-muted">These feature unlock for the whole server while subscription active</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
</div>
</div>

View File

@ -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();
}
}

View File

@ -1,18 +1,19 @@
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{review.username + "'s"}} Review {{review.isExternal ? '(external)' : ''}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
<ng-container *transloco="let t; read:'review-card-modal'">
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('user-review', {username: review.username})}} {{review.isExternal ? t('external-mod') : ''}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal">
<p *ngIf="review.tagline" [innerHTML]="review.tagline | safeHtml"></p>
<p #container class="img-max-width" [innerHTML]="review.body | safeHtml"></p>
</div>
<div class="modal-footer">
<a *ngIf="review.externalUrl" class="btn btn-icon" [href]="review.externalUrl | safeHtml" target="_blank" rel="noopener noreferrer" [title]="review.externalUrl">
{{t('go-to-review')}}
</a>
<button type="submit" class="btn btn-primary" (click)="close()">{{t('close')}}</button>
</div>
</div>
<div class="modal-body scrollable-modal">
<p *ngIf="review.tagline" [innerHTML]="review.tagline | safeHtml"></p>
<p #container class="img-max-width" [innerHTML]="review.body | safeHtml"></p>
</div>
<div class="modal-footer">
<a *ngIf="review.externalUrl" class="btn btn-icon" [href]="review.externalUrl | safeHtml" target="_blank" rel="noopener noreferrer" [title]="review.externalUrl">
Go To Review
</a>
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
</div>
</div>
</ng-container>

View File

@ -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,

View File

@ -1,35 +1,37 @@
<div class="card mb-3" style="max-width: 320px; max-height: 160px; height: 160px" (click)="showModal()">
<div class="row g-0">
<div class="col-md-2 d-none d-md-block">
<i class="img-fluid rounded-start fa-solid fa-circle-user profile-image" aria-hidden="true"></i>
<div *ngIf="isMyReview" class="my-review">
<i class="fa-solid fa-star" aria-hidden="true" title="This is your review"></i>
<span class="visually-hidden">This is your review</span>
<ng-container *transloco="let t; read:'review-card'">
<div class="card mb-3" style="max-width: 320px; max-height: 160px; height: 160px" (click)="showModal()">
<div class="row g-0">
<div class="col-md-2 d-none d-md-block">
<i class="img-fluid rounded-start fa-solid fa-circle-user profile-image" aria-hidden="true"></i>
<div *ngIf="isMyReview" class="my-review">
<i class="fa-solid fa-star" aria-hidden="true" [title]="t('your-review')"></i>
<span class="visually-hidden">{{t('your-review')}}</span>
</div>
</div>
</div>
<div class="col-md-10">
<div class="card-body">
<h6 class="card-title" [title]="review.tagline">
<ng-container *ngIf="review.tagline && review.tagline.length > 0; else noTagline">{{review.tagline.substring(0, 29)}}{{review.tagline.length > 29 ? '…' : ''}}</ng-container>
<ng-template #noTagline>
{{review.isExternal ? 'External Review' : 'Review'}}
</ng-template>
</h6>
<p class="card-text no-images">
<app-read-more [text]="(review.isExternal ? review.bodyJustText : review.body) || ''" [maxLength]="100" [showToggle]="false"></app-read-more>
</p>
<div class="col-md-10">
<div class="card-body">
<h6 class="card-title" [title]="review.tagline">
<ng-container *ngIf="review.tagline && review.tagline.length > 0; else noTagline">{{review.tagline.substring(0, 29)}}{{review.tagline.length > 29 ? '…' : ''}}</ng-container>
<ng-template #noTagline>
{{review.isExternal ? t('external-review') : t('local-review')}}
</ng-template>
</h6>
<p class="card-text no-images">
<app-read-more [text]="(review.isExternal ? review.bodyJustText : review.body) || ''" [maxLength]="100" [showToggle]="false"></app-read-more>
</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent text-muted">
<ng-container *ngIf="isMyReview; else normalReview">
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" title="This is your review"></i>
</ng-container>
<ng-template #normalReview>
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
</ng-template>
{{(isMyReview ? '' : review.username | defaultValue:'')}}
<span style="float: right" *ngIf="review.isExternal">Rating {{review.score}}%</span>
<div class="card-footer bg-transparent text-muted">
<ng-container *ngIf="isMyReview; else normalReview">
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" [title]="t('your-review')"></i>
</ng-container>
<ng-template #normalReview>
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
</ng-template>
{{(isMyReview ? '' : review.username | defaultValue:'')}}
<span style="float: right" *ngIf="review.isExternal">{{t('rating-percentage', {r: review.score})}}%</span>
</div>
</div>
</div>
</div>
</ng-container>

View File

@ -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

View File

@ -1,28 +1,30 @@
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit Review</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
<ng-container *transloco="let t; read:'review-series-modal'">
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
</button>
</div>
<div class="modal-body">
<form [formGroup]="reviewGroup">
<div class="row g-0">
<label for="tagline" class="form-label">Tagline</label>
<input id="tagline" class="form-control" formControlName="tagline" />
</div>
</button>
</div>
<div class="modal-body">
<form [formGroup]="reviewGroup">
<div class="row g-0">
<label for="tagline" class="form-label">{{t('tagline-label')}}</label>
<input id="tagline" class="form-control" formControlName="tagline" />
</div>
<div class="row g-0 mt-2">
<label for="review" class="form-label">Review</label>
<textarea id="review" class="form-control" formControlName="reviewBody" rows="3" ></textarea>
</div>
</form>
<div class="row g-0 mt-2">
<label for="review" class="form-label">{{t('review-label')}}</label>
<textarea id="review" class="form-control" formControlName="reviewBody" rows="3" ></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" (click)="close()">Close</button>
<button type="submit" class="btn btn-primary" (click)="save()">Save</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
<button type="submit" class="btn btn-primary" (click)="save()">{{t('save')}}</button>
</div>
</div>
</ng-container>

View File

@ -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

View File

@ -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');
}
}

View File

@ -1,7 +1,8 @@
<div (click)="toggle()" [attr.aria-expanded]="!isCollapsed" class="btn spoiler" tabindex="0">
<span *ngIf="isCollapsed; else show">Spoiler, click to show</span>
<ng-template #show>
<div [innerHTML]="html | safeHtml"></div>
</ng-template>
</div>
<ng-container *transloco="let t; read:'spoiler'">
<div (click)="toggle()" [attr.aria-expanded]="!isCollapsed" class="btn spoiler" tabindex="0">
<span *ngIf="isCollapsed; else show">{{t('click-to-show')}}</span>
<ng-template #show>
<div [innerHTML]="html | safeHtml"></div>
</ng-template>
</div>
</ng-container>

View File

@ -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,

View File

@ -1,51 +1,50 @@
<h5>Scrobble History</h5>
<p>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.</p>
<div class="row g-0 mb-2">
<div class="col-md-10">
<form [formGroup]="formGroup">
<div class="form-group pe-1">
<label for="filter">Filter</label>
<input id="filter" type="text" class="form-control" formControlName="filter" autocomplete="off"/>
</div>
</form>
<ng-container *transloco="let t; read:'user-scrobble-history'">
<h5>{{t('title')}}</h5>
<p>{{t('description')}}</p>
<div class="row g-0 mb-2">
<div class="col-md-10">
<form [formGroup]="formGroup">
<div class="form-group pe-1">
<label for="filter">{{t('filter-label')}}</label>
<input id="filter" type="text" class="form-control" formControlName="filter" autocomplete="off"/>
</div>
</form>
</div>
<div class="col-md-2 mt-4">
<ngb-pagination *ngIf="pagination"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
[collectionSize]="pagination.totalItems"
(pageChange)="onPageChange($event)"
></ngb-pagination>
</div>
</div>
<div class="col-md-2 mt-4">
<ngb-pagination *ngIf="pagination"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
[collectionSize]="pagination.totalItems"
(pageChange)="onPageChange($event)"
></ngb-pagination>
</div>
</div>
<table class="table table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="created" (sort)="updateSort($event)">
Created
{{t('created-header')}}
</th>
<th scope="col" sortable="lastModified" (sort)="updateSort($event)" direction="desc">
Last Modified
{{t('last-modified-header')}}
</th>
<th scope="col">
Type
{{t('type-header')}}
</th>
<th scope="col" sortable="seriesName" (sort)="updateSort($event)">
Series
{{t('series-header')}}
</th>
<th scope="col">
Data
{{t('data-header')}}
</th>
<th scope="col">
Is Processed
{{t('is-processed-header')}}
</th>
</tr>
</thead>
<tbody>
<tr *ngIf="events.length === 0">
<td colspan="6">No Data</td>
<td colspan="6">{{t('no-data')}}/td>
</tr>
<tr *ngFor="let item of events; let idx = index;">
<td>
@ -63,22 +62,24 @@
<td>
<ng-container [ngSwitch]="item.scrobbleEventType">
<ng-container *ngSwitchCase="ScrobbleEventType.ChapterRead">
Volume {{item.volumeNumber}} Chapter {{item.chapterNumber}}
{{t('volume-and-chapter-num', {v: item.volumeNumber, c: item.chapterNumber})}}
</ng-container>
<ng-container *ngSwitchCase="ScrobbleEventType.ScoreUpdated">
Rating {{item.rating}}
{{t('rating', {r: item.rating})}}
</ng-container>
<ng-container *ngSwitchDefault>
Not Applicable
{{t('not-applicable')}}
</ng-container>
</ng-container>
</td>
<td>
<i class="fa-regular fa-circle icon" aria-hidden="true" *ngIf="!item.isProcessed"></i>
<i class="fa-solid fa-check-circle icon" aria-hidden="true" *ngIf="item.isProcessed"></i>
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">{{item.isProcessed ? 'Processed' : 'Not Processed'}}</span>
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">
{{item.isProcessed ? t('processed') : t('not-processed')}}
</span>
</td>
</tr>
</tbody>
</table>
</ng-container>

View File

@ -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

View File

@ -1,63 +1,66 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Choose a Directory</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<ng-container *transloco="let t; read:'directory-picker'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="typeahead-focus" class="form-label">Path</label>
<div class="input-group">
<input id="typeahead-focus" type="text" class="form-control" [(ngModel)]="path" [ngbTypeahead]="search"
(focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)"
(ngModelChange)="updateTable()" #instance="ngbTypeahead" placeholder="Start typing or select path"
[resultTemplate]="rt" />
</div>
<ng-template #rt let-r="result" let-t="term">
<ngb-highlight [result]="r" [term]="t"></ngb-highlight>
</ng-template>
<label for="typeahead-focus" class="form-label">{{t('path')}}</label>
<div class="input-group">
<input id="typeahead-focus" type="text" class="form-control" [(ngModel)]="path" [ngbTypeahead]="search"
(focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)"
(ngModelChange)="updateTable()" #instance="ngbTypeahead" [placeholder]="t('path-placeholder')"
[resultTemplate]="rt" />
</div>
<ng-template #rt let-r="result" let-t="term">
<ngb-highlight [result]="r" [term]="t"></ngb-highlight>
</ng-template>
</div>
<nav aria-label="directory breadcrumb">
<ol class="breadcrumb" *ngIf="routeStack.peek() !== undefined; else noBreadcrumb">
<li class="breadcrumb-item {{route === routeStack.peek() ? 'active' : ''}}"
*ngFor="let route of routeStack.items; let index = index; let last = last;">
<ng-container *ngIf="last; else nonActive">
{{route}}
</ng-container>
<ng-template #nonActive>
<a href="javascript:void(0);" (click)="navigateTo(index)">{{route}}</a>
</ng-template>
</li>
</ol>
<ng-template #noBreadcrumb>
<div class="breadcrumb">Select a folder to view breadcrumb. Don't see your directory? Try checking / first.
</div>
</ng-template>
<ol class="breadcrumb" *ngIf="routeStack.peek() !== undefined; else noBreadcrumb">
<li class="breadcrumb-item {{route === routeStack.peek() ? 'active' : ''}}"
*ngFor="let route of routeStack.items; let index = index; let last = last;">
<ng-container *ngIf="last; else nonActive">
{{route}}
</ng-container>
<ng-template #nonActive>
<a href="javascript:void(0);" (click)="navigateTo(index)">{{route}}</a>
</ng-template>
</li>
</ol>
<ng-template #noBreadcrumb>
<div class="breadcrumb">{{t('instructions')}}
</div>
</ng-template>
</nav>
<table class="table table-striped scrollable">
<thead>
<tr>
<th scope="col" style="width: 40px;">Type</th>
<th scope="col">Name</th>
</tr>
</thead>
<tbody>
<tr (click)="goBack()">
<td><i class="fa-solid fa-arrow-turn-up" aria-hidden="true"></i></td>
<td>...</td>
</tr>
<tr *ngFor="let folder of folders; let idx = index;" (click)="selectNode(folder)" style="cursor: pointer;" [ngClass]="{'disabled': folder.disabled}">
<td><i class="fa-regular fa-folder" aria-hidden="true"></i></td>
<td id="folder--{{idx}}">
{{folder.name}}
</td>
</tr>
</tbody>
<thead>
<tr>
<th scope="col" style="width: 40px;">{{t('type-header')}}</th>
<th scope="col">{{t('name-header')}}</th>
</tr>
</thead>
<tbody>
<tr (click)="goBack()">
<td><i class="fa-solid fa-arrow-turn-up" aria-hidden="true"></i></td>
<td>...</td>
</tr>
<tr *ngFor="let folder of folders; let idx = index;" (click)="selectNode(folder)" style="cursor: pointer;" [ngClass]="{'disabled': folder.disabled}">
<td><i class="fa-regular fa-folder" aria-hidden="true"></i></td>
<td id="folder--{{idx}}">
{{folder.name}}
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<a class="btn btn-icon" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank" rel="noopener noreferrer">Help</a>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="button" class="btn btn-primary" (click)="share()">Share</button>
</div>
</div>
<div class="modal-footer">
<a class="btn btn-icon" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank" rel="noopener noreferrer">{{t('help')}}</a>
<button type="button" class="btn btn-secondary" (click)="close()">{{t('cancel')}}</button>
<button type="button" class="btn btn-primary" (click)="share()">{{t('share')}}</button>
</div>
</ng-container>

View File

@ -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);

View File

@ -1,33 +1,36 @@
<ng-container *transloco="let t; read:'library-access-modal'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Library Access</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
</div>
<div class="modal-body">
<div class="list-group">
<div class="form-check">
<input id="selectall" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
</div>
<ul>
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" class="form-check-input" attr.aria-label="Library {{library.name}}"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label attr.for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div>
</li>
<li class="list-group-item" *ngIf="allLibraries.length === 0">
There are no libraries setup yet.
</li>
</ul>
<div class="form-check">
<input id="select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
</div>
<ul>
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div>
</li>
<li class="list-group-item" *ngIf="allLibraries.length === 0">
{{t('no-data')}}
</li>
</ul>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" (click)="reset()">Reset</button>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="button" class="btn btn-primary" (click)="save()">Save</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" (click)="reset()">{{t('reset')}}</button>
<button type="button" class="btn btn-secondary" (click)="close()">{{t('cancel')}}</button>
<button type="button" class="btn btn-primary" (click)="save()">{{t('save')}}</button>
</div>
</ng-container>

View File

@ -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 {

View File

@ -1,21 +1,23 @@
<form [formGroup]="resetPasswordForm">
<ng-container *transloco="let t; read:'reset-password-modal'">
<form [formGroup]="resetPasswordForm">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Reset {{member.username | sentenceCase}}'s Password</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
<h4 class="modal-title" id="modal-basic-title">{{t('title', {username: member.username | sentenceCase})}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
</button>
</div>
<div class="modal-body">
<div class="alert alert-info" *ngIf="errorMessage !== ''">
<strong>Error: </strong> {{errorMessage}}
</div>
<div class="mb-3">
<label for="password" class="form-label">New Password</label>
<input id="password" class="form-control" minlength="4" formControlName="password" type="password">
</div>
<div class="alert alert-info" *ngIf="errorMessage !== ''">
<strong>{{t('error-label')}}</strong> {{errorMessage}}
</div>
<div class="mb-3">
<label for="password" class="form-label">{{t('new-password-label')}}</label>
<input id="password" class="form-control" minlength="4" formControlName="password" type="password">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="submit" class="btn btn-primary" [disabled]="resetPasswordForm.value.password.length === 0" (click)="save()">Save</button>
<button type="button" class="btn btn-secondary" (click)="close()">{{t('cancel')}}</button>
<button type="submit" class="btn btn-primary" [disabled]="resetPasswordForm.value.password.length === 0" (click)="save()">{{t('save')}}</button>
</div>
</form>
</form>
</ng-container>

View File

@ -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 {

View File

@ -1,12 +1,13 @@
<app-side-nav-companion-bar>
<h2 title>
Admin Dashboard
</h2>
</app-side-nav-companion-bar>
<div class="container-fluid g-0">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
<ng-container *transloco="let t; read: 'admin-dashboard'">
<app-side-nav-companion-bar>
<h2 title>
{{t('title')}}
</h2>
</app-side-nav-companion-bar>
<div class="container-fluid g-0">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab" class=tab>
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ t(tab.title) }}</a>
<ng-template ngbNavContent>
<ng-container *ngIf="tab.fragment === TabID.General">
<app-manage-settings></app-manage-settings>
@ -18,10 +19,10 @@
<app-manage-media-settings></app-manage-media-settings>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Users">
<app-manage-users></app-manage-users>
<app-manage-users></app-manage-users>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Libraries">
<app-manage-library></app-manage-library>
<app-manage-library></app-manage-library>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Logs">
<app-manage-logs></app-manage-logs>
@ -36,12 +37,14 @@
<app-manage-tasks-settings></app-manage-tasks-settings>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.KavitaPlus">
<p>Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">premium benefits</a> today! <a href="https://wiki.kavitareader.com/en/kavita-plus#faq" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
<p>{{t('kavita+-desc-part-1')}} <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}} <a href="https://wiki.kavitareader.com/en/kavita-plus#faq" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
<app-license></app-license>
</ng-container>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3 mb-3"></div>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3 mb-3"></div>
</div>
</div>
</ng-container>

View File

@ -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'));
}
}

View File

@ -1,68 +1,70 @@
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{member.username | sentenceCase}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
<ng-container *transloco="let t; read: 'edit-user'">
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member.username | sentenceCase}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
</button>
</div>
<div class="modal-body scrollable-modal">
</button>
</div>
<div class="modal-body scrollable-modal">
<form [formGroup]="userForm">
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" class="form-control" formControlName="username" type="text"
[class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched" aria-describedby="username-validations">
<div id="username-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
<div *ngIf="userForm.get('username')?.errors?.required">
This field is required
<form [formGroup]="userForm">
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2">
<div class="mb-3">
<label for="username" class="form-label">{{t('username')}}</label>
<input id="username" class="form-control" formControlName="username" type="text"
[class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched" aria-describedby="username-validations">
<div id="username-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
<div *ngIf="userForm.get('username')?.errors?.required">
{{t('required')}}
</div>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">{{t('email')}}</label>
<input class="form-control" inputmode="email" type="email" id="email" formControlName="email" aria-describedby="email-validations">
<div id="email-validations" class="invalid-feedback"
*ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched">
<div *ngIf="userForm.get('email')?.errors?.required">
{{t('required')}}
</div>
<div *ngIf="userForm.get('email')?.errors?.email">
{{t('not-valid-email')}}
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" inputmode="email" type="email" id="email" formControlName="email" aria-describedby="email-validations">
<div id="email-validations" class="invalid-feedback"
*ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched">
<div *ngIf="userForm.get('email')?.errors?.required">
This field is required
</div>
<div *ngIf="userForm.get('email')?.errors?.email">
This must be a valid email address
</div>
</div>
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
<div class="row g-0">
<div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
</div>
</div>
</form>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
</div>
</div>
<div class="row g-0">
<div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">
{{t('cancel')}}
</button>
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
<span *ngIf="isSaving" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSaving ? t('saving') : t('update')}}</span>
</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">
Cancel
</button>
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
<span *ngIf="isSaving" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSaving ? 'Saving...' : 'Update'}}</span>
</button>
</div>
</div>
</ng-container>

View File

@ -10,13 +10,14 @@ import { RestrictionSelectorComponent } from '../../user-settings/restriction-se
import { LibrarySelectorComponent } from '../library-selector/library-selector.component';
import { RoleSelectorComponent } from '../role-selector/role-selector.component';
import { NgIf } from '@angular/common';
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-edit-user',
templateUrl: './edit-user.component.html',
styleUrls: ['./edit-user.component.scss'],
standalone: true,
imports: [ReactiveFormsModule, NgIf, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe]
imports: [ReactiveFormsModule, NgIf, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoModule]
})
export class EditUserComponent implements OnInit {

View File

@ -1,61 +1,61 @@
<div class="modal-container">
<ng-container *transloco="let t; read: 'invite-user'">
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Invite User</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal">
<p>
Invite a user to your server. Enter their email in and we will send them an email to create an account. If you do not want to use our email service, you can <a href="https://wiki.kavitareader.com/en/guides/misc/email" rel="noopener noreferrer" target="_blank">host your own</a>
email service or use a fake email (Forgot User will not work). A link will be presented regardless and can be used to setup the account manually.
<p>
{{t('description')}}
</p>
<form [formGroup]="inviteForm" *ngIf="emailLink === ''">
<div class="row g-0">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">{{t('email')}}</label>
<input class="form-control" type="email" inputmode="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
<div *ngIf="email?.errors?.required">
{{t('required-field')}}
</div>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
</div>
</div>
<div class="row g-0">
<div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected"></app-restriction-selector>
</div>
</div>
</form>
<ng-container *ngIf="emailLink !== ''">
<h4>{{t('setup-user-title')}}</h4>
<p>{{t('setup-user-description')}}
</p>
<form [formGroup]="inviteForm" *ngIf="emailLink === ''">
<div class="row g-0">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" inputmode="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
<div *ngIf="email?.errors?.required">
This field is required
</div>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
</div>
</div>
<div class="row g-0">
<div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected"></app-restriction-selector>
</div>
</div>
</form>
<ng-container *ngIf="emailLink !== ''">
<h4>User invited</h4>
<p>You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user.
If your server is externally accessible, an email will have been sent to the user and the links can be used by them to finish setting up their account.
</p>
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">Setup user's account</a>
<app-api-key title="Invite Url" tooltipText="Copy this and paste in a new tab. You may need to log out." [showRefresh]="false" [transform]="makeLink"></app-api-key>
</ng-container>
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">{{t('setup-user-account')}}</a>
<app-api-key [title]="t('invite-url-label')" [tooltipText]="t('setup-user-account-tooltip')" [showRefresh]="false" [transform]="makeLink"></app-api-key>
</ng-container>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">
Cancel
</button>
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || emailLink !== ''">
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSending ? 'Inviting...' : 'Invite'}}</span>
</button>
<button type="button" class="btn btn-secondary" (click)="close()">
{{t('cancel')}}
</button>
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || emailLink !== ''">
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSending ? t('inviting') : t('invite')}}</span>
</button>
</div>
</div>
</div>
</ng-container>

View File

@ -12,13 +12,14 @@ import { RestrictionSelectorComponent } from '../../user-settings/restriction-se
import { LibrarySelectorComponent } from '../library-selector/library-selector.component';
import { RoleSelectorComponent } from '../role-selector/role-selector.component';
import { NgIf } from '@angular/common';
import {translate, TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-invite-user',
templateUrl: './invite-user.component.html',
styleUrls: ['./invite-user.component.scss'],
standalone: true,
imports: [NgIf, ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, ApiKeyComponent]
imports: [NgIf, ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, ApiKeyComponent, TranslocoModule]
})
export class InviteUserComponent implements OnInit {
@ -61,7 +62,7 @@ export class InviteUserComponent implements OnInit {
this.emailLink = data.emailLink;
this.isSending = false;
if (data.emailSent) {
this.toastr.info('Email sent to ' + email);
this.toastr.info(translate('toasts.email-sent', {email: email}));
this.modal.close(true);
}
}, err => {

View File

@ -1,20 +1,22 @@
<h4>Libraries</h4>
<div class="list-group" *ngIf="!isLoading">
<ng-container *transloco="let t; read: 'library-selector'">
<h4>{{t('title')}}</h4>
<div class="list-group" *ngIf="!isLoading">
<div class="form-check" *ngIf="allLibraries.length > 0">
<input id="selectall" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
<input id="select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}} All</label>
</div>
<ul>
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" class="form-check-input" attr.aria-label="Library {{library.name}}"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label attr.for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div>
</li>
<li class="list-group-item" *ngIf="allLibraries.length === 0">
There are no libraries setup yet.
</li>
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div>
</li>
<li class="list-group-item" *ngIf="allLibraries.length === 0">
{{t('no-data')}}
</li>
</ul>
</div>
</div>
</ng-container>

View File

@ -5,13 +5,14 @@ import { Member } from 'src/app/_models/auth/member';
import { LibraryService } from 'src/app/_services/library.service';
import { SelectionModel } from 'src/app/typeahead/_components/typeahead.component';
import { NgIf, NgFor } from '@angular/common';
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-library-selector',
templateUrl: './library-selector.component.html',
styleUrls: ['./library-selector.component.scss'],
standalone: true,
imports: [NgIf, ReactiveFormsModule, FormsModule, NgFor]
imports: [NgIf, ReactiveFormsModule, FormsModule, NgFor, TranslocoModule]
})
export class LibrarySelectorComponent implements OnInit {
@ -41,7 +42,7 @@ export class LibrarySelectorComponent implements OnInit {
setupSelections() {
this.selections = new SelectionModel<Library>(false, this.allLibraries);
this.isLoading = false;
// If a member is passed in, then auto-select their libraries
if (this.member !== undefined) {
this.member.libraries.forEach(lib => {

View File

@ -1,85 +1,88 @@
<div class="card mt-2">
<div class="card-body">
<div class="card-title">
<div class="container-fluid row mb-2">
<div class="col-10 col-sm-10">
<h4 id="license-key-header">Kavita+ License</h4>
</div>
<div class="col-2 text-end">
<ng-container *ngIf="hasLicense; else noLicense">
<ng-container *ngIf="hasValidLicense; else invalidLicenseBuy">
<a class="btn btn-primary btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">Manage</a>
<ng-container *transloco="let t; read: 'license'">
<div class="card mt-2">
<div class="card-body">
<div class="card-title">
<div class="container-fluid row mb-2">
<div class="col-10 col-sm-10">
<h4 id="license-key-header">{{t('title')}}</h4>
</div>
<div class="col-2 text-end">
<ng-container *ngIf="hasLicense; else noLicense">
<ng-container *ngIf="hasValidLicense; else invalidLicenseBuy">
<a class="btn btn-primary btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
</ng-container>
<ng-template #invalidLicenseBuy>
<a class="btn btn-primary btn-sm me-1"
[ngbTooltip]="t('invalid-license-tooltip')"
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
>{{t('renew')}}</a>
</ng-template>
<button class="btn btn-secondary btn-sm me-1" style="width: 58px" (click)="validateLicense()">
<span *ngIf="!isChecking">{{t('check')}}</span>
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
</button>
<button class="btn btn-secondary btn-sm" style="width: 62px" (click)="toggleViewMode()">
<span *ngIf="!isViewMode">{{t('cancel')}}</span>
<span *ngIf="isViewMode">{{t('edit')}}</span>
</button>
</ng-container>
<ng-template #invalidLicenseBuy>
<a class="btn btn-primary btn-sm me-1"
ngbTooltip="If your subscription has ended, you must email support to get a new subscription created"
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
>Renew</a>
<ng-template #noLicense>
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">{{t('buy')}}</a>
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? t('activate') : t('cancel')}}</button>
</ng-template>
<button class="btn btn-secondary btn-sm me-1" style="width: 58px" (click)="validateLicense()">
<span *ngIf="!isChecking">Check</span>
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
</button>
<button class="btn btn-secondary btn-sm" style="width: 62px" (click)="toggleViewMode()">
<span *ngIf="!isViewMode">Cancel</span>
<span *ngIf="isViewMode">Edit</span>
</button>
</ng-container>
<ng-template #noLicense>
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">Buy</a>
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? 'Activate' : 'Cancel'}}</button>
</ng-template>
</div>
</div>
</div>
</div>
<ng-container *ngIf="isViewMode">
<div class="container-fluid row">
<ng-container *ngIf="isViewMode">
<div class="container-fluid row">
<span class="col-12">
<ng-container *ngIf="hasLicense; else noToken">
<span class="me-1">*********</span>
<ng-container *ngIf="!isChecking; else checking">
<i *ngIf="hasValidLicense" ngbTooltip="License is valid" class="fa-solid fa-check-circle successful-validation ms-1">
<span class="visually-hidden">License is Valid</span>
<i *ngIf="hasValidLicense" [ngbTooltip]="t('license-valid')" class="fa-solid fa-check-circle successful-validation ms-1">
<span class="visually-hidden">{{t('license-valid')}}</span>
</i>
<i class="error fa-solid fa-exclamation-circle ms-1" ngbTooltip="License Invalid" *ngIf="!hasValidLicense">
<span class="visually-hidden">License Not Valid</span>
<i class="error fa-solid fa-exclamation-circle ms-1" [ngbTooltip]="t('license-not-valid')" *ngIf="!hasValidLicense">
<span class="visually-hidden">{{t('license-not-valid')}}</span>
</i>
</ng-container>
<ng-template #checking>
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Loading...</span>
<span class="visually-hidden">{{t('loading')}}</span>
</div>
</ng-template>
</ng-container>
<ng-template #noToken>No license key</ng-template>
<ng-template #noToken>{{t('no-license-key')}}</ng-template>
</span>
</div>
</ng-container>
</div>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<form [formGroup]="formGroup">
<p>Enter the License Key and Email used to register with Stripe</p>
<div class="form-group mb-3">
<label for="license-key">License Key</label>
<input id="license-key" type="text" class="form-control" formControlName="licenseKey" autocomplete="off"/>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<form [formGroup]="formGroup">
<p>{{t('activate-description')}}</p>
<div class="form-group mb-3">
<label for="license-key">{{t('activate-license-label')}}</label>
<input id="license-key" type="text" class="form-control" formControlName="licenseKey" autocomplete="off"/>
</div>
<div class="form-group mb-3">
<label for="email">{{t('activate-email-label')}}</label>
<input id="email" type="email" class="form-control" formControlName="email" autocomplete="off"/>
</div>
</form>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header" (click)="deleteLicense()">
{{t('activate-delete')}}
</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header" [disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
<span *ngIf="!isSaving">{{t('activate-save')}}</span>
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading>
</button>
</div>
<div class="form-group mb-3">
<label for="email">Email</label>
<input id="email" type="email" class="form-control" formControlName="email" autocomplete="off"/>
</div>
</form>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header" (click)="deleteLicense()">
Delete
</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header" [disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
<span *ngIf="!isSaving">Save</span>
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading>
</button>
</div>
</div>
</div>
</div>
</ng-container>

View File

@ -15,6 +15,7 @@ import { LoadingComponent } from '../../shared/loading/loading.component';
import { NgbTooltip, NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
import { NgIf } from '@angular/common';
import {environment} from "../../../environments/environment";
import {translate, TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-license',
@ -22,7 +23,7 @@ import {environment} from "../../../environments/environment";
styleUrls: ['./license.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgbTooltip, LoadingComponent, NgbCollapse, ReactiveFormsModule]
imports: [NgIf, NgbTooltip, LoadingComponent, NgbCollapse, ReactiveFormsModule, TranslocoModule]
})
export class LicenseComponent implements OnInit {
@ -71,9 +72,9 @@ export class LicenseComponent implements OnInit {
this.accountService.hasValidLicense(true).subscribe(isValid => {
this.hasValidLicense = isValid;
if (!this.hasValidLicense) {
this.toastr.info("License Key saved, but it is not valid. Click check to revalidate the subscription. First time registration may take a min to propagate.");
this.toastr.info(translate('toasts.k+-license-saved'));
} else {
this.toastr.success('Kavita+ unlocked!');
this.toastr.success(translate('toasts.k+-unlocked'));
}
this.hasLicense = this.formGroup.get('licenseKey')!.value.length > 0;
this.resetForm();
@ -85,7 +86,7 @@ export class LicenseComponent implements OnInit {
if (err.hasOwnProperty('error')) {
this.toastr.error(JSON.parse(err['error'])['message']);
} else {
this.toastr.error("There was an error when activating your license. Please try again.");
this.toastr.error(translate('toasts.k+-error'));
}
this.isSaving = false;
this.cdRef.markForCheck();
@ -93,7 +94,7 @@ export class LicenseComponent implements OnInit {
}
async deleteLicense() {
if (!await this.confirmService.confirm('This will only delete Kavita\'s license key and allow a buy link to show. This will not cancel your subscription! Use this only if directed by support!')) {
if (!await this.confirmService.confirm(translate('k+-delete-key'))) {
return;
}

View File

@ -1,53 +1,54 @@
<p>This table contains issues found during scan or reading of your media. This list is non-managed.
You can clear it at any time and use Library (Force) Scan to perform analysis. A list of some common errors and what
they mean can be found on the <a rel="noopener noreferrer" target="_blank" href="https://wiki.kavitareader.com/en/guides/managing-your-files/scanner#media-errors">wiki</a>.</p>
<ng-container *transloco="let t; read: 'manage-alerts'">
<p>{{t('description-part-1')}} <a rel="noopener noreferrer" target="_blank" href="https://wiki.kavitareader.com/en/guides/managing-your-files/scanner#media-errors">{{t('description-part-2')}}</a></p>
<form [formGroup]="formGroup">
<form [formGroup]="formGroup">
<div class="row g-0 mb-3">
<div class="col-md-12">
<label for="filter" class="visually-hidden">Filter</label>
<div class="input-group">
<input id="filter" type="text" class="form-control" placeholder="Filter" formControlName="filter" />
<button class="btn btn-primary" type="button" (click)="clear()">Clear Alerts</button>
</div>
<div class="col-md-12">
<label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
<div class="input-group">
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
<button class="btn btn-primary" type="button" (click)="clear()">{{t('clear-alerts')}}</button>
</div>
</div>
</div>
</form>
<table class="table table-striped table-hover table-sm table-hover">
</form>
<table class="table table-striped table-hover table-sm table-hover">
<thead #header>
<tr>
<th scope="col"sortable="extension" (sort)="onSort($event)">
Extension
</th>
<th scope="col" sortable="filePath" (sort)="onSort($event)">
File
</th>
<th scope="col" sortable="comment" (sort)="onSort($event)">
Comment
</th>
<th scope="col" sortable="details" (sort)="onSort($event)">
Details
</th>
</tr>
<tr>
<th scope="col" sortable="extension" (sort)="onSort($event)">
{{t('extension-header')}}
</th>
<th scope="col" sortable="filePath" (sort)="onSort($event)">
{{t('file-header')}}
</th>
<th scope="col" sortable="comment" (sort)="onSort($event)">
{{t('comment-header')}}
</th>
<th scope="col" sortable="details" (sort)="onSort($event)">
{{t('details-header')}}
</th>
</tr>
</thead>
<tbody #container>
<tr *ngIf="isLoading"><td colspan="4" style="text-align: center;"><app-loading [loading]="isLoading"></app-loading></td></tr>
<ng-container *ngIf="data | filter: filterList as filteredData">
<tr *ngIf="filteredData.length === 0 && !isLoading"><td colspan="4" style="text-align: center;">No issues</td></tr>
<tr *ngFor="let item of filteredData; index as i">
<td>
{{item.extension}}
</td>
<td>
{{item.filePath}}
</td>
<td>
{{item.comment}}
</td>
<td>
{{item.details}}
</td>
</tr>
</ng-container>
<tr *ngIf="isLoading"><td colspan="4" style="text-align: center;"><app-loading [loading]="isLoading"></app-loading></td></tr>
<ng-container *ngIf="data | filter: filterList as filteredData">
<tr *ngIf="filteredData.length === 0 && !isLoading"><td colspan="4" style="text-align: center;">No issues</td></tr>
<tr *ngFor="let item of filteredData; index as i">
<td>
{{item.extension}}
</td>
<td>
{{item.filePath}}
</td>
<td>
{{item.comment}}
</td>
<td>
{{item.details}}
</td>
</tr>
</ng-container>
</tbody>
</table>
</table>
</ng-container>

View File

@ -20,6 +20,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { FilterPipe } from '../../pipe/filter.pipe';
import { LoadingComponent } from '../../shared/loading/loading.component';
import { NgIf, NgFor } from '@angular/common';
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-manage-alerts',
@ -27,7 +28,7 @@ import { NgIf, NgFor } from '@angular/common';
styleUrls: ['./manage-alerts.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReactiveFormsModule, NgIf, LoadingComponent, NgFor, FilterPipe, SortableHeader]
imports: [ReactiveFormsModule, NgIf, LoadingComponent, NgFor, FilterPipe, SortableHeader, TranslocoModule]
})
export class ManageAlertsComponent implements OnInit {

View File

@ -1,45 +1,46 @@
<div class="container-fluid">
<ng-container *transloco="let t; read: 'manage-email-settings'">
<div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<h4>Email Services (SMTP)</h4>
<p>Kavita comes out of the box with an email service to power tasks like inviting users, password reset requests, etc. Emails sent via our service are deleted immediately. You can use your own
email service by setting up the <a href="https://github.com/Kareadita/KavitaEmail" target="_blank" rel="noopener noreferrer">Kavita Email</a> service. Set the URL of the email service and use the Test button to ensure it works.
You can reset these settings to default at any time. There is no way to disable emails for authentication, although you are not required to use a
valid email address for users. Confirmation links will always be saved to logs and presented in the UI.
Registration/confirmation emails will not be sent if you are not accessing Kavita via a publicly reachable URL or unless the Host Name feature is configured.
<span class="text-warning">If you want Send to Device to work you must host your own email service.</span>
</p>
<div class="mb-3">
<label for="settings-emailservice" class="form-label">Email Service URL</label><i class="ms-1 fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>
<ng-template #emailServiceTooltip>Use fully qualified URL of the email service. Do not include ending slash.</ng-template>
<span class="visually-hidden" id="settings-emailservice-help"><ng-container [ngTemplateOutlet]="emailServiceTooltip"></ng-container></span>
<div class="input-group">
<input id="settings-emailservice" aria-describedby="settings-emailservice-help" class="form-control" formControlName="emailServiceUrl" type="url" autocapitalize="off" inputmode="url">
<button class="btn btn-outline-secondary" (click)="resetEmailServiceUrl()">
Reset
</button>
<button class="btn btn-outline-secondary" (click)="testEmailServiceUrl()">
Test
</button>
</div>
<h4>{{t('title')}}</h4>
<p [innerHTML]="t('description', {link: link}) | safeHtml">
<span class="text-warning">{{t('send-to-warning')}}</span>
</p>
<div class="mb-3">
<label for="settings-emailservice" class="form-label">{{t('email-url-label')}}</label><i class="ms-1 fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>
<ng-template #emailServiceTooltip>{{t('email-url-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-emailservice-help"><ng-container [ngTemplateOutlet]="emailServiceTooltip"></ng-container></span>
<div class="input-group">
<input id="settings-emailservice" aria-describedby="settings-emailservice-help" class="form-control" formControlName="emailServiceUrl" type="url" autocapitalize="off" inputmode="url">
<button class="btn btn-outline-secondary" (click)="resetEmailServiceUrl()">
{{t('reset')}}
</button>
<button class="btn btn-outline-secondary" (click)="testEmailServiceUrl()">
{{t('test')}}
</button>
</div>
</div>
<div class="mb-3">
<label for="settings-hostname" class="form-label">Host Name</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="hostNameTooltip" role="button" tabindex="0"></i>
<ng-template #hostNameTooltip>Domain Name (of Reverse Proxy). If set, email generation will always use this.</ng-template>
<span class="visually-hidden" id="settings-hostname-help">Domain Name (of Reverse Proxy). If set, email generation will always use this.</span>
<input id="settings-hostname" aria-describedby="settings-hostname-help" class="form-control" formControlName="hostName" type="text"
[class.is-invalid]="settingsForm.get('hostName')?.invalid && settingsForm.get('hostName')?.touched">
<div id="hostname-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
<div *ngIf="settingsForm.get('hostName')?.errors?.pattern">
Host name must start with http(s) and not end in /
</div>
</div>
<div class="mb-3">
<label for="settings-hostname" class="form-label">{{t('host-name-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="hostNameTooltip" role="button" tabindex="0"></i>
<ng-template #hostNameTooltip>{{t('host-name-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-hostname-help">
<ng-container [ngTemplateOutlet]="hostNameTooltip"></ng-container>
</span>
<input id="settings-hostname" aria-describedby="settings-hostname-help" class="form-control" formControlName="hostName" type="text"
[class.is-invalid]="settingsForm.get('hostName')?.invalid && settingsForm.get('hostName')?.touched">
<div id="hostname-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
<div *ngIf="settingsForm.get('hostName')?.errors?.pattern">
{{t('host-name-validation')}}
</div>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">Reset to Default</button>
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">Save</button>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">{{t('reset')}}</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
</div>
</form>
</div>
</div>
</ng-container>

Some files were not shown because too many files have changed in this diff Show More