diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index cfb89935a..90bd2d279 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Data.Common; using System.IO.Abstractions.TestingHelpers; using System.Linq; @@ -16,7 +15,6 @@ using API.Tests.Helpers; using AutoMapper; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -28,7 +26,6 @@ public class ReaderServiceTests private readonly IUnitOfWork _unitOfWork; - private readonly DbConnection _connection; private readonly DataContext _context; private const string CacheDirectory = "C:/kavita/config/cache/"; @@ -39,7 +36,6 @@ public class ReaderServiceTests public ReaderServiceTests() { var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options; - _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; _context = new DataContext(contextOptions); Task.Run(SeedDb).GetAwaiter().GetResult(); @@ -83,7 +79,7 @@ public class ReaderServiceTests return await _context.SaveChangesAsync() > 0; } - private async Task ResetDB() + private async Task ResetDb() { _context.Series.RemoveRange(_context.Series.ToList()); @@ -122,7 +118,7 @@ public class ReaderServiceTests [Fact] public async Task CapPageToChapterTest() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -161,7 +157,7 @@ public class ReaderServiceTests [Fact] public async Task SaveReadingProgress_ShouldCreateNewEntity() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -210,7 +206,7 @@ public class ReaderServiceTests [Fact] public async Task SaveReadingProgress_ShouldUpdateExisting() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -276,7 +272,7 @@ public class ReaderServiceTests [Fact] public async Task MarkChaptersAsReadTest() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -314,7 +310,7 @@ public class ReaderServiceTests var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var volumes = await _unitOfWork.VolumeRepository.GetVolumes(1); - readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + await readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); await _context.SaveChangesAsync(); Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); @@ -326,7 +322,7 @@ public class ReaderServiceTests [Fact] public async Task MarkChapterAsUnreadTest() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -364,12 +360,12 @@ public class ReaderServiceTests var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); - readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + await readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); await _context.SaveChangesAsync(); Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); - readerService.MarkChaptersAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + await readerService.MarkChaptersAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); await _context.SaveChangesAsync(); var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; @@ -385,7 +381,7 @@ public class ReaderServiceTests public async Task GetNextChapterIdAsync_ShouldGetNextVolume() { // V1 -> V2 - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -431,7 +427,7 @@ public class ReaderServiceTests [Fact] public async Task GetNextChapterIdAsync_ShouldRollIntoNextVolume() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -478,7 +474,7 @@ public class ReaderServiceTests [Fact] public async Task GetNextChapterIdAsync_ShouldRollIntoChaptersFromVolume() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -518,10 +514,55 @@ public class ReaderServiceTests Assert.Equal("1", actualChapter.Range); } + [Fact] + public async Task GetNextChapterIdAsync_ShouldRollIntoNextChapterWhenVolumesAreOnlyOneChapterAndNextChapterIs0() + { + await ResetDb(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("66", false, new List()), + EntityFactory.CreateChapter("67", false, new List()), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("0", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + + var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 3, 1); + Assert.NotEqual(-1, nextChapter); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.Equal("0", actualChapter.Range); + } + [Fact] public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromSpecial() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -562,7 +603,7 @@ public class ReaderServiceTests [Fact] public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromVolume() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -598,7 +639,7 @@ public class ReaderServiceTests [Fact] public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_NoSpecials() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -639,7 +680,7 @@ public class ReaderServiceTests [Fact] public async Task GetNextChapterIdAsync_ShouldMoveFromVolumeToSpecial_NoLooseLeafChapters() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -682,7 +723,7 @@ public class ReaderServiceTests [Fact] public async Task GetNextChapterIdAsync_ShouldMoveFromLooseLeafChapterToSpecial() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -721,7 +762,7 @@ public class ReaderServiceTests [Fact] public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromSpecial_WithVolumeAndLooseLeafChapters() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -763,7 +804,7 @@ public class ReaderServiceTests [Fact] public async Task GetNextChapterIdAsync_ShouldMoveFromSpecialToSpecial() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -811,7 +852,7 @@ public class ReaderServiceTests public async Task GetPrevChapterIdAsync_ShouldGetPrevVolume() { // V1 -> V2 - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -857,7 +898,7 @@ public class ReaderServiceTests [Fact] public async Task GetPrevChapterIdAsync_ShouldRollIntoPrevVolume() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -904,7 +945,7 @@ public class ReaderServiceTests [Fact] public async Task GetPrevChapterIdAsync_ShouldMoveFromSpecialToVolume() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -947,7 +988,7 @@ public class ReaderServiceTests [Fact] public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolume() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -983,7 +1024,7 @@ public class ReaderServiceTests [Fact] public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolumeWithZeroChapter() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -1018,7 +1059,7 @@ public class ReaderServiceTests [Fact] public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolumeWithZeroChapterAndHasNormalChapters() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -1058,7 +1099,7 @@ public class ReaderServiceTests [Fact] public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolumeWithZeroChapterAndHasNormalChapters2() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -1112,7 +1153,7 @@ public class ReaderServiceTests [Fact] public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromChapter() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -1148,7 +1189,7 @@ public class ReaderServiceTests [Fact] public async Task GetPrevChapterIdAsync_ShouldMoveFromSpecialToSpecial() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -1191,7 +1232,7 @@ public class ReaderServiceTests [Fact] public async Task GetPrevChapterIdAsync_ShouldMoveFromChapterToVolume() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -1902,7 +1943,7 @@ public class ReaderServiceTests [Fact] public async Task MarkChaptersUntilAsRead_ShouldMarkAsReadAnythingUntil() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", @@ -1979,7 +2020,7 @@ public class ReaderServiceTests [Fact] public async Task MarkSeriesAsReadTest() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -2044,7 +2085,7 @@ public class ReaderServiceTests [Fact] public async Task MarkSeriesAsUnreadTest() { - await ResetDB(); + await ResetDb(); _context.Series.Add(new Series() { @@ -2082,7 +2123,7 @@ public class ReaderServiceTests var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); - readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + await readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); await _context.SaveChangesAsync(); Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); @@ -2096,4 +2137,40 @@ public class ReaderServiceTests } #endregion + + #region FormatChapterName + + [Fact] + public void FormatChapterName_Manga_Chapter() + { + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var actual = readerService.FormatChapterName(LibraryType.Manga, false, false); + Assert.Equal("Chapter", actual); + } + + [Fact] + public void FormatChapterName_Book_Chapter_WithTitle() + { + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var actual = readerService.FormatChapterName(LibraryType.Book, false, false); + Assert.Equal("Book", actual); + } + + [Fact] + public void FormatChapterName_Comic() + { + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var actual = readerService.FormatChapterName(LibraryType.Comic, false, false); + Assert.Equal("Issue", actual); + } + + [Fact] + public void FormatChapterName_Comic_WithHash() + { + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var actual = readerService.FormatChapterName(LibraryType.Comic, true, true); + Assert.Equal("Issue #", actual); + } + + #endregion } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 758c6d5ab..34cb411bd 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -28,32 +28,27 @@ namespace API.Controllers private readonly ILogger _logger; private readonly IReaderService _readerService; private readonly IBookmarkService _bookmarkService; - private readonly IEventHub _eventHub; /// public ReaderController(ICacheService cacheService, IUnitOfWork unitOfWork, ILogger logger, - IReaderService readerService, IBookmarkService bookmarkService, - IEventHub eventHub) + IReaderService readerService, IBookmarkService bookmarkService) { _cacheService = cacheService; _unitOfWork = unitOfWork; _logger = logger; _readerService = readerService; _bookmarkService = bookmarkService; - _eventHub = eventHub; } /// /// Returns the PDF for the chapterId. /// - /// API Key for user to validate they have access /// /// [HttpGet("pdf")] public async Task GetPdf(int chapterId) { - var chapter = await _cacheService.Ensure(chapterId); if (chapter == null) return BadRequest("There was an issue finding pdf file for reading"); @@ -152,9 +147,9 @@ namespace API.Controllers if (dto == null) return BadRequest("Please perform a scan on this series or library and try again"); var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First(); - return Ok(new ChapterInfoDto() + var info = new ChapterInfoDto() { - ChapterNumber = dto.ChapterNumber, + ChapterNumber = dto.ChapterNumber, VolumeNumber = dto.VolumeNumber, VolumeId = dto.VolumeId, FileName = Path.GetFileName(mangaFile.FilePath), @@ -164,8 +159,33 @@ namespace API.Controllers LibraryId = dto.LibraryId, IsSpecial = dto.IsSpecial, Pages = dto.Pages, - ChapterTitle = dto.ChapterTitle ?? string.Empty - }); + ChapterTitle = dto.ChapterTitle ?? string.Empty, + Subtitle = string.Empty, + Title = dto.SeriesName + }; + + if (info.ChapterTitle is {Length: > 0}) { + info.Title += " - " + info.ChapterTitle; + } + + if (info.IsSpecial && dto.VolumeNumber.Equals(Parser.Parser.DefaultVolume)) + { + info.Subtitle = info.FileName; + } else if (!info.IsSpecial && info.VolumeNumber.Equals(Parser.Parser.DefaultVolume)) + { + info.Subtitle = _readerService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber; + } + else + { + info.Subtitle = "Volume " + info.VolumeNumber; + if (!info.ChapterNumber.Equals(Parser.Parser.DefaultChapter)) + { + info.Subtitle += " " + _readerService.FormatChapterName(info.LibraryType, true, true) + + info.ChapterNumber; + } + } + + return Ok(info); } /// @@ -235,7 +255,7 @@ namespace API.Controllers var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); - _readerService.MarkChaptersAsUnread(user, markVolumeReadDto.SeriesId, chapters); + await _readerService.MarkChaptersAsUnread(user, markVolumeReadDto.SeriesId, chapters); _unitOfWork.UserRepository.Update(user); @@ -258,7 +278,7 @@ namespace API.Controllers var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); - _readerService.MarkChaptersAsRead(user, markVolumeReadDto.SeriesId, chapters); + await _readerService.MarkChaptersAsRead(user, markVolumeReadDto.SeriesId, chapters); _unitOfWork.UserRepository.Update(user); @@ -288,7 +308,7 @@ namespace API.Controllers chapterIds.Add(chapterId); } var chapters = await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds); - _readerService.MarkChaptersAsRead(user, dto.SeriesId, chapters); + await _readerService.MarkChaptersAsRead(user, dto.SeriesId, chapters); _unitOfWork.UserRepository.Update(user); @@ -317,7 +337,7 @@ namespace API.Controllers chapterIds.Add(chapterId); } var chapters = await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds); - _readerService.MarkChaptersAsUnread(user, dto.SeriesId, chapters); + await _readerService.MarkChaptersAsUnread(user, dto.SeriesId, chapters); _unitOfWork.UserRepository.Update(user); @@ -343,7 +363,7 @@ namespace API.Controllers var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true); foreach (var volume in volumes) { - _readerService.MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters); + await _readerService.MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters); } _unitOfWork.UserRepository.Update(user); @@ -370,7 +390,7 @@ namespace API.Controllers var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true); foreach (var volume in volumes) { - _readerService.MarkChaptersAsUnread(user, volume.SeriesId, volume.Chapters); + await _readerService.MarkChaptersAsUnread(user, volume.SeriesId, volume.Chapters); } _unitOfWork.UserRepository.Update(user); diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index a4285431e..1c326d5cc 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -73,8 +73,6 @@ namespace API.Controllers var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId); return Ok(items); - - //return Ok(await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(userId, items.ToList())); } /// diff --git a/API/DTOs/Reader/ChapterInfoDto.cs b/API/DTOs/Reader/ChapterInfoDto.cs index 6af7442dd..7f4079910 100644 --- a/API/DTOs/Reader/ChapterInfoDto.cs +++ b/API/DTOs/Reader/ChapterInfoDto.cs @@ -1,23 +1,68 @@ -using System; -using API.Entities.Enums; +using API.Entities.Enums; -namespace API.DTOs.Reader +namespace API.DTOs.Reader; + +/// +/// Information about the Chapter for the Reader to render +/// +public class ChapterInfoDto : IChapterInfoDto { - public class ChapterInfoDto : IChapterInfoDto - { + /// + /// The Chapter Number + /// + public string ChapterNumber { get; set; } + /// + /// The Volume Number + /// + public string VolumeNumber { get; set; } + /// + /// Volume entity Id + /// + public int VolumeId { get; set; } + /// + /// Series Name + /// + public string SeriesName { get; set; } + /// + /// Series Format + /// + public MangaFormat SeriesFormat { get; set; } + /// + /// Series entity Id + /// + public int SeriesId { get; set; } + /// + /// Library entity Id + /// + public int LibraryId { get; set; } + /// + /// Library type + /// + public LibraryType LibraryType { get; set; } + /// + /// Chapter's title if set via ComicInfo.xml (Title field) + /// + public string ChapterTitle { get; set; } = string.Empty; + /// + /// Total Number of pages in this Chapter + /// + public int Pages { get; set; } + /// + /// File name of the chapter + /// + public string FileName { get; set; } + /// + /// If this is marked as a special in Kavita + /// + public bool IsSpecial { get; set; } + /// + /// The subtitle to render on the reader + /// + public string Subtitle { get; set; } + /// + /// Series Title + /// + /// Usually just series name, but can include chapter title + public string Title { get; set; } - public string ChapterNumber { get; set; } - public string VolumeNumber { get; set; } - public int VolumeId { get; set; } - public string SeriesName { get; set; } - public MangaFormat SeriesFormat { get; set; } - public int SeriesId { get; set; } - public int LibraryId { get; set; } - public LibraryType LibraryType { get; set; } - public string ChapterTitle { get; set; } = string.Empty; - public int Pages { get; set; } - public string FileName { get; set; } - public bool IsSpecial { get; set; } - - } } diff --git a/API/Entities/ReadingListItem.cs b/API/Entities/ReadingListItem.cs index 002911131..a7c7982b2 100644 --- a/API/Entities/ReadingListItem.cs +++ b/API/Entities/ReadingListItem.cs @@ -1,6 +1,5 @@ namespace API.Entities { - //[Index(nameof(SeriesId), nameof(VolumeId), nameof(ChapterId), IsUnique = true)] public class ReadingListItem { public int Id { get; init; } @@ -16,7 +15,7 @@ public ReadingList ReadingList { get; set; } public int ReadingListId { get; set; } - // Idea, keep these for easy join statements + // Keep these for easy join statements public Series Series { get; set; } public Volume Volume { get; set; } public Chapter Chapter { get; set; } diff --git a/API/Helpers/CacheHelper.cs b/API/Helpers/CacheHelper.cs index 28eb59a63..3f4382d57 100644 --- a/API/Helpers/CacheHelper.cs +++ b/API/Helpers/CacheHelper.cs @@ -66,9 +66,9 @@ public class CacheHelper : ICacheHelper /// /// Has the file been modified since last scan or is user forcing an update /// - /// - /// - /// + /// Last time the scan was performed on this file + /// Should we ignore any logic and force this to return true + /// The file in question /// public bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile) { @@ -76,10 +76,6 @@ public class CacheHelper : ICacheHelper if (forceUpdate) return true; return _fileService.HasFileBeenModifiedSince(firstFile.FilePath, lastScan) || _fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified); - // return firstFile != null && - // (!forceUpdate && - // !(_fileService.HasFileBeenModifiedSince(firstFile.FilePath, lastScan) - // || _fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified))); } /// diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 8aa57d45b..f9b85fb59 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -189,6 +189,7 @@ namespace API.Services /// This always creates a thumbnail /// /// File name to use based on context of entity. + /// Where to output the file, defaults to covers directory /// public string GetCoverImage(string archivePath, string fileName, string outputDirectory) { @@ -261,16 +262,16 @@ namespace API.Services archive.Entries.Any(e => e.FullName.Contains(Path.AltDirectorySeparatorChar) && !Parser.Parser.HasBlacklistedFolderInPath(e.FullName)); } - // TODO: Refactor CreateZipForDownload to return the temp file so we can stream it from temp /// - /// + /// Creates a zip file form the listed files and outputs to the temp folder. /// - /// + /// List of files to be zipped up. Should be full file paths. /// Temp folder name to use for preparing the files. Will be created and deleted - /// + /// All bytes for the given file in a Tuple /// public async Task> CreateZipForDownload(IEnumerable files, string tempFolder) { + // TODO: Refactor CreateZipForDownload to return the temp file so we can stream it from temp var dateString = DateTime.Now.ToShortDateString().Replace("/", "_"); var tempLocation = Path.Join(_directoryService.TempDirectory, $"{tempFolder}_{dateString}"); diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 660c22b4a..2ff50ade4 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -686,6 +686,7 @@ namespace API.Services /// /// /// Name of the new file. + /// Where to output the file, defaults to covers directory /// public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory) { diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index a1ab70fd0..c453240c4 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -25,6 +25,7 @@ public interface IImageService /// Converts the passed image to webP and outputs it in the same directory /// /// Full path to the image to convert + /// Where to output the file /// File of written webp image Task ConvertToWebP(string filePath, string outputPath); } @@ -89,6 +90,7 @@ public class ImageService : IImageService /// /// Stream to write to disk. Ensure this is rewinded. /// filename to save as without extension + /// Where to output the file, defaults to covers directory /// File name with extension of the file. This will always write to public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory) { diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 7bb34e159..5f3f37d8b 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -35,6 +35,7 @@ public interface IMetadataService /// /// /// + /// Overrides any cache logic and forces execution Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = true); } @@ -278,6 +279,7 @@ public class MetadataService : IMetadataService /// /// /// + /// Overrides any cache logic and forces execution public async Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = true) { var sw = Stopwatch.StartNew(); diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 18de289cd..1c993a487 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -9,6 +9,7 @@ using API.Data.Repositories; using API.DTOs; using API.DTOs.Reader; using API.Entities; +using API.Entities.Enums; using API.Extensions; using API.SignalR; using Kavita.Common; @@ -20,8 +21,8 @@ public interface IReaderService { Task MarkSeriesAsRead(AppUser user, int seriesId); Task MarkSeriesAsUnread(AppUser user, int seriesId); - void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable chapters); - void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable chapters); + Task MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable chapters); + Task MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable chapters); Task SaveReadingProgress(ProgressDto progressDto, int userId); Task CapPageToChapter(int chapterId, int page); Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); @@ -30,6 +31,7 @@ public interface IReaderService Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber); Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber); HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub); + string FormatChapterName(LibraryType libraryType, bool includeHash = false, bool includeSpace = false); } public class ReaderService : IReaderService @@ -71,7 +73,7 @@ public class ReaderService : IReaderService user.Progresses ??= new List(); foreach (var volume in volumes) { - MarkChaptersAsRead(user, seriesId, volume.Chapters); + await MarkChaptersAsRead(user, seriesId, volume.Chapters); } _unitOfWork.UserRepository.Update(user); @@ -88,7 +90,7 @@ public class ReaderService : IReaderService user.Progresses ??= new List(); foreach (var volume in volumes) { - MarkChaptersAsUnread(user, seriesId, volume.Chapters); + await MarkChaptersAsUnread(user, seriesId, volume.Chapters); } _unitOfWork.UserRepository.Update(user); @@ -100,7 +102,7 @@ public class ReaderService : IReaderService /// /// /// - public void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable chapters) + public async Task MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable chapters) { foreach (var chapter in chapters) { @@ -115,12 +117,17 @@ public class ReaderService : IReaderService SeriesId = seriesId, ChapterId = chapter.Id }); + await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate, + MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName, seriesId, chapter.VolumeId, chapter.Id, chapter.Pages)); } else { userProgress.PagesRead = chapter.Pages; userProgress.SeriesId = seriesId; userProgress.VolumeId = chapter.VolumeId; + + await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate, + MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName, userProgress.SeriesId, userProgress.VolumeId, userProgress.ChapterId, chapter.Pages)); } } } @@ -131,7 +138,7 @@ public class ReaderService : IReaderService /// /// /// - public void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable chapters) + public async Task MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable chapters) { foreach (var chapter in chapters) { @@ -142,6 +149,9 @@ public class ReaderService : IReaderService userProgress.PagesRead = 0; userProgress.SeriesId = seriesId; userProgress.VolumeId = chapter.VolumeId; + + await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate, + MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName, userProgress.SeriesId, userProgress.VolumeId, userProgress.ChapterId, 0)); } } @@ -321,6 +331,8 @@ public class ReaderService : IReaderService currentChapter.Range, dto => dto.Range); if (chapterId > 0) return chapterId; } else if (double.Parse(firstChapter.Number) > double.Parse(currentChapter.Number)) return firstChapter.Id; + // If we are the last chapter and next volume is there, we should try to use it (unless it's volume 0) + else if (double.Parse(firstChapter.Number) == 0) return firstChapter.Id; } // If we are the last volume and we didn't find any next volume, loop back to volume 0 and give the first chapter @@ -485,7 +497,7 @@ public class ReaderService : IReaderService var chapters = volume.Chapters .OrderBy(c => float.Parse(c.Number)) .Where(c => !c.IsSpecial && Parser.Parser.MaxNumberFromRange(c.Range) <= chapterNumber); - MarkChaptersAsRead(user, volume.SeriesId, chapters); + await MarkChaptersAsRead(user, volume.SeriesId, chapters); } } @@ -494,7 +506,7 @@ public class ReaderService : IReaderService var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List { seriesId }, true); foreach (var volume in volumes.OrderBy(v => v.Number).Where(v => v.Number <= volumeNumber && v.Number > 0)) { - MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters); + await MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters); } } @@ -540,4 +552,29 @@ public class ReaderService : IReaderService AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F)) }; } + + /// + /// Formats a Chapter name based on the library it's in + /// + /// + /// For comics only, includes a # which is used for numbering on cards + /// Add a space at the end of the string. if includeHash and includeSpace are true, only hash will be at the end. + /// + public string FormatChapterName(LibraryType libraryType, bool includeHash = false, bool includeSpace = false) + { + switch(libraryType) + { + case LibraryType.Manga: + return "Chapter" + (includeSpace ? " " : string.Empty); + case LibraryType.Comic: + if (includeHash) { + return "Issue #"; + } + return "Issue" + (includeSpace ? " " : string.Empty); + case LibraryType.Book: + return "Book" + (includeSpace ? " " : string.Empty); + default: + throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null); + } + } } diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index a4ca32c05..6abb8b581 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -105,7 +105,6 @@ public class SeriesService : ISeriesService series.Metadata.LanguageLocked = true; } - series.Metadata.CollectionTags ??= new List(); UpdateRelatedList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, (tag) => { @@ -200,10 +199,11 @@ public class SeriesService : ISeriesService return false; } - // TODO: Move this to a helper so we can easily test + private static void UpdateRelatedList(ICollection tags, Series series, IReadOnlyCollection allTags, Action handleAdd) { + // TODO: Move UpdateRelatedList to a helper so we can easily test if (tags == null) return; // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different var existingTags = series.Metadata.CollectionTags.ToList(); diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index a129a11b3..3a3498a40 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using API.Data; @@ -162,8 +163,12 @@ public class TaskScheduler : ITaskScheduler public void ScanLibrary(int libraryId) { + if (HasAlreadyEnqueuedTask("ScannerService","ScanLibrary", new object[] {libraryId})) + { + _logger.LogInformation("A duplicate request to scan library for library occured. Skipping"); + return; + } _logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId); - // TODO: If a library scan is already queued up for libraryId, don't do anything BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId)); // When we do a scan, force cache to re-unpack in case page numbers change BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory()); @@ -176,24 +181,48 @@ public class TaskScheduler : ITaskScheduler public void RefreshMetadata(int libraryId, bool forceUpdate = true) { + if (HasAlreadyEnqueuedTask("MetadataService","RefreshMetadata", new object[] {libraryId, forceUpdate})) + { + _logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping"); + return; + } + _logger.LogInformation("Enqueuing library metadata refresh for: {LibraryId}", libraryId); BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate)); } public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false) { + if (HasAlreadyEnqueuedTask("MetadataService","RefreshMetadataForSeries", new object[] {libraryId, seriesId, forceUpdate})) + { + _logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping"); + return; + } + _logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId); BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate)); } public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) { + if (HasAlreadyEnqueuedTask("ScannerService", "ScanSeries", new object[] {libraryId, seriesId, forceUpdate})) + { + _logger.LogInformation("A duplicate request to scan series occured. Skipping"); + return; + } + _logger.LogInformation("Enqueuing series scan for: {SeriesId}", seriesId); BackgroundJob.Enqueue(() => _scannerService.ScanSeries(libraryId, seriesId, CancellationToken.None)); } public void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false) { + if (HasAlreadyEnqueuedTask("WordCountAnalyzerService", "ScanSeries", new object[] {libraryId, seriesId, forceUpdate})) + { + _logger.LogInformation("A duplicate request to scan series occured. Skipping"); + return; + } + _logger.LogInformation("Enqueuing analyze files scan for: {SeriesId}", seriesId); BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(libraryId, seriesId, forceUpdate)); } @@ -212,4 +241,21 @@ public class TaskScheduler : ITaskScheduler var update = await _versionUpdaterService.CheckForUpdate(); await _versionUpdaterService.PushUpdate(update); } + + /// + /// Checks if this same invocation is already enqueued + /// + /// Method name that was enqueued + /// Class name the method resides on + /// object[] of arguments in the order they are passed to enqueued job + /// Queue to check against. Defaults to "default" + /// + private static bool HasAlreadyEnqueuedTask(string className, string methodName, object[] args, string queue = "default") + { + var enqueuedJobs = JobStorage.Current.GetMonitoringApi().EnqueuedJobs(queue, 0, int.MaxValue); + return enqueuedJobs.Any(j => j.Value.InEnqueuedState && + j.Value.Job.Method.DeclaringType != null && j.Value.Job.Args.SequenceEqual(args) && + j.Value.Job.Method.Name.Equals(methodName) && + j.Value.Job.Method.DeclaringType.Name.Equals(className)); + } } diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index fb830da03..785f9ad46 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -40,6 +40,7 @@ namespace API.Services.Tasks.Scanner /// Logger of the parent class that invokes this /// Directory Service /// ReadingItemService Service for extracting information on a number of formats + /// For firing off SignalR events public ParseScannedFiles(ILogger logger, IDirectoryService directoryService, IReadingItemService readingItemService, IEventHub eventHub) { @@ -251,6 +252,7 @@ namespace API.Services.Tasks.Scanner /// /// Type of library. Used for selecting the correct file extensions to search for and parsing files /// The folders to scan. By default, this should be library.Folders, however it can be overwritten to restrict folders + /// Name of the Library /// public async Task>> ScanLibrariesForSeries(LibraryType libraryType, IEnumerable folders, string libraryName) { diff --git a/API/Startup.cs b/API/Startup.cs index c203c6b31..aa57fd70d 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -22,6 +22,7 @@ using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.ResponseCompression; @@ -30,6 +31,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; using Microsoft.OpenApi.Models; using TaskScheduler = API.Services.TaskScheduler; @@ -230,7 +232,13 @@ namespace API app.UseStaticFiles(new StaticFileOptions { - ContentTypeProvider = new FileExtensionContentTypeProvider() + ContentTypeProvider = new FileExtensionContentTypeProvider(), + HttpsCompression = HttpsCompressionMode.Compress, + OnPrepareResponse = ctx => + { + const int durationInSeconds = 60 * 60 * 24; + ctx.Context.Response.Headers[HeaderNames.CacheControl] = "public,max-age=" + durationInSeconds; + } }); app.Use(async (context, next) => diff --git a/Kavita.Common/EnvironmentInfo/BuildInfo.cs b/Kavita.Common/EnvironmentInfo/BuildInfo.cs index b6403b729..116c07866 100644 --- a/Kavita.Common/EnvironmentInfo/BuildInfo.cs +++ b/Kavita.Common/EnvironmentInfo/BuildInfo.cs @@ -1,56 +1,33 @@ using System; -using System.IO; using System.Linq; using System.Reflection; -namespace Kavita.Common.EnvironmentInfo +namespace Kavita.Common.EnvironmentInfo; + +public static class BuildInfo { - public static class BuildInfo + static BuildInfo() { - static BuildInfo() + var assembly = Assembly.GetExecutingAssembly(); + + Version = assembly.GetName().Version; + + var attributes = assembly.GetCustomAttributes(true); + + Branch = "unknown"; + + var config = attributes.OfType().FirstOrDefault(); + if (config != null) { - var assembly = Assembly.GetExecutingAssembly(); - - Version = assembly.GetName().Version; - - var attributes = assembly.GetCustomAttributes(true); - - Branch = "unknown"; - - var config = attributes.OfType().FirstOrDefault(); - if (config != null) - { - Branch = config.Configuration; // NOTE: This is not helpful, better to have main/develop branch - } - - Release = $"{Version}-{Branch}"; + Branch = config.Configuration; // NOTE: This is not helpful, better to have main/develop branch } - public static string AppName { get; } = "Kavita"; - - public static Version Version { get; } - public static string Branch { get; } - public static string Release { get; } - - public static DateTime BuildDateTime - { - get - { - var fileLocation = Assembly.GetCallingAssembly().Location; - return new FileInfo(fileLocation).LastWriteTimeUtc; - } - } - - public static bool IsDebug - { - get - { -#if DEBUG - return true; -#else - return false; -#endif - } - } + Release = $"{Version}-{Branch}"; } + + public static string AppName { get; } = "Kavita"; + + public static Version Version { get; } + public static string Branch { get; } + public static string Release { get; } } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index cee5ea59e..094a7bb30 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -2,7 +2,7 @@ import { Injectable, OnDestroy } from '@angular/core'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { Subject } from 'rxjs'; -import { finalize, take, takeWhile } from 'rxjs/operators'; +import { take } from 'rxjs/operators'; import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component'; import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component'; import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component'; diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 8d6986259..e7927a9f1 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -1,8 +1,9 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { Location } from '@angular/common'; +import { Router } from '@angular/router'; import { environment } from 'src/environments/environment'; import { ChapterInfo } from '../manga-reader/_models/chapter-info'; -import { UtilityService } from '../shared/_services/utility.service'; import { Chapter } from '../_models/chapter'; import { HourEstimateRange } from '../_models/hour-estimate-range'; import { MangaFormat } from '../_models/manga-format'; @@ -23,7 +24,7 @@ export class ReaderService { // Override background color for reader and restore it onDestroy private originalBodyColor!: string; - constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } + constructor(private httpClient: HttpClient, private router: Router, private location: Location) { } getNavigationArray(libraryId: number, seriesId: number, chapterId: number, format: MangaFormat) { if (format === undefined) format = MangaFormat.ARCHIVE; @@ -144,7 +145,6 @@ export class ReaderService { return this.httpClient.get(this.baseUrl + 'reader/continue-point?seriesId=' + seriesId); } - // TODO: Cache this information getTimeLeft(seriesId: number) { return this.httpClient.get(this.baseUrl + 'reader/time-left?seriesId=' + seriesId); } @@ -240,4 +240,15 @@ export class ReaderService { checkFullscreenMode() { return document.fullscreenElement != null; } + + /** + * Closes the reader and causes a redirection + */ + closeReader(readingListMode: boolean = false, readingListId: number = 0) { + if (readingListMode) { + this.router.navigateByUrl('lists/' + readingListId); + } else { + this.location.back(); + } + } } diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.html b/UI/Web/src/app/admin/manage-library/manage-library.component.html index 7cde9794d..4e731acc3 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.html +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.html @@ -4,13 +4,10 @@
    -
  • +
  • {{library.name}}  -
    @@ -18,7 +15,7 @@

    -
    Type: {{libraryType(library.type)}}
    +
    Type: {{library.type | libraryType}}
    Shared Folders: {{library.folders.length + ' folders'}}
    Last Scanned: diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.ts b/UI/Web/src/app/admin/manage-library/manage-library.component.ts index f9791f561..de3a24697 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.ts +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.ts @@ -1,8 +1,8 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { Subject } from 'rxjs'; -import { distinctUntilChanged, filter, take, takeLast, takeUntil } from 'rxjs/operators'; +import { distinctUntilChanged, filter, take, takeUntil } from 'rxjs/operators'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { NotificationProgressEvent } from 'src/app/_models/events/notification-progress-event'; import { ScanSeriesEvent } from 'src/app/_models/events/scan-series-event'; @@ -14,7 +14,8 @@ import { LibraryEditorModalComponent } from '../_modals/library-editor-modal/lib @Component({ selector: 'app-manage-library', templateUrl: './manage-library.component.html', - styleUrls: ['./manage-library.component.scss'] + styleUrls: ['./manage-library.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class ManageLibraryComponent implements OnInit, OnDestroy { @@ -31,7 +32,7 @@ export class ManageLibraryComponent implements OnInit, OnDestroy { constructor(private modalService: NgbModal, private libraryService: LibraryService, private toastr: ToastrService, private confirmService: ConfirmService, - private hubService: MessageHubService) { } + private hubService: MessageHubService, private readonly cdRef: ChangeDetectorRef) { } ngOnInit(): void { this.getLibraries(); @@ -56,6 +57,7 @@ export class ManageLibraryComponent implements OnInit, OnDestroy { const existingLibrary = this.libraries.find(lib => lib.id === libId); if (existingLibrary !== undefined) { existingLibrary.lastScanned = newLibrary?.lastScanned || existingLibrary.lastScanned; + this.cdRef.markForCheck(); } }); }); @@ -79,9 +81,11 @@ export class ManageLibraryComponent implements OnInit, OnDestroy { getLibraries() { this.loading = true; + this.cdRef.markForCheck(); this.libraryService.getLibraries().pipe(take(1)).subscribe(libraries => { this.libraries = libraries; this.loading = false; + this.cdRef.markForCheck(); }); } @@ -109,6 +113,7 @@ export class ManageLibraryComponent implements OnInit, OnDestroy { this.deletionInProgress = true; this.libraryService.delete(library.id).pipe(take(1)).subscribe(() => { this.deletionInProgress = false; + this.cdRef.markForCheck(); this.getLibraries(); this.toastr.success('Library has been removed'); }); @@ -120,16 +125,4 @@ export class ManageLibraryComponent implements OnInit, OnDestroy { this.toastr.info('A scan has been queued for ' + library.name); }); } - - libraryType(libraryType: LibraryType) { - switch(libraryType) { - case LibraryType.Book: - return 'Book'; - case LibraryType.Comic: - return 'Comic'; - case LibraryType.Manga: - return 'Manga'; - } - } - } diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html index 9a10e7ce8..1902d68e6 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html @@ -84,7 +84,7 @@ tabindex="-1" [ngStyle]="{height: PageHeightForPagination}">
    -
    +
    = new Stack(); // TODO: See if continuousChaptersStack can be moved into reader service so we can reduce code duplication between readers (and also use ChapterInfo with it instead) + continuousChaptersStack: Stack = new Stack(); /** * Belongs to the drawer component @@ -383,10 +385,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private renderer: Renderer2, private navService: NavService, private toastr: ToastrService, private domSanitizer: DomSanitizer, private bookService: BookService, private memberService: MemberService, private scrollService: ScrollService, private utilityService: UtilityService, private libraryService: LibraryService, - @Inject(DOCUMENT) private document: Document, private themeService: ThemeService) { + @Inject(DOCUMENT) private document: Document, private themeService: ThemeService, private readonly cdRef: ChangeDetectorRef) { this.navService.hideNavBar(); this.themeService.clearThemes(); this.navService.hideSideNav(); + this.cdRef.markForCheck(); } /** @@ -411,7 +414,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Highlight the current chapter we are on if (Object.keys(this.pageAnchors).length !== 0) { // get the height of the document so we can capture markers that are halfway on the document viewport - const verticalOffset = this.scrollService.scrollPosition + (this.document.body.offsetHeight / 2); + const verticalOffset = this.reader.nativeElement?.scrollTop || (this.scrollService.scrollPosition + (this.document.body.offsetHeight / 2)); const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset); if (alreadyReached.length > 0) { @@ -419,6 +422,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } else { this.currentPageAnchor = ''; } + + this.cdRef.markForCheck(); } // Find the element that is on screen to bookmark against @@ -439,7 +444,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (!this.incognitoMode) { this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, tempPageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); } - } ngOnDestroy(): void { @@ -481,6 +485,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.readingListMode = true; this.readingListId = parseInt(readingListId, 10); } + this.cdRef.markForCheck(); this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(hasProgress => { @@ -504,18 +509,20 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.nextChapterDisabled = false; this.prevChapterDisabled = false; this.nextChapterPrefetched = false; + this.cdRef.markForCheck(); this.bookService.getBookInfo(this.chapterId).subscribe(info => { - this.bookTitle = info.bookTitle; - if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) { // Redirect to the manga reader. const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId); this.router.navigate(this.readerService.getNavigationArray(info.libraryId, info.seriesId, this.chapterId, info.seriesFormat), {queryParams: params}); return; } + + this.bookTitle = info.bookTitle; + this.cdRef.markForCheck(); forkJoin({ chapter: this.seriesService.getChapter(this.chapterId), @@ -527,23 +534,21 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.maxPages = results.chapter.pages; this.chapters = results.chapters; this.pageNum = results.progress.pageNum; + this.cdRef.markForCheck(); if (results.progress.bookScrollId) this.lastSeenScrollPartPath = results.progress.bookScrollId; - - this.continuousChaptersStack.push(this.chapterId); this.libraryService.getLibraryType(this.libraryId).pipe(take(1)).subscribe(type => { this.libraryType = type; }); - // We need to think about if the user modified this and this function call is a continuous reader one - //this.updateLayoutMode(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default); this.updateImagesWithHeight(); if (this.pageNum >= this.maxPages) { this.pageNum = this.maxPages - 1; + this.cdRef.markForCheck(); this.saveProgress(); } @@ -552,6 +557,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) { this.nextChapterDisabled = true; this.nextChapterPrefetched = true; + this.cdRef.markForCheck(); return; } this.setPageNum(this.pageNum); @@ -561,6 +567,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) { this.prevChapterDisabled = true; this.prevChapterPrefetched = true; // If there is no prev chapter, then mark it as prefetched + this.cdRef.markForCheck(); return; } this.setPageNum(this.pageNum); @@ -577,20 +584,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } @HostListener('window:resize', ['$event']) - onResize(event: any){ - // Update the window Height - this.updateWidthAndHeightCalcs(); - - const resumeElement = this.getFirstVisibleElementXPath(); - if (this.layoutMode !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { - this.scrollTo(resumeElement); // This works pretty well, but not perfect - } - } - @HostListener('window:orientationchange', ['$event']) - onOrientationChange() { + onResize(){ // Update the window Height this.updateWidthAndHeightCalcs(); + const resumeElement = this.getFirstVisibleElementXPath(); if (this.layoutMode !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { this.scrollTo(resumeElement); // This works pretty well, but not perfect @@ -616,6 +614,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + closeReader() { + this.readerService.closeReader(this.readingListMode, this.readingListId); + } + sortElements(a: Element, b: Element) { const aTop = a.getBoundingClientRect().top; const bTop = b.getBoundingClientRect().top; @@ -646,6 +648,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.prevPageDisabled) { return; } this.isLoading = true; + this.cdRef.markForCheck(); this.continuousChaptersStack.pop(); const prevChapter = this.continuousChaptersStack.peek(); if (prevChapter != this.chapterId) { @@ -657,6 +660,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } if (this.prevChapterPrefetched && this.prevChapterId === CHAPTER_ID_DOESNT_EXIST) { + this.isLoading = false; + this.cdRef.markForCheck(); return; } @@ -677,8 +682,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Load chapter Id onto route but don't reload const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); window.history.replaceState({}, '', newRoute); - this.init(); this.toastr.info(direction + ' ' + this.utilityService.formatChapterName(this.libraryType).toLowerCase() + ' loaded', '', {timeOut: 3000}); + this.cdRef.markForCheck(); + this.init(); } else { // This will only happen if no actual chapter can be found this.toastr.warning('Could not find ' + direction.toLowerCase() + ' ' + this.utilityService.formatChapterName(this.libraryType).toLowerCase()); @@ -688,6 +694,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } else { this.nextPageDisabled = true; } + this.cdRef.markForCheck(); } } @@ -696,15 +703,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.loadPage('id("' + event.part + '")'); } - closeReader() { - if (this.readingListMode) { - this.router.navigateByUrl('lists/' + this.readingListId); - } else { - this.location.back(); - } - } - - /** * Adds a click handler for any anchors that have 'kavita-page'. If 'kavita-page' present, changes page to kavita-page and optionally passes a part value * from 'kavita-part', which will cause the reader to scroll to the marker. @@ -775,14 +773,15 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { loadPage(part?: string | undefined, scrollTop?: number | undefined) { this.isLoading = true; + this.cdRef.markForCheck(); this.bookService.getBookPage(this.chapterId, this.pageNum).pipe(take(1)).subscribe(content => { this.page = this.domSanitizer.bypassSecurityTrustHtml(content); // PERF: Potential optimization to prefetch next/prev page and store in localStorage - + this.cdRef.markForCheck(); + setTimeout(() => { this.addLinkClickHandlers(); this.updateReaderStyles(this.pageStyles); - this.updateReaderStyles(this.pageStyles); const imgs = this.readingSectionElemRef.nativeElement.querySelectorAll('img'); if (imgs === null || imgs.length === 0) { @@ -805,7 +804,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Applies a max-height inline css property on each image in the page if the layout mode is column-based, else it removes the property */ updateImagesWithHeight() { - const images = this.readingSectionElemRef?.nativeElement.querySelectorAll('img') || []; if (this.layoutMode !== BookPageLayoutMode.Default) { @@ -822,6 +820,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { setupPage(part?: string | undefined, scrollTop?: number | undefined) { this.isLoading = false; + this.cdRef.markForCheck(); // Virtual Paging stuff this.updateWidthAndHeightCalcs(); @@ -853,6 +852,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // we need to click the document before arrow keys will scroll down. this.reader.nativeElement.focus(); this.saveProgress(); + this.isLoading = false; + this.cdRef.markForCheck(); } @@ -868,21 +869,24 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { setPageNum(pageNum: number) { this.pageNum = Math.max(Math.min(pageNum, this.maxPages), 0); + this.cdRef.markForCheck(); if (this.pageNum >= this.maxPages - 10) { // Tell server to cache the next chapter - if (!this.nextChapterPrefetched && this.nextChapterId !== CHAPTER_ID_DOESNT_EXIST) { // && !this.nextChapterDisabled + if (!this.nextChapterPrefetched && this.nextChapterId !== CHAPTER_ID_DOESNT_EXIST) { this.readerService.getChapterInfo(this.nextChapterId).pipe(take(1), catchError(err => { this.nextChapterDisabled = true; + this.cdRef.markForCheck(); return of(null); })).subscribe(res => { this.nextChapterPrefetched = true; }); } } else if (this.pageNum <= 10) { - if (!this.prevChapterPrefetched && this.prevChapterId !== CHAPTER_ID_DOESNT_EXIST) { // && !this.prevChapterDisabled + if (!this.prevChapterPrefetched && this.prevChapterId !== CHAPTER_ID_DOESNT_EXIST) { this.readerService.getChapterInfo(this.prevChapterId).pipe(take(1), catchError(err => { this.prevChapterDisabled = true; + this.cdRef.markForCheck(); return of(null); })).subscribe(res => { this.prevChapterPrefetched = true; @@ -1105,6 +1109,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Recalculate if bottom action bar is needed this.scrollbarNeeded = this.readingHtml.nativeElement.clientHeight > this.reader.nativeElement.clientHeight; + this.cdRef.markForCheck(); } toggleDrawer() { @@ -1113,6 +1118,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.immersiveMode) { this.actionBarVisible = false; } + this.cdRef.markForCheck(); } scrollTo(partSelector: string) { @@ -1133,7 +1139,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.layoutMode === BookPageLayoutMode.Default) { const fromTopOffset = element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET; // We need to use a delay as webkit browsers (aka apple devices) don't always have the document rendered by this point - setTimeout(() => this.scrollService.scrollTo(fromTopOffset, this.reader.nativeElement), 10); + setTimeout(() => this.scrollService.scrollTo(fromTopOffset, this.reader.nativeElement), 10); // BUG: This is broken } else { setTimeout(() => (element as Element).scrollIntoView({'block': 'start', 'inline': 'start'})); } @@ -1184,11 +1190,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.isFullscreen) { this.readerService.exitFullscreen(() => { this.isFullscreen = false; + this.cdRef.markForCheck(); this.renderer.removeStyle(this.reader.nativeElement, 'background'); }); } else { this.readerService.enterFullscreen(this.reader.nativeElement, () => { this.isFullscreen = true; + this.cdRef.markForCheck(); // HACK: This is a bug with how browsers change the background color for fullscreen mode this.renderer.setStyle(this.reader.nativeElement, 'background', this.themeService.getCssVariable('--bs-body-color')); if (!this.darkMode) { @@ -1200,6 +1208,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { updateLayoutMode(mode: BookPageLayoutMode) { this.layoutMode = mode; + this.cdRef.markForCheck(); // Remove any max-heights from column layout this.updateImagesWithHeight(); @@ -1209,7 +1218,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { setTimeout(() => this.updateLayoutMode(this.layoutMode), 10); return; } - setTimeout(() => {this.scrollbarNeeded = this.readingHtml.nativeElement.clientHeight > this.reader.nativeElement.clientHeight;}); + setTimeout(() => { + this.scrollbarNeeded = this.readingHtml.nativeElement.clientHeight > this.reader.nativeElement.clientHeight; + this.cdRef.markForCheck(); + }); // When I switch layout, I might need to resume the progress point. if (mode === BookPageLayoutMode.Default) { @@ -1220,6 +1232,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { updateReadingDirection(readingDirection: ReadingDirection) { this.readingDirection = readingDirection; + this.cdRef.markForCheck(); } updateImmersiveMode(immersiveMode: boolean) { @@ -1227,13 +1240,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.immersiveMode && !this.drawerOpen) { this.actionBarVisible = false; } - this.updateReadingSectionHeight(); + this.cdRef.markForCheck(); } updateReadingSectionHeight() { setTimeout(() => { - //console.log('setting height on ', this.readingSectionElemRef) if (this.immersiveMode) { this.renderer.setStyle(this.readingSectionElemRef, 'height', 'calc(var(--vh, 1vh) * 100)', RendererStyleFlags2.Important); } else { @@ -1263,6 +1275,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { setupPageAnchors() { this.pageAnchors = {}; this.currentPageAnchor = ''; + this.cdRef.markForCheck(); const ids = this.chapters.map(item => item.children).flat().filter(item => item.page === this.pageNum).map(item => item.part).filter(item => item.length > 0); if (ids.length > 0) { const elems = this.getPageMarkers(ids); @@ -1275,6 +1288,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Settings Handlers showPaginationOverlay(clickToPaginate: boolean) { this.clickToPaginate = clickToPaginate; + this.cdRef.markForCheck(); this.clearTimeout(this.clickToPaginateVisualOverlayTimeout2); if (!clickToPaginate) { return; } @@ -1293,6 +1307,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { showClickToPaginateVisualOverlay() { this.clickToPaginateVisualOverlay = true; + this.cdRef.markForCheck(); if (this.clickToPaginateVisualOverlay && this.clickToPaginateVisualOverlayTimeout !== undefined) { clearTimeout(this.clickToPaginateVisualOverlayTimeout); @@ -1300,6 +1315,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } this.clickToPaginateVisualOverlayTimeout = setTimeout(() => { this.clickToPaginateVisualOverlay = false; + this.cdRef.markForCheck(); }, 1000); } @@ -1338,8 +1354,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { Math.abs(this.mousePosition.y - event.screenY) <= mouseOffset ) { this.actionBarVisible = !this.actionBarVisible; + this.cdRef.markForCheck(); } - } mouseDown($event: MouseEvent) { diff --git a/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.ts b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.ts index 8c4344d98..40a00ce43 100644 --- a/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.ts +++ b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.ts @@ -1,5 +1,5 @@ import { DOCUMENT } from '@angular/common'; -import { Component, EventEmitter, Inject, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Inject, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { Subject, take, takeUntil } from 'rxjs'; import { BookPageLayoutMode } from 'src/app/_models/book-page-layout-mode'; @@ -60,7 +60,8 @@ const mobileBreakpointMarginOverride = 700; @Component({ selector: 'app-reader-settings', templateUrl: './reader-settings.component.html', - styleUrls: ['./reader-settings.component.scss'] + styleUrls: ['./reader-settings.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class ReaderSettingsComponent implements OnInit, OnDestroy { /** @@ -131,12 +132,14 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { constructor(private bookService: BookService, private accountService: AccountService, - @Inject(DOCUMENT) private document: Document, private themeService: ThemeService) {} + @Inject(DOCUMENT) private document: Document, private themeService: ThemeService, + private readonly cdRef: ChangeDetectorRef) {} ngOnInit(): void { this.fontFamilies = this.bookService.getFontFamilies(); this.fontOptions = this.fontFamilies.map(f => f.title); + this.cdRef.markForCheck(); this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (user) { @@ -211,6 +214,7 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { this.setTheme(this.user.preferences.bookReaderThemeName || this.themeService.defaultBookTheme); + this.cdRef.markForCheck(); // Emit first time so book reader gets the setting this.readingDirection.emit(this.readingDirectionModel); @@ -241,6 +245,7 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { } this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily); + this.cdRef.markForCheck(); this.styleUpdate.emit(this.pageStyles); } @@ -264,12 +269,12 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { 'margin-right': margin || this.pageStyles['margin-right'] || defaultMargin, 'line-height': lineHeight || this.pageStyles['line-height'] || '100%' }; - } setTheme(themeName: string) { const theme = this.themes.find(t => t.name === themeName); this.activeTheme = theme; + this.cdRef.markForCheck(); this.colorThemeUpdate.emit(theme); } @@ -280,11 +285,13 @@ export class ReaderSettingsComponent implements OnInit, OnDestroy { this.readingDirectionModel = ReadingDirection.LeftToRight; } + this.cdRef.markForCheck(); this.readingDirection.emit(this.readingDirectionModel); } toggleFullscreen() { this.isFullscreen = !this.isFullscreen; + this.cdRef.markForCheck(); this.fullscreen.emit(); } } diff --git a/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.html b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.html index 6d43689df..d59d7823c 100644 --- a/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.html +++ b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.html @@ -1,5 +1,4 @@
    -
    This book does not have Table of Contents set in the metadata or a toc file
    diff --git a/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.ts b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.ts index 709e4d645..0612f60d0 100644 --- a/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.ts +++ b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.ts @@ -1,11 +1,12 @@ -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { Subject } from 'rxjs'; import { BookChapterItem } from '../_models/book-chapter-item'; @Component({ selector: 'app-table-of-contents', templateUrl: './table-of-contents.component.html', - styleUrls: ['./table-of-contents.component.scss'] + styleUrls: ['./table-of-contents.component.scss'], + changeDetection: ChangeDetectionStrategy.Default }) export class TableOfContentsComponent implements OnInit, OnDestroy { @@ -16,11 +17,8 @@ export class TableOfContentsComponent implements OnInit, OnDestroy { @Output() loadChapter: EventEmitter<{pageNum: number, part: string}> = new EventEmitter(); - - private onDestroy: Subject = new Subject(); - pageAnchors: {[n: string]: number } = {}; constructor() {} @@ -44,5 +42,4 @@ export class TableOfContentsComponent implements OnInit, OnDestroy { loadChapterPage(pageNum: number, part: string) { this.loadChapter.emit({pageNum, part}); } - } diff --git a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html index 0fa6d0888..23f792ade 100644 --- a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html +++ b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html @@ -15,7 +15,7 @@
      -
    • +
    • {{collectionTag.title}}
    • No collections created yet
    • diff --git a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.ts b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.ts index be28e41a3..e04e36ad6 100644 --- a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.ts +++ b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { FormGroup, FormControl } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; @@ -10,7 +10,8 @@ import { CollectionTagService } from 'src/app/_services/collection-tag.service'; selector: 'app-bulk-add-to-collection', templateUrl: './bulk-add-to-collection.component.html', encapsulation: ViewEncapsulation.None, // This is needed as per the bootstrap modal documentation to get styles to work. - styleUrls: ['./bulk-add-to-collection.component.scss'] + styleUrls: ['./bulk-add-to-collection.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class BulkAddToCollectionComponent implements OnInit { @@ -27,10 +28,13 @@ export class BulkAddToCollectionComponent implements OnInit { loading: boolean = false; listForm: FormGroup = new FormGroup({}); + collectionTitleTrackby = (index: number, item: CollectionTag) => `${item.title}`; + @ViewChild('title') inputElem!: ElementRef; - constructor(private modal: NgbActiveModal, private collectionService: CollectionTagService, private toastr: ToastrService) { } + constructor(private modal: NgbActiveModal, private collectionService: CollectionTagService, + private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { } ngOnInit(): void { @@ -38,9 +42,11 @@ export class BulkAddToCollectionComponent implements OnInit { this.listForm.addControl('filterQuery', new FormControl('', [])); this.loading = true; + this.cdRef.markForCheck(); this.collectionService.allTags().subscribe(tags => { this.lists = tags; this.loading = false; + this.cdRef.markForCheck(); }); } @@ -48,6 +54,7 @@ export class BulkAddToCollectionComponent implements OnInit { // Shift focus to input if (this.inputElem) { this.inputElem.nativeElement.select(); + this.cdRef.markForCheck(); } } diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html deleted file mode 100644 index b8f8e905e..000000000 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html +++ /dev/null @@ -1,144 +0,0 @@ -
      - - - -
      - diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.scss b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.scss deleted file mode 100644 index dfea7d943..000000000 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.scss +++ /dev/null @@ -1,4 +0,0 @@ -.scrollable-modal { - max-height: 90vh; // 600px - overflow: auto; -} \ No newline at end of file diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts deleted file mode 100644 index 451ec0fce..000000000 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; -import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { ToastrService } from 'ngx-toastr'; -import { take } from 'rxjs/operators'; -import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; -import { Chapter } from 'src/app/_models/chapter'; -import { MangaFile } from 'src/app/_models/manga-file'; -import { MangaFormat } from 'src/app/_models/manga-format'; -import { AccountService } from 'src/app/_services/account.service'; -import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; -import { ActionService } from 'src/app/_services/action.service'; -import { ImageService } from 'src/app/_services/image.service'; -import { UploadService } from 'src/app/_services/upload.service'; -import { LibraryType } from '../../../_models/library'; -import { LibraryService } from '../../../_services/library.service'; -import { SeriesService } from 'src/app/_services/series.service'; -import { Series } from 'src/app/_models/series'; -import { PersonRole } from 'src/app/_models/person'; -import { Volume } from 'src/app/_models/volume'; -import { ChapterMetadata } from 'src/app/_models/chapter-metadata'; -import { PageBookmark } from 'src/app/_models/page-bookmark'; -import { ReaderService } from 'src/app/_services/reader.service'; -import { MetadataService } from 'src/app/_services/metadata.service'; - - - -@Component({ - selector: 'app-card-details-modal', - templateUrl: './card-details-modal.component.html', - styleUrls: ['./card-details-modal.component.scss'] -}) -export class CardDetailsModalComponent implements OnInit { - - @Input() parentName = ''; - @Input() seriesId: number = 0; - @Input() libraryId: number = 0; - @Input() data!: Volume | Chapter; // Volume | Chapter - - /** - * If this is a volume, this will be first chapter for said volume. - */ - chapter!: Chapter; - isChapter = false; - chapters: Chapter[] = []; - - - /** - * If a cover image update occured. - */ - coverImageUpdate: boolean = false; - coverImageIndex: number = 0; - /** - * Url of the selected cover - */ - selectedCover: string = ''; - coverImageLocked: boolean = false; - /** - * When the API is doing work - */ - coverImageSaveLoading: boolean = false; - imageUrls: Array = []; - - - actions: ActionItem[] = []; - chapterActions: ActionItem[] = []; - libraryType: LibraryType = LibraryType.Manga; - - - tabs = [{title: 'General', disabled: false}, {title: 'Metadata', disabled: false}, {title: 'Cover', disabled: false}, {title: 'Info', disabled: false}]; - active = this.tabs[0]; - - chapterMetadata!: ChapterMetadata; - ageRating!: string; - - - get Breakpoint(): typeof Breakpoint { - return Breakpoint; - } - - get PersonRole() { - return PersonRole; - } - - get LibraryType(): typeof LibraryType { - return LibraryType; - } - - constructor(public modal: NgbActiveModal, public utilityService: UtilityService, - public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService, - private accountService: AccountService, private actionFactoryService: ActionFactoryService, - private actionService: ActionService, private router: Router, private libraryService: LibraryService, - private seriesService: SeriesService, private readerService: ReaderService, public metadataService: MetadataService) { } - - ngOnInit(): void { - this.isChapter = this.utilityService.isChapter(this.data); - - this.chapter = this.utilityService.isChapter(this.data) ? (this.data as Chapter) : (this.data as Volume).chapters[0]; - - this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id)); - - this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => { - this.chapterMetadata = metadata; - - this.metadataService.getAgeRating(this.chapterMetadata.ageRating).subscribe(ageRating => this.ageRating = ageRating); - }); - - - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { - if (user) { - if (!this.accountService.hasAdminRole(user)) { - this.tabs.find(s => s.title === 'Cover')!.disabled = true; - } - } - }); - - this.libraryService.getLibraryType(this.libraryId).subscribe(type => { - this.libraryType = type; - }); - - this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)).filter(item => item.action !== Action.Edit); - - if (this.isChapter) { - this.chapters.push(this.data as Chapter); - } else if (!this.isChapter) { - this.chapters.push(...(this.data as Volume).chapters); - } - // TODO: Move this into the backend - this.chapters.sort(this.utilityService.sortChapters); - this.chapters.forEach(c => c.coverImage = this.imageService.getChapterCoverImage(c.id)); - // Try to show an approximation of the reading order for files - var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}); - this.chapters.forEach((c: Chapter) => { - c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath)); - }); - } - - close() { - this.modal.close({coverImageUpdate: this.coverImageUpdate}); - } - - formatChapterNumber(chapter: Chapter) { - if (chapter.number === '0') { - return '1'; - } - return chapter.number; - } - - performAction(action: ActionItem, chapter: Chapter) { - if (typeof action.callback === 'function') { - action.callback(action.action, chapter); - } - } - - updateSelectedIndex(index: number) { - this.coverImageIndex = index; - } - - updateSelectedImage(url: string) { - this.selectedCover = url; - } - - handleReset() { - this.coverImageLocked = false; - } - - saveCoverImage() { - this.coverImageSaveLoading = true; - const selectedIndex = this.coverImageIndex || 0; - if (selectedIndex > 0) { - this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover).subscribe(() => { - if (this.coverImageIndex > 0) { - this.chapter.coverImageLocked = true; - this.coverImageUpdate = true; - } - this.coverImageSaveLoading = false; - }, err => this.coverImageSaveLoading = false); - } else if (this.coverImageLocked === false) { - this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => { - this.toastr.info('Cover image reset'); - this.coverImageSaveLoading = false; - this.coverImageUpdate = true; - }); - } - } - - markChapterAsRead(chapter: Chapter) { - if (this.seriesId === 0) { - return; - } - - this.actionService.markChapterAsRead(this.seriesId, chapter, () => { /* No Action */ }); - } - - markChapterAsUnread(chapter: Chapter) { - if (this.seriesId === 0) { - return; - } - - this.actionService.markChapterAsUnread(this.seriesId, chapter, () => { /* No Action */ }); - } - - handleChapterActionCallback(action: Action, chapter: Chapter) { - switch (action) { - case(Action.MarkAsRead): - this.markChapterAsRead(chapter); - break; - case(Action.MarkAsUnread): - this.markChapterAsUnread(chapter); - break; - case(Action.AddToReadingList): - this.actionService.addChapterToReadingList(chapter, this.seriesId); - break; - default: - break; - } - } - - readChapter(chapter: Chapter) { - if (chapter.pages === 0) { - this.toastr.error('There are no pages. Kavita was not able to read this archive.'); - return; - } - - this.router.navigate(this.readerService.getNavigationArray(this.libraryId, this.seriesId, this.chapter.id, chapter.files[0].format)); - } -} diff --git a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html index 4e7467b38..80fc1a4b2 100644 --- a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html +++ b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html @@ -1,16 +1,14 @@