diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs index 82d9e51e7..8b93c5f90 100644 --- a/API.Tests/Parsing/MangaParsingTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -71,6 +71,7 @@ public class MangaParsingTests [InlineData("죽음 13회", "13")] [InlineData("동의보감 13장", "13")] [InlineData("몰?루 아카이브 7.5권", "7.5")] + [InlineData("주술회전 1.5권", "1.5")] [InlineData("63권#200", "63")] [InlineData("시즌34삽화2", "34")] [InlineData("Accel World Chapter 001 Volume 002", "2")] diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index 962500ec7..e1d7da9e8 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -50,7 +50,7 @@ public class BookController : BaseApiController case MangaFormat.Epub: { var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0]; - using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions); bookTitle = book.Title; break; } @@ -102,7 +102,7 @@ public class BookController : BaseApiController var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); if (chapter == null) return BadRequest(await _localizationService.Get("en", "chapter-doesnt-exist")); - using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.LenientBookReaderOptions); var key = BookService.CoalesceKeyForAnyFile(book, file); if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest(await _localizationService.Get("en", "file-missing")); diff --git a/API/Controllers/FilterController.cs b/API/Controllers/FilterController.cs index 90772c9aa..7fcffb7da 100644 --- a/API/Controllers/FilterController.cs +++ b/API/Controllers/FilterController.cs @@ -12,6 +12,7 @@ using API.Extensions; using API.Helpers; using API.Services; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace API.Controllers; @@ -24,11 +25,16 @@ public class FilterController : BaseApiController { private readonly IUnitOfWork _unitOfWork; private readonly ILocalizationService _localizationService; + private readonly IStreamService _streamService; + private readonly ILogger _logger; - public FilterController(IUnitOfWork unitOfWork, ILocalizationService localizationService) + public FilterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IStreamService streamService, + ILogger logger) { _unitOfWork = unitOfWork; _localizationService = localizationService; + _streamService = streamService; + _logger = logger; } /// @@ -120,4 +126,57 @@ public class FilterController : BaseApiController { return Ok(SmartFilterHelper.Decode(dto.EncodedFilter)); } + + /// + /// Rename a Smart Filter given the filterId and new name + /// + /// + /// + /// + [HttpPost("rename")] + public async Task RenameFilter([FromQuery] int filterId, [FromQuery] string name) + { + try + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), + AppUserIncludes.SmartFilters); + if (user == null) return Unauthorized(); + + name = name.Trim(); + + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) + { + return BadRequest(await _localizationService.Translate(user.Id, "permission-denied")); + } + + if (string.IsNullOrWhiteSpace(name)) + { + return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-name-required")); + } + + if (Seed.DefaultStreams.Any(s => s.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase))) + { + return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-system-name")); + } + + var filter = user.SmartFilters.FirstOrDefault(f => f.Id == filterId); + if (filter == null) + { + return BadRequest(await _localizationService.Translate(user.Id, "filter-not-found")); + } + + filter.Name = name; + _unitOfWork.AppUserSmartFilterRepository.Update(filter); + await _unitOfWork.CommitAsync(); + + await _streamService.RenameSmartFilterStreams(filter); + return Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when renaming smart filter: {FilterId}", filterId); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); + } + + } } diff --git a/API/Controllers/StreamController.cs b/API/Controllers/StreamController.cs index 7fb6d6ebb..049885e78 100644 --- a/API/Controllers/StreamController.cs +++ b/API/Controllers/StreamController.cs @@ -204,4 +204,30 @@ public class StreamController : BaseApiController await _streamService.UpdateSideNavStreamBulk(User.GetUserId(), dto); return Ok(); } + + /// + /// Removes a Smart Filter from a user's SideNav Streams + /// + /// + /// + [HttpDelete("smart-filter-side-nav-stream")] + public async Task DeleteSmartFilterSideNavStream([FromQuery] int sideNavStreamId) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + await _streamService.DeleteSideNavSmartFilterStream(User.GetUserId(), sideNavStreamId); + return Ok(); + } + + /// + /// Removes a Smart Filter from a user's Dashboard Streams + /// + /// + /// + [HttpDelete("smart-filter-dashboard-stream")] + public async Task DeleteSmartFilterDashboardStream([FromQuery] int dashboardStreamId) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + await _streamService.DeleteDashboardSmartFilterStream(User.GetUserId(), dashboardStreamId); + return Ok(); + } } diff --git a/API/Data/Repositories/MediaErrorRepository.cs b/API/Data/Repositories/MediaErrorRepository.cs index 0d3cae2ed..40501768e 100644 --- a/API/Data/Repositories/MediaErrorRepository.cs +++ b/API/Data/Repositories/MediaErrorRepository.cs @@ -15,10 +15,12 @@ public interface IMediaErrorRepository { void Attach(MediaError error); void Remove(MediaError error); + void Remove(IList errors); Task Find(string filename); IEnumerable GetAllErrorDtosAsync(); Task ExistsAsync(MediaError error); Task DeleteAll(); + Task> GetAllErrorsAsync(IList comments); } public class MediaErrorRepository : IMediaErrorRepository @@ -44,6 +46,11 @@ public class MediaErrorRepository : IMediaErrorRepository _context.MediaError.Remove(error); } + public void Remove(IList errors) + { + _context.MediaError.RemoveRange(errors); + } + public Task Find(string filename) { return _context.MediaError.Where(e => e.FilePath == filename).SingleOrDefaultAsync(); @@ -71,4 +78,11 @@ public class MediaErrorRepository : IMediaErrorRepository _context.MediaError.RemoveRange(await _context.MediaError.ToListAsync()); await _context.SaveChangesAsync(); } + + public Task> GetAllErrorsAsync(IList comments) + { + return _context.MediaError + .Where(m => comments.Contains(m.Comment)) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 3a46ad2a1..ef790f29e 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -57,7 +57,9 @@ public interface IUserRepository void Delete(AppUser? user); void Delete(AppUserBookmark bookmark); void Delete(IEnumerable streams); + void Delete(AppUserDashboardStream stream); void Delete(IEnumerable streams); + void Delete(AppUserSideNavStream stream); Task> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true); Task> GetAdminUsersAsync(); Task IsUserAdminAsync(AppUser? user); @@ -95,6 +97,7 @@ public interface IUserRepository Task> GetDashboardStreamWithFilter(int filterId); Task> GetSideNavStreams(int userId, bool visibleOnly = false); Task GetSideNavStream(int streamId); + Task GetSideNavStreamWithUser(int streamId); Task> GetSideNavStreamWithFilter(int filterId); Task> GetSideNavStreamsByLibraryId(int libraryId); Task> GetSideNavStreamWithExternalSource(int externalSourceId); @@ -167,11 +170,21 @@ public class UserRepository : IUserRepository _context.AppUserDashboardStream.RemoveRange(streams); } + public void Delete(AppUserDashboardStream stream) + { + _context.AppUserDashboardStream.Remove(stream); + } + public void Delete(IEnumerable streams) { _context.AppUserSideNavStream.RemoveRange(streams); } + public void Delete(AppUserSideNavStream stream) + { + _context.AppUserSideNavStream.Remove(stream); + } + /// /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. /// @@ -396,6 +409,7 @@ public class UserRepository : IUserRepository .FirstOrDefaultAsync(d => d.Id == streamId); } + public async Task> GetDashboardStreamWithFilter(int filterId) { return await _context.AppUserDashboardStream @@ -432,10 +446,10 @@ public class UserRepository : IUserRepository .Select(d => d.LibraryId) .ToList(); - var libraryDtos = _context.Library + var libraryDtos = await _context.Library .Where(l => libraryIds.Contains(l.Id)) .ProjectTo(_mapper.ConfigurationProvider) - .ToList(); + .ToListAsync(); foreach (var dto in sideNavStreams.Where(dto => dto.StreamType == SideNavStreamType.Library)) { @@ -459,13 +473,21 @@ public class UserRepository : IUserRepository return sideNavStreams; } - public async Task GetSideNavStream(int streamId) + public async Task GetSideNavStream(int streamId) { return await _context.AppUserSideNavStream .Include(d => d.SmartFilter) .FirstOrDefaultAsync(d => d.Id == streamId); } + public async Task GetSideNavStreamWithUser(int streamId) + { + return await _context.AppUserSideNavStream + .Include(d => d.SmartFilter) + .Include(d => d.AppUser) + .FirstOrDefaultAsync(d => d.Id == streamId); + } + public async Task> GetSideNavStreamWithFilter(int filterId) { return await _context.AppUserSideNavStream diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index 046c07efa..46e7241f5 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -121,6 +121,8 @@ public class SeriesMetadata : IHasConcurrencyToken /// public bool AllKavitaPlus(PersonRole role) { - return People.Where(p => p.Role == role).All(p => p.KavitaPlusConnection); + var people = People.Where(p => p.Role == role); + if (people.Any()) return people.All(p => p.KavitaPlusConnection); + return false; } } diff --git a/API/Helpers/Builders/MediaErrorBuilder.cs b/API/Helpers/Builders/MediaErrorBuilder.cs index 56b19ba33..4d0f7f3a0 100644 --- a/API/Helpers/Builders/MediaErrorBuilder.cs +++ b/API/Helpers/Builders/MediaErrorBuilder.cs @@ -1,5 +1,6 @@ using System.IO; using API.Entities; +using API.Services.Tasks.Scanner.Parser; namespace API.Helpers.Builders; @@ -12,7 +13,7 @@ public class MediaErrorBuilder : IEntityBuilder { _mediaError = new MediaError() { - FilePath = filePath, + FilePath = Parser.NormalizePath(filePath), Extension = Path.GetExtension(filePath).Replace(".", string.Empty).ToUpperInvariant() }; } diff --git a/API/Helpers/JwtHelper.cs b/API/Helpers/JwtHelper.cs index 4cdf4048d..0f9219804 100644 --- a/API/Helpers/JwtHelper.cs +++ b/API/Helpers/JwtHelper.cs @@ -18,14 +18,18 @@ public static class JwtHelper // Parse the JWT and extract the expiry claim var jwtHandler = new JwtSecurityTokenHandler(); var token = jwtHandler.ReadJwtToken(jwtToken); - var exp = token.Claims.FirstOrDefault(c => c.Type == "exp")?.Value; + return token.ValidTo; - if (long.TryParse(exp, CultureInfo.InvariantCulture, out var expSeconds)) - { - return DateTimeOffset.FromUnixTimeSeconds(expSeconds).UtcDateTime; - } - - return DateTime.MinValue; + // var exp = token.Claims.FirstOrDefault(c => c.Type == "exp")?.Value; + // + // if (long.TryParse(exp, CultureInfo.InvariantCulture, out var expSeconds)) + // { + // return DateTimeOffset.FromUnixTimeSeconds(expSeconds).UtcDateTime; + // } + // + // + // + // return DateTime.MinValue; } /// diff --git a/API/I18N/en.json b/API/I18N/en.json index bf2a79766..6e37a3cd9 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -186,6 +186,10 @@ "external-source-required": "ApiKey and Host required", "external-source-doesnt-exist": "External Source doesn't exist", "external-source-already-in-use": "There is an existing stream with this External Source", + "sidenav-stream-only-delete-smart-filter": "Only smart filter streams can be deleted from the SideNav", + "dashboard-stream-only-delete-smart-filter": "Only smart filter streams can be deleted from the dashboard", + "smart-filter-name-required": "Smart Filter name required", + "smart-filter-system-name": "You cannot use the name of a system provided stream", "not-authenticated": "User is not authenticated", "unable-to-register-k+": "Unable to register license due to error. Reach out to Kavita+ Support", diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index f8f1ef222..2eee99891 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -85,11 +85,32 @@ public class BookService : IBookService }, Epub2NcxReaderOptions = new Epub2NcxReaderOptions { - IgnoreMissingContentForNavigationPoints = true + IgnoreMissingContentForNavigationPoints = false }, SpineReaderOptions = new SpineReaderOptions { - IgnoreMissingManifestItems = true + IgnoreMissingManifestItems = false + }, + BookCoverReaderOptions = new BookCoverReaderOptions + { + Epub2MetadataIgnoreMissingManifestItem = false + } + }; + + public static readonly EpubReaderOptions LenientBookReaderOptions = new() + { + PackageReaderOptions = new PackageReaderOptions + { + IgnoreMissingToc = true, + SkipInvalidManifestItems = true, + }, + Epub2NcxReaderOptions = new Epub2NcxReaderOptions + { + IgnoreMissingContentForNavigationPoints = false + }, + SpineReaderOptions = new SpineReaderOptions + { + IgnoreMissingManifestItems = false }, BookCoverReaderOptions = new BookCoverReaderOptions { @@ -455,9 +476,12 @@ public class BookService : IBookService private ComicInfo? GetEpubComicInfo(string filePath) { + EpubBookRef? epubBook = null; + try { - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + epubBook = OpenEpubWithFallback(filePath, epubBook); + var publicationDate = epubBook.Schema.Package.Metadata.Dates.Find(pDate => pDate.Event == "publication")?.Date; @@ -465,6 +489,7 @@ public class BookService : IBookService { publicationDate = epubBook.Schema.Package.Metadata.Dates.FirstOrDefault()?.Date; } + var (year, month, day) = GetPublicationDate(publicationDate); var summary = epubBook.Schema.Package.Metadata.Descriptions.FirstOrDefault(); @@ -476,7 +501,8 @@ public class BookService : IBookService Day = day, Year = year, Title = epubBook.Title, - Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim())), + Genre = string.Join(",", + epubBook.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim())), LanguageISO = ValidateLanguage(epubBook.Schema.Package.Metadata.Languages .Select(l => l.Language) .FirstOrDefault()) @@ -487,7 +513,8 @@ public class BookService : IBookService foreach (var identifier in epubBook.Schema.Package.Metadata.Identifiers) { if (string.IsNullOrEmpty(identifier.Identifier)) continue; - if (!string.IsNullOrEmpty(identifier.Scheme) && identifier.Scheme.Equals("ISBN", StringComparison.InvariantCultureIgnoreCase)) + if (!string.IsNullOrEmpty(identifier.Scheme) && + identifier.Scheme.Equals("ISBN", StringComparison.InvariantCultureIgnoreCase)) { var isbn = identifier.Identifier.Replace("urn:isbn:", string.Empty).Replace("isbn:", string.Empty); if (!ArticleNumberHelper.IsValidIsbn10(isbn) && !ArticleNumberHelper.IsValidIsbn13(isbn)) @@ -495,11 +522,13 @@ public class BookService : IBookService _logger.LogDebug("[BookService] {File} has invalid ISBN number", filePath); continue; } + info.Isbn = isbn; } - if ((!string.IsNullOrEmpty(identifier.Scheme) && identifier.Scheme.Equals("URL", StringComparison.InvariantCultureIgnoreCase)) || - identifier.Identifier.StartsWith("url:")) + if ((!string.IsNullOrEmpty(identifier.Scheme) && + identifier.Scheme.Equals("URL", StringComparison.InvariantCultureIgnoreCase)) || + identifier.Identifier.StartsWith("url:")) { var url = identifier.Identifier.Replace("url:", string.Empty); weblinks.Add(url.Trim()); @@ -529,6 +558,7 @@ public class BookService : IBookService { info.SeriesSort = metadataItem.Content; } + break; case "calibre:series_index": info.Volume = metadataItem.Content; @@ -548,6 +578,7 @@ public class BookService : IBookService { info.SeriesSort = metadataItem.Content; } + break; case "collection-type": // These look to be genres from https://manual.calibre-ebook.com/sub_groups.html or can be "series" @@ -578,7 +609,8 @@ public class BookService : IBookService } // If this is a single book and not a collection, set publication status to Completed - if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) + if (string.IsNullOrEmpty(info.Volume) && + Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) { info.Count = 1; } @@ -590,7 +622,8 @@ public class BookService : IBookService var hasVolumeInSeries = !Parser.ParseVolume(info.Title, LibraryType.Manga) .Equals(Parser.LooseLeafVolume); - if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series))) + if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && + (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series))) { // This is likely a light novel for which we can set series from parsed title info.Series = Parser.ParseSeries(info.Title, LibraryType.Manga); @@ -601,14 +634,40 @@ public class BookService : IBookService } catch (Exception ex) { - _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata"); + _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata: {FilePath}", filePath); _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, "There was an exception parsing metadata", ex); } + finally + { + epubBook?.Dispose(); + } return null; } + private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook) + { + try + { + epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "[GetComicInfo] There was an exception parsing metadata, falling back to a more lenient parsing method: {FilePath}", + filePath); + _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, + "There was an exception parsing metadata", ex); + } + finally + { + epubBook ??= EpubReader.OpenBook(filePath, LenientBookReaderOptions); + } + + return epubBook; + } + public ComicInfo? GetComicInfo(string filePath) { if (!IsValidFile(filePath)) return null; @@ -765,7 +824,7 @@ public class BookService : IBookService return docReader.GetPageCount(); } - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + using var epubBook = EpubReader.OpenBook(filePath, LenientBookReaderOptions); return epubBook.GetReadingOrder().Count; } catch (Exception ex) @@ -823,7 +882,7 @@ public class BookService : IBookService try { - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + using var epubBook = EpubReader.OpenBook(filePath, LenientBookReaderOptions); // // @@ -1027,7 +1086,7 @@ public class BookService : IBookService /// public async Task> GenerateTableOfContents(Chapter chapter) { - using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions); var mappings = await CreateKeyToPageMappingAsync(book); var navItems = await book.GetNavigationAsync(); @@ -1155,7 +1214,7 @@ public class BookService : IBookService /// All exceptions throw this public async Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl) { - using var book = await EpubReader.OpenBookAsync(cachedEpubPath, BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions); var mappings = await CreateKeyToPageMappingAsync(book); var apiBase = baseUrl + "book/" + chapterId + "/" + BookApiUrl; @@ -1257,7 +1316,7 @@ public class BookService : IBookService return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, encodeFormat, size); } - using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions); + using var epubBook = EpubReader.OpenBook(fileFilePath, LenientBookReaderOptions); try { diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index 91f81813e..72b63e0e5 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using MimeKit; +using MimeTypes; namespace API.Services; #nullable enable @@ -355,9 +356,21 @@ public class EmailService : IEmailService if (userEmailOptions.Attachments != null) { - foreach (var attachment in userEmailOptions.Attachments) + foreach (var attachmentPath in userEmailOptions.Attachments) { - await body.Attachments.AddAsync(attachment); + var mimeType = MimeTypeMap.GetMimeType(attachmentPath) ?? "application/octet-stream"; + var mediaType = mimeType.Split('/')[0]; + var mediaSubtype = mimeType.Split('/')[1]; + + var attachment = new MimePart(mediaType, mediaSubtype) + { + Content = new MimeContent(File.OpenRead(attachmentPath)), + ContentDisposition = new ContentDisposition(ContentDisposition.Attachment), + ContentTransferEncoding = ContentEncoding.Base64, + FileName = Path.GetFileName(attachmentPath) + }; + + body.Attachments.Add(attachment); } } diff --git a/API/Services/LocalizationService.cs b/API/Services/LocalizationService.cs index 30384a757..7db35bb8e 100644 --- a/API/Services/LocalizationService.cs +++ b/API/Services/LocalizationService.cs @@ -271,7 +271,7 @@ public class LocalizationService : ILocalizationService // This could use a lookup table or follow a naming convention try { - var cultureInfo = new System.Globalization.CultureInfo(fileName); + var cultureInfo = new System.Globalization.CultureInfo(fileName.Replace('_', '-')); return cultureInfo.NativeName; } catch diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index ae0af5e81..b681224c6 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -437,16 +437,24 @@ public class ExternalMetadataService : IExternalMetadataService // Trim quotes if the response is a JSON string errorMessage = errorMessage.Trim('"'); - if (ex.StatusCode == 400 && errorMessage.Contains("Too many Requests")) + if (ex.StatusCode == 400) { - _logger.LogInformation("Hit rate limit, will retry in 3 seconds"); - await Task.Delay(3000); + if (errorMessage.Contains("Too many Requests")) + { + _logger.LogInformation("Hit rate limit, will retry in 3 seconds"); + await Task.Delay(3000); - result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") - .WithKavitaPlusHeaders(license, token) - .PostJsonAsync(data) - .ReceiveJson< - SeriesDetailPlusApiDto>(); + result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(data) + .ReceiveJson< + SeriesDetailPlusApiDto>(); + } + else if (errorMessage.Contains("Unknown Series")) + { + series.IsBlacklisted = true; + await _unitOfWork.CommitAsync(); + } } } diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index 5ac0c563e..b89fb95d0 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -956,6 +956,7 @@ public class ScrobblingService : IScrobblingService // Recalculate the highest volume/chapter foreach (var readEvt in readEvents) { + // Note: this causes skewing in the scrobble history because it makes it look like there are duplicate events readEvt.VolumeNumber = (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(readEvt.SeriesId, readEvt.AppUser.Id); @@ -1027,7 +1028,7 @@ public class ScrobblingService : IScrobblingService _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() { Comment = "AniList token has expired and needs rotating. Scrobbling wont work until then", - Details = $"User: {evt.AppUser.UserName}", + Details = $"User: {evt.AppUser.UserName}, Expired: {TokenService.GetTokenExpiry(evt.AppUser.AniListAccessToken)}", LibraryId = evt.LibraryId, SeriesId = evt.SeriesId }); @@ -1124,33 +1125,22 @@ public class ScrobblingService : IScrobblingService private static bool CanProcessScrobbleEvent(ScrobbleEvent readEvent) { var userProviders = GetUserProviders(readEvent.AppUser); - if (readEvent.Series.Library.Type == LibraryType.Manga && MangaProviders.Intersect(userProviders).Any()) + switch (readEvent.Series.Library.Type) { - return true; + case LibraryType.Manga when MangaProviders.Intersect(userProviders).Any(): + case LibraryType.Comic when + ComicProviders.Intersect(userProviders).Any(): + case LibraryType.Book when + BookProviders.Intersect(userProviders).Any(): + case LibraryType.LightNovel when + LightNovelProviders.Intersect(userProviders).Any(): + return true; + default: + return false; } - - if (readEvent.Series.Library.Type == LibraryType.Comic && - ComicProviders.Intersect(userProviders).Any()) - { - return true; - } - - if (readEvent.Series.Library.Type == LibraryType.Book && - BookProviders.Intersect(userProviders).Any()) - { - return true; - } - - if (readEvent.Series.Library.Type == LibraryType.LightNovel && - LightNovelProviders.Intersect(userProviders).Any()) - { - return true; - } - - return false; } - private static IList GetUserProviders(AppUser appUser) + private static List GetUserProviders(AppUser appUser) { var providers = new List(); if (!string.IsNullOrEmpty(appUser.AniListAccessToken)) providers.Add(ScrobbleProvider.AniList); @@ -1227,8 +1217,7 @@ public class ScrobblingService : IScrobblingService public static string CreateUrl(string url, long? id) { - if (id is null or 0) return string.Empty; - return $"{url}{id}/"; + return id is null or 0 ? string.Empty : $"{url}{id}/"; } diff --git a/API/Services/StreamService.cs b/API/Services/StreamService.cs index 26915100d..1f2e55579 100644 --- a/API/Services/StreamService.cs +++ b/API/Services/StreamService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -11,6 +12,7 @@ using API.Helpers; using API.SignalR; using Kavita.Common; using Kavita.Common.Helpers; +using Microsoft.Extensions.Logging; namespace API.Services; @@ -33,6 +35,9 @@ public interface IStreamService Task CreateExternalSource(int userId, ExternalSourceDto dto); Task UpdateExternalSource(int userId, ExternalSourceDto dto); Task DeleteExternalSource(int userId, int externalSourceId); + Task DeleteSideNavSmartFilterStream(int userId, int sideNavStreamId); + Task DeleteDashboardSmartFilterStream(int userId, int dashboardStreamId); + Task RenameSmartFilterStreams(AppUserSmartFilter smartFilter); } public class StreamService : IStreamService @@ -40,12 +45,14 @@ public class StreamService : IStreamService private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; private readonly ILocalizationService _localizationService; + private readonly ILogger _logger; - public StreamService(IUnitOfWork unitOfWork, IEventHub eventHub, ILocalizationService localizationService) + public StreamService(IUnitOfWork unitOfWork, IEventHub eventHub, ILocalizationService localizationService, ILogger logger) { _unitOfWork = unitOfWork; _eventHub = eventHub; _localizationService = localizationService; + _logger = logger; } public async Task> GetDashboardStreams(int userId, bool visibleOnly = true) @@ -91,6 +98,7 @@ public class StreamService : IStreamService var ret = new DashboardStreamDto() { + Id = createdStream.Id, Name = createdStream.Name, IsProvided = createdStream.IsProvided, Visible = createdStream.Visible, @@ -182,6 +190,7 @@ public class StreamService : IStreamService var ret = new SideNavStreamDto() { + Id = createdStream.Id, Name = createdStream.Name, IsProvided = createdStream.IsProvided, Visible = createdStream.Visible, @@ -344,4 +353,72 @@ public class StreamService : IStreamService await _unitOfWork.CommitAsync(); } + + public async Task DeleteSideNavSmartFilterStream(int userId, int sideNavStreamId) + { + try + { + var stream = await _unitOfWork.UserRepository.GetSideNavStream(sideNavStreamId); + if (stream == null) throw new KavitaException("sidenav-stream-doesnt-exist"); + + if (stream.AppUserId != userId) throw new KavitaException("sidenav-stream-doesnt-exist"); + + + if (stream.StreamType != SideNavStreamType.SmartFilter) + { + throw new KavitaException("sidenav-stream-only-delete-smart-filter"); + } + + _unitOfWork.UserRepository.Delete(stream); + + await _unitOfWork.CommitAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception deleting SideNav Smart Filter Stream: {FilterId}", sideNavStreamId); + throw; + } + } + + public async Task DeleteDashboardSmartFilterStream(int userId, int dashboardStreamId) + { + try + { + var stream = await _unitOfWork.UserRepository.GetDashboardStream(dashboardStreamId); + if (stream == null) throw new KavitaException("dashboard-stream-doesnt-exist"); + + if (stream.AppUserId != userId) throw new KavitaException("dashboard-stream-doesnt-exist"); + + if (stream.StreamType != DashboardStreamType.SmartFilter) + { + throw new KavitaException("dashboard-stream-only-delete-smart-filter"); + } + + _unitOfWork.UserRepository.Delete(stream); + + await _unitOfWork.CommitAsync(); + } catch (Exception ex) + { + _logger.LogError(ex, "There was an exception deleting Dashboard Smart Filter Stream: {FilterId}", dashboardStreamId); + throw; + } + } + + public async Task RenameSmartFilterStreams(AppUserSmartFilter smartFilter) + { + var sideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreamWithFilter(smartFilter.Id); + var dashboardStreams = await _unitOfWork.UserRepository.GetDashboardStreamWithFilter(smartFilter.Id); + + foreach (var sideNavStream in sideNavStreams) + { + sideNavStream.Name = smartFilter.Name; + } + + foreach (var dashboardStream in dashboardStreams) + { + dashboardStream.Name = smartFilter.Name; + } + + await _unitOfWork.CommitAsync(); + } } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index c3f1a00e5..e73d82b1f 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -329,7 +329,7 @@ public class TaskScheduler : ITaskScheduler if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, normalizedOriginal]) || HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty])) { - _logger.LogDebug("Skipped scheduling ScanFolder for {Folder} as a job already queued", + _logger.LogTrace("Skipped scheduling ScanFolder for {Folder} as a job already queued", normalizedFolder); return; } @@ -346,7 +346,7 @@ public class TaskScheduler : ITaskScheduler var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath); if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty])) { - _logger.LogDebug("Skipped scheduling ScanFolder for {Folder} as a job already queued", + _logger.LogTrace("Skipped scheduling ScanFolder for {Folder} as a job already queued", normalizedFolder); return; } diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index c4ad40fe8..e39600c3f 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -8,8 +8,10 @@ using API.DTOs.Filtering; using API.Entities; using API.Entities.Enums; using API.Helpers; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using Hangfire; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services.Tasks; @@ -35,6 +37,9 @@ public interface ICleanupService Task CleanupWantToRead(); Task ConsolidateProgress(); + + Task CleanupMediaErrors(); + } /// /// Cleans up after operations on reoccurring basis @@ -88,9 +93,11 @@ public class CleanupService : ICleanupService await CleanupBackups(); await SendProgress(0.35F, "Consolidating Progress Events"); - _logger.LogInformation("Consolidating Progress Events"); await ConsolidateProgress(); + await SendProgress(0.4F, "Consolidating Media Errors"); + await CleanupMediaErrors(); + await SendProgress(0.50F, "Cleaning deleted cover images"); _logger.LogInformation("Cleaning deleted cover images"); await DeleteSeriesCoverImages(); @@ -241,6 +248,7 @@ public class CleanupService : ICleanupService /// public async Task ConsolidateProgress() { + _logger.LogInformation("Consolidating Progress Events"); // AppUserProgress var allProgress = await _unitOfWork.AppUserProgressRepository.GetAllProgress(); @@ -291,6 +299,52 @@ public class CleanupService : ICleanupService await _unitOfWork.CommitAsync(); } + /// + /// Scans through Media Error and removes any entries that have been fixed and are within the DB (proper files where wordcount/pagecount > 0) + /// + public async Task CleanupMediaErrors() + { + try + { + List errorStrings = ["This archive cannot be read or not supported", "File format not supported"]; + var mediaErrors = await _unitOfWork.MediaErrorRepository.GetAllErrorsAsync(errorStrings); + _logger.LogInformation("Beginning consolidation of {Count} Media Errors", mediaErrors.Count); + + var pathToErrorMap = mediaErrors + .GroupBy(me => Parser.NormalizePath(me.FilePath)) + .ToDictionary( + group => group.Key, + group => group.ToList() // The same file can be duplicated (rare issue when network drives die out midscan) + ); + + var normalizedPaths = pathToErrorMap.Keys.ToList(); + + // Find all files that are valid + var validFiles = await _unitOfWork.DataContext.MangaFile + .Where(f => normalizedPaths.Contains(f.FilePath) && f.Pages > 0) + .Select(f => f.FilePath) + .ToListAsync(); + + var removalCount = 0; + foreach (var validFilePath in validFiles) + { + if (!pathToErrorMap.TryGetValue(validFilePath, out var mediaError)) continue; + + _unitOfWork.MediaErrorRepository.Remove(mediaError); + removalCount++; + } + + await _unitOfWork.CommitAsync(); + + _logger.LogInformation("Finished consolidation of {Count} Media Errors, Removed: {RemovalCount}", + mediaErrors.Count, removalCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception consolidating media errors"); + } + } + public async Task CleanupLogs() { _logger.LogInformation("Performing cleanup of logs directory"); diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs index 89c43f827..bff7001bd 100644 --- a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -179,7 +179,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService var pageCounter = 1; try { - using var book = await EpubReader.OpenBookAsync(filePath, BookService.BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(filePath, BookService.LenientBookReaderOptions); var totalPages = book.Content.Html.Local; foreach (var bookPage in totalPages) diff --git a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs index f59a3b66f..679d6a031 100644 --- a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs +++ b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs @@ -130,9 +130,9 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau } // Patch is SeriesSort from ComicInfo - if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort)) + if (!string.IsNullOrEmpty(info.ComicInfo.SeriesSort)) { - info.SeriesSort = info.ComicInfo.TitleSort.Trim(); + info.SeriesSort = info.ComicInfo.SeriesSort.Trim(); } } diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 163954ba7..46bc74a65 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -167,7 +167,7 @@ public static partial class Parser MatchOptions, RegexTimeout), // Korean Volume: 제n화|권|회|장 -> Volume n, n화|권|회|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) new Regex( - @"제?(?\d+(\.\d)?)(권|회|화|장)", + @"제?(?\d+(\.\d+)?)(권|회|화|장)", MatchOptions, RegexTimeout), // Korean Season: 시즌n -> Season n, new Regex( diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index eb1c5dd0d..e22ee4bb6 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -161,7 +161,7 @@ public class ScannerService : IScannerService { if (TaskScheduler.HasScanTaskRunningForSeries(series.Id)) { - _logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); + _logger.LogTrace("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); return; } @@ -186,7 +186,7 @@ public class ScannerService : IScannerService { if (TaskScheduler.HasScanTaskRunningForLibrary(library.Id)) { - _logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder); + _logger.LogTrace("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder); return; } BackgroundJob.Schedule(() => ScanLibrary(library.Id, false, true), TimeSpan.FromMinutes(1)); diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs index 721eb0481..720d97663 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -162,4 +162,10 @@ public class TokenService : ITokenService { return !JwtHelper.IsTokenValid(token); } + + + public static DateTime GetTokenExpiry(string? token) + { + return JwtHelper.GetTokenExpiry(token); + } } diff --git a/UI/Web/.editorconfig b/UI/Web/.editorconfig index bd01a6818..28045b9af 100644 --- a/UI/Web/.editorconfig +++ b/UI/Web/.editorconfig @@ -11,6 +11,9 @@ trim_trailing_whitespace = true [*.json] indent_size = 2 +[en.json] +indent_size = 4 + [*.html] indent_size = 2 diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 5ef3f73cb..f7384900b 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -39,9 +39,9 @@ "luxon": "^3.5.0", "ng-circle-progress": "^1.7.1", "ng-lazyload-image": "^9.1.3", - "ng-select2-component": "^17.2.1", + "ng-select2-component": "^17.2.2", "ngx-color-picker": "^19.0.0", - "ngx-extended-pdf-viewer": "^22.3.9", + "ngx-extended-pdf-viewer": "^23.0.0-alpha.7", "ngx-file-drop": "^16.0.0", "ngx-stars": "^1.6.5", "ngx-toastr": "^19.0.0", @@ -542,7 +542,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.3.tgz", "integrity": "sha512-ePh/7A6eEDAyfVn8QgLcAvrxhXBAf6mTqB/3+HwQeXLaka1gtN6xvZ6cjLEegP4s6kcYGhdfdLwzCcy0kjsY5g==", - "dev": true, "dependencies": { "@babel/core": "7.26.9", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -570,7 +569,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", - "dev": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -585,7 +583,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", - "dev": true, "engines": { "node": ">= 14.16.0" }, @@ -4907,8 +4904,7 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cosmiconfig": { "version": "8.3.6", @@ -5355,7 +5351,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -5365,7 +5360,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -7420,9 +7414,9 @@ } }, "node_modules/ng-select2-component": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-17.2.1.tgz", - "integrity": "sha512-61CvdsFH/UbhEYwBr8j29eB3z8HMktLRRzNAbyl+PPTiZoGXdGGR9Bxatqw8go4vQBkwr5ju1JhsMQrECS0MvQ==", + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-17.2.2.tgz", + "integrity": "sha512-dAeUSqmjU9Gexi47vMEz1bXGQkl3Be2O0wl6QqpYwFvM+QEfUyQiY0zWpYvB8shO1sIHoCQNKt9yTFcRzvzW0g==", "dependencies": { "ngx-infinite-scroll": ">=18.0.0 || >=19.0.0", "tslib": "^2.3.0" @@ -7447,9 +7441,9 @@ } }, "node_modules/ngx-extended-pdf-viewer": { - "version": "22.3.9", - "resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-22.3.9.tgz", - "integrity": "sha512-7DRR1P9UUx4VjG8GnFkXoXuvlgW1+Q2395u+24or7O8HDcIAve/QgokS7jizQmF55DNLsilYzunOBm0ezK5lHw==", + "version": "23.0.0-alpha.7", + "resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-23.0.0-alpha.7.tgz", + "integrity": "sha512-S5jI9Z6p6wglLwvpf85MddxGKYUiJczb02nZcFWztDSZ7BlKXkjdtssW+chBOc/sg46p2kTDoa0M/R07yqRFcA==", "dependencies": { "tslib": "^2.3.0" }, @@ -8184,8 +8178,7 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, "node_modules/replace-in-file": { "version": "7.1.0", @@ -8406,7 +8399,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "devOptional": true }, "node_modules/sass": { "version": "1.85.0", @@ -8471,7 +8464,6 @@ "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -9096,7 +9088,6 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/UI/Web/package.json b/UI/Web/package.json index 64eb4aa70..1ffbb1c07 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -47,9 +47,9 @@ "luxon": "^3.5.0", "ng-circle-progress": "^1.7.1", "ng-lazyload-image": "^9.1.3", - "ng-select2-component": "^17.2.1", + "ng-select2-component": "^17.2.2", "ngx-color-picker": "^19.0.0", - "ngx-extended-pdf-viewer": "^22.3.9", + "ngx-extended-pdf-viewer": "^23.0.0-alpha.7", "ngx-file-drop": "^16.0.0", "ngx-stars": "^1.6.5", "ngx-toastr": "^19.0.0", diff --git a/UI/Web/src/app/_models/metadata/series-filter.ts b/UI/Web/src/app/_models/metadata/series-filter.ts index bfaee4f3f..7d043aa3c 100644 --- a/UI/Web/src/app/_models/metadata/series-filter.ts +++ b/UI/Web/src/app/_models/metadata/series-filter.ts @@ -1,6 +1,5 @@ -import { MangaFormat } from "../manga-format"; -import { SeriesFilterV2 } from "./v2/series-filter-v2"; -import {FilterField} from "./v2/filter-field"; +import {MangaFormat} from "../manga-format"; +import {SeriesFilterV2} from "./v2/series-filter-v2"; export interface FilterItem { title: string; @@ -34,22 +33,22 @@ export const allSortFields = Object.keys(SortField) export const mangaFormatFilters = [ { - title: 'Images', + title: 'images', value: MangaFormat.IMAGE, selected: false }, { - title: 'EPUB', + title: 'epub', value: MangaFormat.EPUB, selected: false }, { - title: 'PDF', + title: 'pdf', value: MangaFormat.PDF, selected: false }, { - title: 'ARCHIVE', + title: 'archive', value: MangaFormat.ARCHIVE, selected: false } diff --git a/UI/Web/src/app/_models/wiki.ts b/UI/Web/src/app/_models/wiki.ts index a94e0f7db..93df34418 100644 --- a/UI/Web/src/app/_models/wiki.ts +++ b/UI/Web/src/app/_models/wiki.ts @@ -6,7 +6,7 @@ export enum WikiLink { SeriesRelationships = 'https://wiki.kavitareader.com/guides/features/relationships', Bookmarks = 'https://wiki.kavitareader.com/guides/features/bookmarks', DataCollection = 'https://wiki.kavitareader.com/troubleshooting/faq#q-does-kavita-collect-any-data-on-me', - MediaIssues = 'https://wiki.kavitareader.com/guides/admin-settings/media#media-issues', + MediaIssues = 'https://wiki.kavitareader.com/guides/admin-settings/mediaissues/', KavitaPlusDiscordId = 'https://wiki.kavitareader.com/guides/admin-settings/kavita+#discord-id', KavitaPlus = 'https://wiki.kavitareader.com/kavita+/features/', KavitaPlusFAQ = 'https://wiki.kavitareader.com/kavita+/faq', diff --git a/UI/Web/src/app/_pipes/age-rating.pipe.ts b/UI/Web/src/app/_pipes/age-rating.pipe.ts index 15554cf05..f99a77f72 100644 --- a/UI/Web/src/app/_pipes/age-rating.pipe.ts +++ b/UI/Web/src/app/_pipes/age-rating.pipe.ts @@ -1,22 +1,22 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; -import { Observable, of } from 'rxjs'; -import { AgeRating } from '../_models/metadata/age-rating'; -import { AgeRatingDto } from '../_models/metadata/age-rating-dto'; +import {AgeRating} from '../_models/metadata/age-rating'; +import {AgeRatingDto} from '../_models/metadata/age-rating-dto'; import {TranslocoService} from "@jsverse/transloco"; @Pipe({ name: 'ageRating', - standalone: true + standalone: true, + pure: true }) export class AgeRatingPipe implements PipeTransform { - translocoService = inject(TranslocoService); + private readonly translocoService = inject(TranslocoService); - transform(value: AgeRating | AgeRatingDto | undefined): Observable { - if (value === undefined || value === null) return of(this.translocoService.translate('age-rating-pipe.unknown') as string); + transform(value: AgeRating | AgeRatingDto | undefined): string { + if (value === undefined || value === null) return this.translocoService.translate('age-rating-pipe.unknown'); if (value.hasOwnProperty('title')) { - return of((value as AgeRatingDto).title); + return (value as AgeRatingDto).title; } switch (value) { @@ -54,7 +54,7 @@ export class AgeRatingPipe implements PipeTransform { return this.translocoService.translate('age-rating-pipe.r18-plus'); } - return of(this.translocoService.translate('age-rating-pipe.unknown') as string); + return this.translocoService.translate('age-rating-pipe.unknown'); } } diff --git a/UI/Web/src/app/_pipes/log-level.pipe.ts b/UI/Web/src/app/_pipes/log-level.pipe.ts new file mode 100644 index 000000000..1a1c7c19a --- /dev/null +++ b/UI/Web/src/app/_pipes/log-level.pipe.ts @@ -0,0 +1,17 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {translate} from "@jsverse/transloco"; + +/** + * Transforms the log level string into a localized string + */ +@Pipe({ + name: 'logLevel', + standalone: true, + pure: true +}) +export class LogLevelPipe implements PipeTransform { + transform(value: string): string { + return translate('log-level-pipe.' + value.toLowerCase()); + } + +} diff --git a/UI/Web/src/app/_pipes/role-localized.pipe.ts b/UI/Web/src/app/_pipes/role-localized.pipe.ts new file mode 100644 index 000000000..1890962dd --- /dev/null +++ b/UI/Web/src/app/_pipes/role-localized.pipe.ts @@ -0,0 +1,15 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {Role} from "../_services/account.service"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'roleLocalized' +}) +export class RoleLocalizedPipe implements PipeTransform { + + transform(value: Role | string): string { + const key = (value + '').toLowerCase().replace(' ', '-'); + return translate(`role-localized-pipe.${key}`); + } + +} diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 447095a82..131e20cac 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -1,14 +1,14 @@ -import { Injectable } from '@angular/core'; -import { map, Observable, shareReplay } from 'rxjs'; -import { Chapter } from '../_models/chapter'; +import {Injectable} from '@angular/core'; +import {map, Observable, shareReplay} from 'rxjs'; +import {Chapter} from '../_models/chapter'; import {UserCollection} from '../_models/collection-tag'; -import { Device } from '../_models/device/device'; -import { Library } from '../_models/library/library'; -import { ReadingList } from '../_models/reading-list'; -import { Series } from '../_models/series'; -import { Volume } from '../_models/volume'; -import { AccountService } from './account.service'; -import { DeviceService } from './device.service'; +import {Device} from '../_models/device/device'; +import {Library} from '../_models/library/library'; +import {ReadingList} from '../_models/reading-list'; +import {Series} from '../_models/series'; +import {Volume} from '../_models/volume'; +import {AccountService} from './account.service'; +import {DeviceService} from './device.service'; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {translate} from "@jsverse/transloco"; @@ -170,6 +170,8 @@ export class ActionFactoryService { sideNavStreamActions: Array> = []; smartFilterActions: Array> = []; + sideNavHomeActions: Array> = []; + isAdmin = false; @@ -226,6 +228,10 @@ export class ActionFactoryService { return this.applyCallbackToList(this.personActions, callback); } + getSideNavHomeActions(callback: ActionCallback) { + return this.applyCallbackToList(this.sideNavHomeActions, callback); + } + dummyCallback(action: ActionItem, data: any) {} filterSendToAction(actions: Array>, chapter: Chapter) { @@ -873,6 +879,19 @@ export class ActionFactoryService { children: [], }, ]; + + this.sideNavHomeActions = [ + { + action: Action.Edit, + title: 'reorder', + description: '', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + } + ] + + } private applyCallback(action: ActionItem, callback: (action: ActionItem, data: any) => void) { diff --git a/UI/Web/src/app/_services/dashboard.service.ts b/UI/Web/src/app/_services/dashboard.service.ts index 7ece274bb..493fae370 100644 --- a/UI/Web/src/app/_services/dashboard.service.ts +++ b/UI/Web/src/app/_services/dashboard.service.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@angular/core'; +import {Injectable} from '@angular/core'; import {TextResonse} from "../_types/text-response"; -import { HttpClient } from "@angular/common/http"; +import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {DashboardStream} from "../_models/dashboard/dashboard-stream"; @@ -26,4 +26,8 @@ export class DashboardService { createDashboardStream(smartFilterId: number) { return this.httpClient.post(this.baseUrl + 'stream/add-dashboard-stream?smartFilterId=' + smartFilterId, {}); } + + deleteSmartFilterStream(streamId: number) { + return this.httpClient.delete(this.baseUrl + 'stream/smart-filter-dashboard-stream?dashboardStreamId=' + streamId, {}); + } } diff --git a/UI/Web/src/app/_services/filter.service.ts b/UI/Web/src/app/_services/filter.service.ts index 2c47ff95d..e76c1926f 100644 --- a/UI/Web/src/app/_services/filter.service.ts +++ b/UI/Web/src/app/_services/filter.service.ts @@ -23,4 +23,8 @@ export class FilterService { return this.httpClient.delete(this.baseUrl + 'filter?filterId=' + filterId); } + renameSmartFilter(filter: SmartFilter) { + return this.httpClient.post(this.baseUrl + `filter/rename?filterId=${filter.id}&name=${filter.name.trim()}`, {}); + } + } diff --git a/UI/Web/src/app/_services/license.service.ts b/UI/Web/src/app/_services/license.service.ts index f71b54f52..a2e77f2fe 100644 --- a/UI/Web/src/app/_services/license.service.ts +++ b/UI/Web/src/app/_services/license.service.ts @@ -1,11 +1,9 @@ import {inject, Injectable} from '@angular/core'; import {HttpClient} from "@angular/common/http"; -import {catchError, map, of, ReplaySubject, tap, throwError} from "rxjs"; +import {catchError, map, ReplaySubject, tap, throwError} from "rxjs"; import {environment} from "../../environments/environment"; -import { TextResonse } from '../_types/text-response'; +import {TextResonse} from '../_types/text-response'; import {LicenseInfo} from "../_models/kavitaplus/license-info"; -import {translate} from "@jsverse/transloco"; -import {ConfirmService} from "../shared/confirm.service"; @Injectable({ providedIn: 'root' @@ -58,7 +56,6 @@ export class LicenseService { } hasValidLicense(forceCheck: boolean = false) { - console.log('hasValidLicense being called: ', forceCheck); return this.httpClient.get(this.baseUrl + 'license/valid-license?forceCheck=' + forceCheck, TextResonse) .pipe( map(res => res === "true"), diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 7b1e4dd8c..65d9fca17 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -1,7 +1,7 @@ import {DOCUMENT} from '@angular/common'; import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core'; -import {distinctUntilChanged, filter, ReplaySubject, take} from 'rxjs'; -import { HttpClient } from "@angular/common/http"; +import {filter, ReplaySubject, take} from 'rxjs'; +import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {TextResonse} from "../_types/text-response"; @@ -93,6 +93,10 @@ export class NavService { return this.httpClient.post(this.baseUrl + 'stream/bulk-sidenav-stream-visibility', {ids: streamIds, visibility: targetVisibility}); } + deleteSideNavSmartFilter(streamId: number) { + return this.httpClient.delete(this.baseUrl + 'stream/smart-filter-side-nav-stream?sideNavStreamId=' + streamId, {}); + } + /** * Shows the top nav bar. This should be visible on all pages except the reader. */ diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html index 6520e2f8a..979794d20 100644 --- a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html @@ -7,7 +7,9 @@ diff --git a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts index 596d71e4c..3eb8c080c 100644 --- a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts +++ b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts @@ -3,7 +3,6 @@ import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {Library} from 'src/app/_models/library/library'; import {Member} from 'src/app/_models/auth/member'; import {LibraryService} from 'src/app/_services/library.service'; -import {NgFor, NgIf} from '@angular/common'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {TranslocoDirective} from "@jsverse/transloco"; import {SelectionModel} from "../../../typeahead/_models/selection-model"; @@ -13,7 +12,7 @@ import {SelectionModel} from "../../../typeahead/_models/selection-model"; templateUrl: './library-access-modal.component.html', styleUrls: ['./library-access-modal.component.scss'], standalone: true, - imports: [ReactiveFormsModule, FormsModule, NgFor, NgIf, TranslocoDirective], + imports: [ReactiveFormsModule, FormsModule, TranslocoDirective], changeDetection: ChangeDetectionStrategy.OnPush }) export class LibraryAccessModalComponent implements OnInit { @@ -23,6 +22,7 @@ export class LibraryAccessModalComponent implements OnInit { private readonly libraryService = inject(LibraryService); @Input() member: Member | undefined; + allLibraries: Library[] = []; selectedLibraries: Array<{selected: boolean, data: Library}> = []; selections!: SelectionModel; diff --git a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html index 716f95e78..258503e94 100644 --- a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html +++ b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html @@ -7,9 +7,12 @@ diff --git a/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts b/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts index be46b6592..d0636ab5c 100644 --- a/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts +++ b/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts @@ -1,48 +1,63 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, - Component, DestroyRef, + Component, + DestroyRef, EventEmitter, - HostListener, inject, OnInit } from '@angular/core'; -import { Title } from '@angular/platform-browser'; -import { ActivatedRoute, Router } from '@angular/router'; -import { take, debounceTime } from 'rxjs/operators'; -import { BulkSelectionService } from 'src/app/cards/bulk-selection.service'; -import { FilterSettings } from 'src/app/metadata-filter/filter-settings'; -import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service'; -import { UtilityService, KEY_CODES } from 'src/app/shared/_services/utility.service'; -import { JumpKey } from 'src/app/_models/jumpbar/jump-key'; -import { Pagination } from 'src/app/_models/pagination'; -import { Series } from 'src/app/_models/series'; -import { FilterEvent } from 'src/app/_models/metadata/series-filter'; -import { Action, ActionItem } from 'src/app/_services/action-factory.service'; -import { ActionService } from 'src/app/_services/action.service'; -import { JumpbarService } from 'src/app/_services/jumpbar.service'; -import { MessageHubService, Message, EVENTS } from 'src/app/_services/message-hub.service'; -import { SeriesService } from 'src/app/_services/series.service'; +import {Title} from '@angular/platform-browser'; +import {ActivatedRoute, Router} from '@angular/router'; +import {debounceTime, take} from 'rxjs/operators'; +import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; +import {FilterSettings} from 'src/app/metadata-filter/filter-settings'; +import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service'; +import {UtilityService} from 'src/app/shared/_services/utility.service'; +import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; +import {Pagination} from 'src/app/_models/pagination'; +import {Series} from 'src/app/_models/series'; +import {FilterEvent} from 'src/app/_models/metadata/series-filter'; +import {Action, ActionItem} from 'src/app/_services/action-factory.service'; +import {ActionService} from 'src/app/_services/action.service'; +import {JumpbarService} from 'src/app/_services/jumpbar.service'; +import {EVENTS, Message, MessageHubService} from 'src/app/_services/message-hub.service'; +import {SeriesService} from 'src/app/_services/series.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import { SeriesCardComponent } from '../../../cards/series-card/series-card.component'; -import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component'; -import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component'; -import { NgIf, DecimalPipe } from '@angular/common'; -import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; +import {SeriesCardComponent} from '../../../cards/series-card/series-card.component'; +import {CardDetailLayoutComponent} from '../../../cards/card-detail-layout/card-detail-layout.component'; +import {BulkOperationsComponent} from '../../../cards/bulk-operations/bulk-operations.component'; +import {DecimalPipe} from '@angular/common'; +import { + SideNavCompanionBarComponent +} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2"; - @Component({ - selector: 'app-all-series', - templateUrl: './all-series.component.html', - styleUrls: ['./all-series.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [SideNavCompanionBarComponent, NgIf, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, DecimalPipe, TranslocoDirective] + selector: 'app-all-series', + templateUrl: './all-series.component.html', + styleUrls: ['./all-series.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SideNavCompanionBarComponent, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, + DecimalPipe, TranslocoDirective], }) export class AllSeriesComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly router = inject(Router); + private readonly seriesService = inject(SeriesService); + private readonly titleService = inject(Title); + private readonly actionService = inject(ActionService); + private readonly hubService = inject(MessageHubService); + private readonly utilityService = inject(UtilityService); + private readonly route = inject(ActivatedRoute); + private readonly filterUtilityService = inject(FilterUtilitiesService); + private readonly jumpbarService = inject(JumpbarService); + private readonly cdRef = inject(ChangeDetectorRef); + protected readonly bulkSelectionService = inject(BulkSelectionService); + title: string = translate('side-nav.all-series'); series: Series[] = []; loadingSeries = false; @@ -53,7 +68,7 @@ export class AllSeriesComponent implements OnInit { filterActiveCheck!: SeriesFilterV2; filterActive: boolean = false; jumpbarKeys: Array = []; - private readonly destroyRef = inject(DestroyRef); + bulkActionCallback = (action: ActionItem, data: any) => { const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); @@ -103,13 +118,10 @@ export class AllSeriesComponent implements OnInit { } } - constructor(private router: Router, private seriesService: SeriesService, - private titleService: Title, private actionService: ActionService, - public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService, - private utilityService: UtilityService, private route: ActivatedRoute, - private filterUtilityService: FilterUtilitiesService, private jumpbarService: JumpbarService, - private readonly cdRef: ChangeDetectorRef) { + + + constructor() { this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { @@ -140,7 +152,7 @@ export class AllSeriesComponent implements OnInit { return; } - this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((encodedFilter) => { + this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((_) => { this.loadPage(); }); } @@ -163,5 +175,5 @@ export class AllSeriesComponent implements OnInit { }); } - trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`; + trackByIdentity = (_: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`; } diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index b000f3406..a595013fb 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -18,7 +18,7 @@ import { import {DOCUMENT, NgClass, NgIf, NgStyle, NgTemplateOutlet} from '@angular/common'; import {ActivatedRoute, Router} from '@angular/router'; import {ToastrService} from 'ngx-toastr'; -import {forkJoin, fromEvent, of} from 'rxjs'; +import {forkJoin, fromEvent, merge, of} from 'rxjs'; import {catchError, debounceTime, distinctUntilChanged, take, tap} from 'rxjs/operators'; import {Chapter} from 'src/app/_models/chapter'; import {AccountService} from 'src/app/_services/account.service'; @@ -515,7 +515,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.handleScrollEvent(); }); - fromEvent(this.bookContainerElemRef.nativeElement, 'mousemove') + const mouseMove$ = fromEvent(this.bookContainerElemRef.nativeElement, 'mousemove'); + const touchMove$ = fromEvent(this.bookContainerElemRef.nativeElement, 'touchmove'); + + merge(mouseMove$, touchMove$) .pipe( takeUntilDestroyed(this.destroyRef), distinctUntilChanged(), @@ -527,7 +530,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { ) .subscribe(); - fromEvent(this.bookContainerElemRef.nativeElement, 'mouseup') + const mouseUp$ = fromEvent(this.bookContainerElemRef.nativeElement, 'mouseup'); + const touchEnd$ = fromEvent(this.bookContainerElemRef.nativeElement, 'touchend'); + + merge(mouseUp$, touchEnd$) .pipe( takeUntilDestroyed(this.destroyRef), distinctUntilChanged(), diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index d8e47ed57..710f99819 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -8,7 +8,8 @@