diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 6d4a3b359..166284164 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -1473,6 +1473,51 @@ public class ReaderServiceTests Assert.Equal("1", nextChapter.Range); } + [Fact] + public async Task GetContinuePoint_ShouldReturnFirstVolume_WhenFirstVolumeIsAlsoTaggedAsChapter1_WithProgress() + { + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 3), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("0", false, new List(), 1), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 2, + ChapterId = 1, + SeriesId = 1, + VolumeId = 1 + }, 1); + var nextChapter = await readerService.GetContinuePoint(1, 1); + + Assert.Equal("1", nextChapter.Range); + } + [Fact] public async Task GetContinuePoint_ShouldReturnFirstNonSpecial() { diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index a3cae9d80..ea4ba9bdb 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -8,7 +7,6 @@ using API.DTOs.Reader; using API.Entities.Enums; using API.Services; using Kavita.Common; -using HtmlAgilityPack; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using VersOne.Epub; @@ -97,7 +95,7 @@ public class BookController : BaseApiController var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions); - var key = BookService.CleanContentKeys(file); + var key = BookService.CoalesceKeyForAnyFile(book, file); if (!book.Content.AllFiles.ContainsKey(key)) return BadRequest("File was not found in book"); var bookFile = book.Content.AllFiles[key]; diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 6322718e6..8ffcd429e 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -187,7 +187,7 @@ public class ReaderController : BaseApiController var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); 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(); + var mangaFile = chapter.Files.First(); var info = new ChapterInfoDto() { diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 7c921fe8f..29668cc78 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -143,6 +143,19 @@ public class ServerController : BaseApiController return Ok(); } + /// + /// Triggers the scheduling of the convert covers job. Only one job will run at a time. + /// + /// + [HttpPost("convert-covers")] + public ActionResult ScheduleConvertCovers() + { + if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty(), + TaskScheduler.DefaultQueue, true)) return Ok(); + BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllCoverToWebP()); + return Ok(); + } + [HttpGet("logs")] public ActionResult GetLogs() { diff --git a/API/DTOs/MangaFileDto.cs b/API/DTOs/MangaFileDto.cs index d20da8eb5..7945d1872 100644 --- a/API/DTOs/MangaFileDto.cs +++ b/API/DTOs/MangaFileDto.cs @@ -8,6 +8,7 @@ public class MangaFileDto public int Id { get; init; } public string FilePath { get; init; } public int Pages { get; init; } + public long Bytes { get; init; } public MangaFormat Format { get; init; } public DateTime Created { get; init; } diff --git a/API/DTOs/Statistics/UserReadStatistics.cs b/API/DTOs/Statistics/UserReadStatistics.cs index 274f4c29c..686957e93 100644 --- a/API/DTOs/Statistics/UserReadStatistics.cs +++ b/API/DTOs/Statistics/UserReadStatistics.cs @@ -10,6 +10,10 @@ public class UserReadStatistics /// public long TotalPagesRead { get; set; } /// + /// Total number of words read + /// + public long TotalWordsRead { get; set; } + /// /// Total time spent reading based on estimates /// public long TimeSpentReading { get; set; } diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 695de9366..1c5d9099f 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -18,6 +18,7 @@ public enum ChapterIncludes { None = 1, Volumes = 2, + Files = 4 } public interface IChapterRepository @@ -26,7 +27,7 @@ public interface IChapterRepository Task> GetChaptersByIdsAsync(IList chapterIds, ChapterIncludes includes = ChapterIncludes.None); Task GetChapterInfoDtoAsync(int chapterId); Task GetChapterTotalPagesAsync(int chapterId); - Task GetChapterAsync(int chapterId); + Task GetChapterAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files); Task GetChapterDtoAsync(int chapterId); Task GetChapterMetadataDtoAsync(int chapterId); Task> GetFilesForChapterAsync(int chapterId); @@ -34,6 +35,7 @@ public interface IChapterRepository Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds); Task GetChapterCoverImageAsync(int chapterId); Task> GetAllCoverImagesAsync(); + Task> GetAllChaptersWithNonWebPCovers(); Task> GetCoverImagesForLockedChaptersAsync(); } public class ChapterRepository : IChapterRepository @@ -162,12 +164,17 @@ public class ChapterRepository : IChapterRepository /// Returns a Chapter for an Id. Includes linked s. /// /// + /// /// - public async Task GetChapterAsync(int chapterId) + public async Task GetChapterAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files) { - return await _context.Chapter - .Include(c => c.Files) - .AsSplitQuery() + var query = _context.Chapter + .AsSplitQuery(); + + if (includes.HasFlag(ChapterIncludes.Files)) query = query.Include(c => c.Files); + if (includes.HasFlag(ChapterIncludes.Volumes)) query = query.Include(c => c.Volume); + + return await query .SingleOrDefaultAsync(c => c.Id == chapterId); } @@ -207,6 +214,13 @@ public class ChapterRepository : IChapterRepository .ToListAsync(); } + public async Task> GetAllChaptersWithNonWebPCovers() + { + return await _context.Chapter + .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) + .ToListAsync(); + } + /// /// Returns cover images for locked chapters /// diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 3219d58a0..3eaccb211 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -90,7 +90,7 @@ public static class Seed Key = ServerSettingKey.Port, Value = "5000" }, // Not used from DB, but DB is sync with appSettings.json new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"}, - new() {Key = ServerSettingKey.EnableOpds, Value = "false"}, + new() {Key = ServerSettingKey.EnableOpds, Value = "true"}, new() {Key = ServerSettingKey.EnableAuthentication, Value = "true"}, new() {Key = ServerSettingKey.BaseUrl, Value = "/"}, new() {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()}, diff --git a/API/Logging/LogEnricher.cs b/API/Logging/LogEnricher.cs index 8cc7a6b29..4acdd5f4d 100644 --- a/API/Logging/LogEnricher.cs +++ b/API/Logging/LogEnricher.cs @@ -15,5 +15,6 @@ public static class LogEnricher { diagnosticContext.Set("ClientIP", httpContext.Connection.RemoteIpAddress?.ToString()); diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].FirstOrDefault()); + diagnosticContext.Set("Path", httpContext.Request.Path); } } diff --git a/API/Logging/LogLevelOptions.cs b/API/Logging/LogLevelOptions.cs index 51ed86632..a00547938 100644 --- a/API/Logging/LogLevelOptions.cs +++ b/API/Logging/LogLevelOptions.cs @@ -1,6 +1,9 @@ -using System.IO; +using System; +using System.IO; +using System.Net; using API.Services; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Serilog; using Serilog.Core; @@ -57,7 +60,18 @@ public static class LogLevelOptions .WriteTo.File(LogFile, shared: true, rollingInterval: RollingInterval.Day, - outputTemplate: outputTemplate); + outputTemplate: outputTemplate) + .Filter.ByIncludingOnly(ShouldIncludeLogStatement); + } + + private static bool ShouldIncludeLogStatement(LogEvent e) + { + if (e.Properties.ContainsKey("SourceContext") && + e.Properties["SourceContext"].ToString().Replace("\"", string.Empty) == "Serilog.AspNetCore.RequestLoggingMiddleware") + { + if (e.Properties.ContainsKey("Path") && e.Properties["Path"].ToString().Replace("\"", string.Empty) == "/api/health") return false; + } + return true; } public static void SwitchLogLevel(string level) diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 1373990fa..2e2ca1904 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -33,17 +33,6 @@ public interface IBookService { int GetNumberOfPages(string filePath); string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP = false); - Task> CreateKeyToPageMappingAsync(EpubBookRef book); - - /// - /// Scopes styles to .reading-section and replaces img src to the passed apiBase - /// - /// - /// - /// If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath. - /// Book Reference, needed for if you expect Import statements - /// - Task ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book); ComicInfo GetComicInfo(string filePath); ParserInfo ParseInfo(string filePath); /// @@ -53,11 +42,9 @@ public interface IBookService /// /// Where the files will be extracted to. If doesn't exist, will be created. void ExtractPdfImages(string fileFilePath, string targetDirectory); - - Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary mappings, int page); Task> GenerateTableOfContents(Chapter chapter); - Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl); + Task> CreateKeyToPageMappingAsync(EpubBookRef book); } public class BookService : IBookService @@ -163,6 +150,14 @@ public class BookService : IBookService anchor.Attributes.Add("href", "javascript:void(0)"); } + /// + /// Scopes styles to .reading-section and replaces img src to the passed apiBase + /// + /// + /// + /// If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath. + /// Book Reference, needed for if you expect Import statements + /// public async Task ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book) { // @Import statements will be handled by browser, so we must inline the css into the original file that request it, so they can be Scoped @@ -717,6 +712,13 @@ public class BookService : IBookService return PrepareFinalHtml(doc, body); } + /// + /// Tries to find the correct key by applying cleaning and remapping if the epub has bad data. Only works for HTML files. + /// + /// + /// + /// + /// private static string CoalesceKey(EpubBookRef book, IDictionary mappings, string key) { if (mappings.ContainsKey(CleanContentKeys(key))) return key; @@ -731,6 +733,23 @@ public class BookService : IBookService return key; } + public static string CoalesceKeyForAnyFile(EpubBookRef book, string key) + { + if (book.Content.AllFiles.ContainsKey(key)) return key; + + var cleanedKey = CleanContentKeys(key); + if (book.Content.AllFiles.ContainsKey(cleanedKey)) return cleanedKey; + + // Fallback to searching for key (bad epub metadata) + var correctedKey = book.Content.AllFiles.Keys.SingleOrDefault(s => s.EndsWith(key)); + if (!string.IsNullOrEmpty(correctedKey)) + { + key = correctedKey; + } + + return key; + } + /// /// This will return a list of mappings from ID -> page num. ID will be the xhtml key and page num will be the reading order /// this is used to rewrite anchors in the book text so that we always load properly in our reader. @@ -844,7 +863,7 @@ public class BookService : IBookService if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) return content; // In more cases than not, due to this being XML not HTML, we need to escape the script tags. - content = BookService.EscapeTags(content); + content = EscapeTags(content); doc.LoadHtml(content); var body = doc.DocumentNode.SelectSingleNode("//body"); diff --git a/API/Services/BookmarkService.cs b/API/Services/BookmarkService.cs index 72dd72279..abebee475 100644 --- a/API/Services/BookmarkService.cs +++ b/API/Services/BookmarkService.cs @@ -22,6 +22,7 @@ public interface IBookmarkService Task> GetBookmarkFilesById(IEnumerable bookmarkIds); [DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)] Task ConvertAllBookmarkToWebP(); + Task ConvertAllCoverToWebP(); } @@ -183,7 +184,9 @@ public class BookmarkService : IBookmarkService var count = 1F; foreach (var bookmark in bookmarks) { - await SaveBookmarkAsWebP(bookmarkDirectory, bookmark); + bookmark.FileName = await SaveAsWebP(bookmarkDirectory, bookmark.FileName, + BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId)); + _unitOfWork.UserRepository.Update(bookmark); await _unitOfWork.CommitAsync(); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.ConvertBookmarksProgressEvent(count / bookmarks.Count, ProgressEventType.Started)); @@ -196,10 +199,40 @@ public class BookmarkService : IBookmarkService _logger.LogInformation("[BookmarkService] Converted bookmarks to WebP"); } + /// + /// This is a long-running job that will convert all covers into WebP. Do not invoke anyway except via Hangfire. + /// + [DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)] + public async Task ConvertAllCoverToWebP() + { + var coverDirectory = _directoryService.CoverImageDirectory; + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.ConvertCoverProgressEvent(0F, ProgressEventType.Started)); + var chapters = await _unitOfWork.ChapterRepository.GetAllChaptersWithNonWebPCovers(); + + var count = 1F; + foreach (var chapter in chapters) + { + var newFile = await SaveAsWebP(coverDirectory, chapter.CoverImage, coverDirectory); + chapter.CoverImage = newFile; + _unitOfWork.ChapterRepository.Update(chapter); + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.ConvertCoverProgressEvent(count / chapters.Count, ProgressEventType.Started)); + count++; + } + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.ConvertCoverProgressEvent(1F, ProgressEventType.Ended)); + + _logger.LogInformation("[BookmarkService] Converted covers to WebP"); + } + /// /// This is a job that runs after a bookmark is saved /// - public async Task ConvertBookmarkToWebP(int bookmarkId) + private async Task ConvertBookmarkToWebP(int bookmarkId) { var bookmarkDirectory = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; @@ -212,46 +245,52 @@ public class BookmarkService : IBookmarkService var bookmark = await _unitOfWork.UserRepository.GetBookmarkAsync(bookmarkId); if (bookmark == null) return; - await SaveBookmarkAsWebP(bookmarkDirectory, bookmark); + bookmark.FileName = await SaveAsWebP(bookmarkDirectory, bookmark.FileName, + BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId)); + _unitOfWork.UserRepository.Update(bookmark); + await _unitOfWork.CommitAsync(); } /// - /// Converts bookmark file, deletes original, marks bookmark as dirty. Does not commit. + /// Converts an image file, deletes original and returns the new path back /// - /// - /// - private async Task SaveBookmarkAsWebP(string bookmarkDirectory, AppUserBookmark bookmark) + /// Full Path to where files are stored + /// The file to convert + /// Full path to where files should be stored or any stem + /// + private async Task SaveAsWebP(string imageDirectory, string filename, string targetFolder) { - var fullSourcePath = _directoryService.FileSystem.Path.Join(bookmarkDirectory, bookmark.FileName); - var fullTargetDirectory = fullSourcePath.Replace(new FileInfo(bookmark.FileName).Name, string.Empty); - var targetFolderStem = BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId); + var fullSourcePath = _directoryService.FileSystem.Path.Join(imageDirectory, filename); + var fullTargetDirectory = fullSourcePath.Replace(new FileInfo(filename).Name, string.Empty); - _logger.LogDebug("Converting {Source} bookmark into WebP at {Target}", fullSourcePath, fullTargetDirectory); + var newFilename = string.Empty; + _logger.LogDebug("Converting {Source} image into WebP at {Target}", fullSourcePath, fullTargetDirectory); try { // Convert target file to webp then delete original target file and update bookmark - var originalFile = bookmark.FileName; + var originalFile = filename; try { var targetFile = await _imageService.ConvertToWebP(fullSourcePath, fullTargetDirectory); var targetName = new FileInfo(targetFile).Name; - bookmark.FileName = Path.Join(targetFolderStem, targetName); + newFilename = Path.Join(targetFolder, targetName); _directoryService.DeleteFiles(new[] {fullSourcePath}); } catch (Exception ex) { - _logger.LogError(ex, "Could not convert file {FilePath}", bookmark.FileName); - bookmark.FileName = originalFile; + _logger.LogError(ex, "Could not convert image {FilePath}", filename); + newFilename = originalFile; } - _unitOfWork.UserRepository.Update(bookmark); } catch (Exception ex) { - _logger.LogError(ex, "Could not convert bookmark to WebP"); + _logger.LogError(ex, "Could not convert image to WebP"); } + + return newFilename; } private static string BookmarkStem(int userId, int seriesId, int chapterId) diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index fcfe6655d..adc6db7e8 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -82,7 +82,7 @@ public class CacheService : ICacheService PageNumber = i, Height = image.Height, Width = image.Width, - FileName = file + FileName = file.Replace(path, string.Empty) }); } diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index ac2933b75..a3eee4178 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -3,7 +3,6 @@ using System.IO; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using NetVips; -using SixLabors.ImageSharp; using Image = NetVips.Image; namespace API.Services; diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 453aa77b6..cb73c3d87 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -479,11 +479,14 @@ public class ReaderService : IReaderService var volumeChapters = volumes .Where(v => v.Number != 0) .SelectMany(v => v.Chapters) - .OrderBy(c => float.Parse(c.Number)) + //.OrderBy(c => float.Parse(c.Number)) .ToList(); + // NOTE: If volume 1 has chapter 1 and volume 2 is just chapter 0 due to being a full volume file, then this fails // If there are any volumes that have progress, return those. If not, move on. - var currentlyReadingChapter = volumeChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages); + var currentlyReadingChapter = volumeChapters + .OrderBy(c => double.Parse(c.Range), _chapterSortComparer) + .FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages); if (currentlyReadingChapter != null) return currentlyReadingChapter; // Order with volume 0 last so we prefer the natural order diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 7db7709c1..580271706 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -70,6 +70,10 @@ public class StatisticService : IStatisticService .Where(c => chapterIds.Contains(c.Id)) .SumAsync(c => c.AvgHoursToRead); + var totalWordsRead = await _context.Chapter + .Where(c => chapterIds.Contains(c.Id)) + .SumAsync(c => c.WordCount); + var chaptersRead = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) .Where(p => libraryIds.Contains(p.LibraryId)) @@ -90,8 +94,7 @@ public class StatisticService : IStatisticService .AsEnumerable() .GroupBy(g => g.series.LibraryId) .ToDictionary(g => g.Key, g => g.Sum(c => c.chapter.Pages)); - // - // + var totalProgressByLibrary = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) .Where(p => p.LibraryId > 0) @@ -108,11 +111,12 @@ public class StatisticService : IStatisticService .Where(p => p.AppUserId == userId) .Join(_context.Chapter, p => p.ChapterId, c => c.Id, (p, c) => (p.PagesRead / (float) c.Pages) * c.AvgHoursToRead) - .Average() / 7; + .Average() / 7.0; return new UserReadStatistics() { TotalPagesRead = totalPagesRead, + TotalWordsRead = totalWordsRead, TimeSpentReading = timeSpentReading, ChaptersRead = chaptersRead, LastActive = lastActive, @@ -314,7 +318,7 @@ public class StatisticService : IStatisticService .Select(u => new ReadHistoryEvent { UserId = u.AppUserId, - UserName = _context.AppUser.Single(u => u.Id == userId).UserName, + UserName = _context.AppUser.Single(u2 => u2.Id == userId).UserName, SeriesName = _context.Series.Single(s => s.Id == u.SeriesId).Name, SeriesId = u.SeriesId, LibraryId = u.LibraryId, diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs index 763287a34..2e02187d1 100644 --- a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -243,8 +243,9 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService var doc = new HtmlDocument(); doc.LoadHtml(await bookFile.ReadContentAsTextAsync()); - return doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]") - .DefaultIfEmpty() + var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]"); + if (textNodes == null) return 0; + return textNodes .Select(node => node.InnerText.Split(' ', StringSplitOptions.RemoveEmptyEntries) .Where(s => char.IsLetter(s[0]))) .Sum(words => words.Count()); diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index f2020d86c..11c1e625b 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -106,6 +106,10 @@ public static class MessageFactory /// private const string ConvertBookmarksProgress = "ConvertBookmarksProgress"; /// + /// When bulk covers are being converted + /// + private const string ConvertCoversProgress = "ConvertBookmarksProgress"; + /// /// When files are being scanned to calculate word count /// private const string WordCountAnalyzerProgress = "WordCountAnalyzerProgress"; @@ -495,4 +499,21 @@ public static class MessageFactory } }; } + + public static SignalRMessage ConvertCoverProgressEvent(float progress, string eventType) + { + return new SignalRMessage() + { + Name = ConvertCoversProgress, + Title = "Converting Covers to WebP", + SubTitle = string.Empty, + EventType = eventType, + Progress = ProgressType.Determinate, + Body = new + { + Progress = progress, + EventTime = DateTime.Now + } + }; + } } diff --git a/UI/Web/src/_manga-reader-common.scss b/UI/Web/src/_manga-reader-common.scss index 403787e70..1e987c050 100644 --- a/UI/Web/src/_manga-reader-common.scss +++ b/UI/Web/src/_manga-reader-common.scss @@ -14,7 +14,7 @@ img { &.full-height { height: 100vh; - display: inline-block; + display: flex; // changed from inline-block to fix the centering on tablets not working } &.original { diff --git a/UI/Web/src/app/_models/manga-file.ts b/UI/Web/src/app/_models/manga-file.ts index ae4584402..b630054af 100644 --- a/UI/Web/src/app/_models/manga-file.ts +++ b/UI/Web/src/app/_models/manga-file.ts @@ -6,4 +6,5 @@ export interface MangaFile { pages: number; format: MangaFormat; created: string; + bytes: number; } diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index a053ac447..467b55564 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -44,5 +44,5 @@ export const scalingOptions = [{text: 'Automatic', value: ScalingOption.Automati export const pageSplitOptions = [{text: 'Fit to Screen', value: PageSplitOption.FitSplit}, {text: 'Right to Left', value: PageSplitOption.SplitRightToLeft}, {text: 'Left to Right', value: PageSplitOption.SplitLeftToRight}, {text: 'No Split', value: PageSplitOption.NoSplit}]; export const readingModes = [{text: 'Left to Right', value: ReaderMode.LeftRight}, {text: 'Up to Down', value: ReaderMode.UpDown}, {text: 'Webtoon', value: ReaderMode.Webtoon}]; export const layoutModes = [{text: 'Single', value: LayoutMode.Single}, {text: 'Double', value: LayoutMode.Double}, {text: 'Double (Manga)', value: LayoutMode.DoubleReversed}]; -export const bookLayoutModes = [{text: 'Default', value: BookPageLayoutMode.Default}, {text: '1 Column', value: BookPageLayoutMode.Column1}, {text: '2 Column', value: BookPageLayoutMode.Column2}]; +export const bookLayoutModes = [{text: 'Scroll', value: BookPageLayoutMode.Default}, {text: '1 Column', value: BookPageLayoutMode.Column1}, {text: '2 Column', value: BookPageLayoutMode.Column2}]; export const pageLayoutModes = [{text: 'Cards', value: PageLayoutMode.Cards}, {text: 'List', value: PageLayoutMode.List}]; diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index e9fa4aa65..667c3feb8 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -191,12 +191,14 @@ export class ReaderService { */ imageUrlToPageNum(imageSrc: string) { if (imageSrc === undefined || imageSrc === '') { return -1; } - return parseInt(imageSrc.split('&page=')[1], 10); + const params = new URLSearchParams(new URL(imageSrc).search); + return parseInt(params.get('page') || '-1', 10); } imageUrlToChapterId(imageSrc: string) { if (imageSrc === undefined || imageSrc === '') { return -1; } - return parseInt(imageSrc.split('chapterId=')[1].split('&')[0], 10); + const params = new URLSearchParams(new URL(imageSrc).search); + return parseInt(params.get('chapterId') || '-1', 10); } getNextChapterUrl(url: string, nextChapterId: number, incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) { diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index 9752394b8..6775a9c47 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -57,4 +57,8 @@ export class ServerService { convertBookmarks() { return this.httpClient.post(this.baseUrl + 'server/convert-bookmarks', {}); } + + convertCovers() { + return this.httpClient.post(this.baseUrl + 'server/convert-covers', {}); + } } diff --git a/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html b/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html index 2e93b6498..e5cf98d75 100644 --- a/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html +++ b/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html @@ -3,6 +3,7 @@

WebP can drastically reduce space requirements for files. WebP is not supported on all browsers or versions. To learn if these settings are appropriate for your setup, visit Can I Use.

+
diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index b3e8f3876..3c492680b 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -39,6 +39,12 @@ export class ManageTasksSettingsComponent implements OnInit { api: this.serverService.convertBookmarks(), successMessage: 'Conversion of Bookmarks has been queued' }, + { + name: 'Convert Covers to WebP', + description: 'Runs a long-running task which will convert all existing covers to WebP. This is slow (especially on ARM devices).', + api: this.serverService.convertCovers(), + successMessage: 'Conversion of Coverts has been queued' + }, { name: 'Clear Cache', description: 'Clears cached files for reading. Usefull when you\'ve just updated a file that you were previously reading within last 24 hours.', diff --git a/UI/Web/src/app/all-series/_components/all-series/all-series.component.html b/UI/Web/src/app/all-series/_components/all-series/all-series.component.html index 74296fe97..cb2eee35f 100644 --- a/UI/Web/src/app/all-series/_components/all-series/all-series.component.html +++ b/UI/Web/src/app/all-series/_components/all-series/all-series.component.html @@ -1,6 +1,6 @@

- All Series + {{title}}

{{pagination.totalItems}} Series
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 34d5fc487..0817c55af 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 @@ -27,6 +27,7 @@ import { SeriesService } from 'src/app/_services/series.service'; }) export class AllSeriesComponent implements OnInit, OnDestroy { + title: string = 'All Series'; series: Series[] = []; loadingSeries = false; pagination!: Pagination; @@ -93,7 +94,9 @@ export class AllSeriesComponent implements OnInit, OnDestroy { private readonly cdRef: ChangeDetectorRef) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; - this.titleService.setTitle('Kavita - All Series'); + + this.title = this.route.snapshot.queryParamMap.get('title') || 'All Series'; + this.titleService.setTitle('Kavita - ' + this.title); this.pagination = this.filterUtilityService.pagination(this.route.snapshot); [this.filterSettings.presets, this.filterSettings.openByDefault] = this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot); diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html index 1902d68e6..57c90a119 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html @@ -84,7 +84,7 @@ tabindex="-1" [ngStyle]="{height: PageHeightForPagination}">
-
+
(); - @ViewChild('readingHtml', {static: false}) readingHtml!: ElementRef; + @ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef; + /** + * book-content class + */ + @ViewChild('bookContentElemRef', {static: false}) bookContentElemRef!: ElementRef; @ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef; @ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef; @ViewChild('reader', {static: true}) reader!: ElementRef; @@ -326,7 +330,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } const [currentVirtualPage, totalVirtualPages, _] = this.getVirtualPage(); - if (this.readingHtml == null) return this.pageNum + 1 >= this.maxPages; + if (this.bookContentElemRef == null) return this.pageNum + 1 >= this.maxPages; return this.pageNum + 1 >= this.maxPages && (currentVirtualPage === totalVirtualPages); } @@ -339,7 +343,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } const [currentVirtualPage,,] = this.getVirtualPage(); - if (this.readingHtml == null) return this.pageNum + 1 >= this.maxPages; + if (this.bookContentElemRef == null) return this.pageNum + 1 >= this.maxPages; return this.pageNum === 0 && (currentVirtualPage === 0); } @@ -378,7 +382,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { get PageHeightForPagination() { if (this.layoutMode === BookPageLayoutMode.Default) { - return (this.readingHtml?.nativeElement?.scrollHeight || 0) - ((this.topOffset * (this.immersiveMode ? 0 : 1)) * 2) + 'px'; + + // if the book content is less than the height of the container, override and return height of container for pagination area + if (this.bookContainerElemRef?.nativeElement?.clientHeight > this.bookContentElemRef?.nativeElement?.clientHeight) { + return (this.bookContainerElemRef?.nativeElement?.clientHeight || 0) + 'px'; + } + + return (this.bookContentElemRef?.nativeElement?.scrollHeight || 0) - ((this.topOffset * (this.immersiveMode ? 0 : 1)) * 2) + 'px'; } if (this.immersiveMode) return this.windowHeight + 'px'; @@ -848,9 +858,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.reader.nativeElement.children // We need to check if we are paging back, because we need to adjust the scroll if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { - setTimeout(() => this.scrollService.scrollToX(this.readingHtml.nativeElement.scrollWidth, this.readingHtml.nativeElement)); + setTimeout(() => this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.scrollWidth, this.bookContentElemRef.nativeElement)); } else { - setTimeout(() => this.scrollService.scrollToX(0, this.readingHtml.nativeElement)); + setTimeout(() => this.scrollService.scrollToX(0, this.bookContentElemRef.nativeElement)); } } } @@ -925,7 +935,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (currentVirtualPage > 1) { // -2 apparently goes back 1 virtual page... - this.scrollService.scrollToX((currentVirtualPage - 2) * pageWidth, this.readingHtml.nativeElement); + this.scrollService.scrollToX((currentVirtualPage - 2) * pageWidth, this.bookContentElemRef.nativeElement); this.handleScrollEvent(); return; } @@ -957,7 +967,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (currentVirtualPage < totalVirtualPages) { // +0 apparently goes forward 1 virtual page... - this.scrollService.scrollToX((currentVirtualPage) * pageWidth, this.readingHtml.nativeElement); + this.scrollService.scrollToX((currentVirtualPage) * pageWidth, this.bookContentElemRef.nativeElement); this.handleScrollEvent(); return; } @@ -995,10 +1005,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * @returns */ getVirtualPage() { - if (this.readingHtml === undefined || this.readingSectionElemRef === undefined) return [1, 1, 0]; + if (this.bookContentElemRef === undefined || this.readingSectionElemRef === undefined) return [1, 1, 0]; - const scrollOffset = this.readingHtml.nativeElement.scrollLeft; - const totalScroll = this.readingHtml.nativeElement.scrollWidth; + const scrollOffset = this.bookContentElemRef.nativeElement.scrollLeft; + const totalScroll = this.bookContentElemRef.nativeElement.scrollWidth; const pageWidth = this.getPageWidth(); const delta = totalScroll - scrollOffset; @@ -1022,9 +1032,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { getFirstVisibleElementXPath() { let resumeElement: string | null = null; - if (this.readingHtml === null) return null; + if (this.bookContentElemRef === null) return null; - const intersectingEntries = Array.from(this.readingHtml.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span')) + const intersectingEntries = Array.from(this.bookContentElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span')) .filter(element => !element.classList.contains('no-observe')) .filter(entry => { return this.utilityService.isInViewport(entry, this.topOffset); @@ -1048,7 +1058,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ updateReaderStyles(pageStyles: PageStyle) { this.pageStyles = pageStyles; - if (this.readingHtml === undefined || !this.readingHtml.nativeElement) return; + if (this.bookContentElemRef === undefined || !this.bookContentElemRef.nativeElement) return; // Before we apply styles, let's get an element on the screen so we can scroll to it after any shifts const resumeElement: string | null | undefined = this.getFirstVisibleElementXPath(); @@ -1060,17 +1070,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { Object.entries(this.pageStyles).forEach(item => { if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { // Remove the style or skip - this.renderer.removeStyle(this.readingHtml.nativeElement, item[0]); + this.renderer.removeStyle(this.bookContentElemRef.nativeElement, item[0]); return; } if (pageLevelStyles.includes(item[0])) { - this.renderer.setStyle(this.readingHtml.nativeElement, item[0], item[1], RendererStyleFlags2.Important); + this.renderer.setStyle(this.bookContentElemRef.nativeElement, item[0], item[1], RendererStyleFlags2.Important); } }); const individualElementStyles = Object.entries(this.pageStyles).filter(item => elementLevelStyles.includes(item[0])); - for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) { - const elem = this.readingHtml.nativeElement.children.item(i); + for(let i = 0; i < this.bookContentElemRef.nativeElement.children.length; i++) { + const elem = this.bookContentElemRef.nativeElement.children.item(i); if (elem?.tagName === 'STYLE') continue; individualElementStyles.forEach(item => { if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { @@ -1114,7 +1124,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.windowWidth = Math.max(this.readingSectionElemRef.nativeElement.clientWidth, window.innerWidth); // Recalculate if bottom action bar is needed - this.scrollbarNeeded = this.readingHtml.nativeElement.clientHeight > this.reader.nativeElement.clientHeight; + this.scrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientHeight > this.reader?.nativeElement?.clientHeight; this.cdRef.markForCheck(); } @@ -1221,12 +1231,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.updateImagesWithHeight(); // Calulate if bottom actionbar is needed. On a timeout to get accurate heights - if (this.readingHtml == null) { + if (this.bookContentElemRef == null) { setTimeout(() => this.updateLayoutMode(this.layoutMode), 10); return; } setTimeout(() => { - this.scrollbarNeeded = this.readingHtml.nativeElement.clientHeight > this.reader.nativeElement.clientHeight; + this.scrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientHeight > this.reader?.nativeElement?.clientHeight; this.cdRef.markForCheck(); }); @@ -1252,11 +1262,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } updateReadingSectionHeight() { + const renderer = this.renderer; + const elem = this.readingSectionElemRef; setTimeout(() => { + if (renderer === undefined || elem === undefined) return; if (this.immersiveMode) { - this.renderer?.setStyle(this.readingSectionElemRef, 'height', 'calc(var(--vh, 1vh) * 100)', RendererStyleFlags2.Important); + renderer.setStyle(elem, 'height', 'calc(var(--vh, 1vh) * 100)', RendererStyleFlags2.Important); } else { - this.renderer?.setStyle(this.readingSectionElemRef, 'height', 'calc(var(--vh, 1vh) * 100 - ' + this.topOffset + 'px)', RendererStyleFlags2.Important); + renderer.setStyle(elem, 'height', 'calc(var(--vh, 1vh) * 100 - ' + this.topOffset + 'px)', RendererStyleFlags2.Important); } }); } diff --git a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html index a57d80555..f28ce296e 100644 --- a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html +++ b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html @@ -108,14 +108,14 @@
- Default: Mirrors epub file (usually one long scrolling page per chapter).
1 Column: Creates a single virtual page at a time.
2 Column: Creates two virtual pages at a time laid out side-by-side.
+ Scroll: Mirrors epub file (usually one long scrolling page per chapter).
1 Column: Creates a single virtual page at a time.
2 Column: Creates two virtual pages at a time laid out side-by-side.

- + 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 cd5dcafd8..d3ac12f9e 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 @@ -369,18 +369,24 @@
Created: {{series.created | date:'shortDate'}}
-
Last Read: {{series.latestReadDate | date:'shortDate' | defaultDate}}
-
Last Added To: {{series.lastChapterAdded | date:'short' | defaultDate}}
-
Last Scanned: {{series.lastFolderScanned | date:'short' | defaultDate}}
-
Folder Path: {{series.folderPath | defaultValue}}
+
Last Read: {{series.latestReadDate | defaultDate | timeAgo}}
+
Last Added To: {{series.lastChapterAdded | defaultDate | timeAgo}}
+
Last Scanned: {{series.lastFolderScanned | defaultDate | timeAgo}}
+
+ +
+
Folder Path: {{series.folderPath | defaultValue}}
- -
Max Items: {{metadata.maxCount}}
-
Total Items: {{metadata.totalCount}}
+
+ Max Items: {{metadata.maxCount}} +
+
+ Total Items: {{metadata.totalCount}} +
Publication Status: {{metadata.publicationStatus | publicationStatus}}
Total Pages: {{series.pages}}
- +
Size: {{size | bytes}}

Volumes

diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index 497fe8e1f..8faf56b37 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -54,7 +54,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { activeTabId = TabID.General; editSeriesForm!: FormGroup; libraryName: string | undefined = undefined; + size: number = 0; private readonly onDestroy = new Subject(); + // Typeaheads @@ -122,7 +124,6 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.initSeries = Object.assign({}, this.series); - this.editSeriesForm = this.fb.group({ id: new FormControl(this.series.id, []), summary: new FormControl('', []), @@ -232,6 +233,16 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { return f; })).flat(); }); + + if (volumes.length > 0) { + this.size = volumes.reduce((sum1, volume) => { + return sum1 + volume.chapters.reduce((sum2, chapter) => { + return sum2 + chapter.files.reduce((sum3, file) => { + return sum3 + file.bytes; + }, 0); + }, 0); + }, 0); + } this.cdRef.markForCheck(); }); } diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html index b0ca4d6f8..a29e1e020 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html @@ -146,12 +146,14 @@ Added: - {{(data.created | date: 'short') || '-'}} + {{data.created | date: 'short' | defaultDate}} - {{(file.created | date: 'short') || '-'}} + {{file.created | date: 'short' | defaultDate}} - +
+
+ Size: {{file.bytes | bytes}}
diff --git a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html index ed15628ed..2efffbba9 100644 --- a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html +++ b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html @@ -2,7 +2,7 @@
- {{chapter.releaseDate | date:'shortDate'}} + {{chapter.releaseDate | date:'shortDate' | defaultDate}}
@@ -28,7 +28,7 @@
- + {{totalWordCount | compactNumber}} Words
@@ -38,7 +38,7 @@
- <1 Hour + <1 Hour {{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}} @@ -50,7 +50,16 @@
- {{chapter.created | date:'short' || '-'}} + {{chapter.created | date:'short' | defaultDate}} + +
+ + + +
+
+ + {{size | bytes}}
diff --git a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts index cc91f0c7b..7348594ca 100644 --- a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts +++ b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts @@ -37,6 +37,7 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy { totalPages: number = 0; totalWordCount: number = 0; readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1}; + size: number = 0; private readonly onDestroy: Subject = new Subject(); @@ -59,6 +60,17 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy { this.chapter = this.utilityService.isChapter(this.entity) ? (this.entity as Chapter) : (this.entity as Volume).chapters[0]; + + if (this.isChapter) { + this.size = this.utilityService.asChapter(this.entity).files.reduce((sum, v) => sum + v.bytes, 0); + } else { + this.size = this.utilityService.asVolume(this.entity).chapters.reduce((sum1, chapter) => { + return sum1 + chapter.files.reduce((sum2, file) => { + return sum2 + file.bytes; + }, 0); + }, 0); + } + if (this.includeMetadata) { this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => { this.chapterMetadata = metadata; diff --git a/UI/Web/src/app/cards/series-info-cards/series-info-cards.component.html b/UI/Web/src/app/cards/series-info-cards/series-info-cards.component.html index ed3529240..09c5cac07 100644 --- a/UI/Web/src/app/cards/series-info-cards/series-info-cards.component.html +++ b/UI/Web/src/app/cards/series-info-cards/series-info-cards.component.html @@ -61,7 +61,7 @@
- + {{series.wordCount | compactNumber}} Words
@@ -81,7 +81,7 @@
- <1 Hour + <1 Hour {{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}} diff --git a/UI/Web/src/app/dashboard/_components/dashboard.component.ts b/UI/Web/src/app/dashboard/_components/dashboard.component.ts index ddacd96c5..e40dde88c 100644 --- a/UI/Web/src/app/dashboard/_components/dashboard.component.ts +++ b/UI/Web/src/app/dashboard/_components/dashboard.component.ts @@ -168,17 +168,20 @@ export class DashboardComponent implements OnInit, OnDestroy { const params: any = {}; params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc params[FilterQueryParam.Page] = 1; + params['title'] = 'Recently Updated'; this.router.navigate(['all-series'], {queryParams: params}); } else if (sectionTitle.toLowerCase() === 'on deck') { const params: any = {}; params[FilterQueryParam.ReadStatus] = 'true,false,false'; params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc params[FilterQueryParam.Page] = 1; + params['title'] = 'On Deck'; this.router.navigate(['all-series'], {queryParams: params}); }else if (sectionTitle.toLowerCase() === 'newly added series') { const params: any = {}; params[FilterQueryParam.SortBy] = SortField.Created + ',false'; // sort by created, desc params[FilterQueryParam.Page] = 1; + params['title'] = 'Newly Added'; this.router.navigate(['all-series'], {queryParams: params}); } } diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index f16bcb3c2..38da63964 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -1053,10 +1053,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { // NOTE: I may want to provide a different prefetcher for double renderer for(let i = 0; i <= PREFETCH_PAGES - 3; i++) { const numOffset = this.pageNum + i; + //console.log('numOffset: ', numOffset); if (numOffset > this.maxPages - 1) continue; const index = (numOffset % this.cachedImages.length + this.cachedImages.length) % this.cachedImages.length; - if (this.readerService.imageUrlToPageNum(this.cachedImages[index].src) !== numOffset) { + const cachedImagePageNum = this.readerService.imageUrlToPageNum(this.cachedImages[index].src); + const cachedImageChapterId = this.readerService.imageUrlToChapterId(this.cachedImages[index].src); + //console.log('chapter id for ', cachedImagePageNum, ' = ', cachedImageChapterId) + if (cachedImagePageNum !== numOffset) { // && cachedImageChapterId === this.chapterId this.cachedImages[index] = new Image(); this.cachedImages[index].src = this.getPageUrl(numOffset); } diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index c45963c4d..a7c1798e9 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -629,12 +629,12 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { apply() { this.applyFilter.emit({filter: this.filter, isFirst: this.updateApplied === 0}); - this.updateApplied++; - - if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile) { + + if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile && this.updateApplied !== 0) { this.toggleSelected(); } - + + this.updateApplied++; this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/pipe/default-date.pipe.ts b/UI/Web/src/app/pipe/default-date.pipe.ts index c72cc49f1..55584544e 100644 --- a/UI/Web/src/app/pipe/default-date.pipe.ts +++ b/UI/Web/src/app/pipe/default-date.pipe.ts @@ -6,7 +6,7 @@ import { Pipe, PipeTransform } from '@angular/core'; export class DefaultDatePipe implements PipeTransform { transform(value: any, replacementString = 'Never'): string { - if (value === null || value === undefined || value === '' || value === Infinity || value === NaN || value === '1/1/01') return replacementString; + if (value === null || value === undefined || value === '' || value === Infinity || Number.isNaN(value) || value === '1/1/01') return replacementString; return value; } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index ba844dce3..ace2887c2 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -148,6 +148,7 @@ @@ -227,6 +228,7 @@ @@ -263,6 +265,7 @@ diff --git a/UI/Web/src/app/sidenav/_components/side-nav-item/side-nav-item.component.scss b/UI/Web/src/app/sidenav/_components/side-nav-item/side-nav-item.component.scss index 132ad88bb..ec41533f5 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav-item/side-nav-item.component.scss +++ b/UI/Web/src/app/sidenav/_components/side-nav-item/side-nav-item.component.scss @@ -11,7 +11,6 @@ cursor: pointer; .side-nav-text { - padding-left: 10px; opacity: 1; min-width: 100px; diff --git a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html index addaf57a9..0002d677a 100644 --- a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html +++ b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html @@ -8,6 +8,15 @@
+ +
+ + {{totalWordsRead | compactNumber}} + +
+
+
+
@@ -20,7 +29,7 @@
- {{avgHoursPerWeekSpentReading | compactNumber}} hours + {{avgHoursPerWeekSpentReading | compactNumber | number: '1.0-2'}} hours
diff --git a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.ts b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.ts index 083d267a7..9119c3796 100644 --- a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.ts +++ b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.ts @@ -9,6 +9,7 @@ import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core export class UserStatsInfoCardsComponent implements OnInit { @Input() totalPagesRead: number = 0; + @Input() totalWordsRead: number = 0; @Input() timeSpentReading: number = 0; @Input() chaptersRead: number = 0; @Input() lastActive: string = ''; diff --git a/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html b/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html index 3560e85d5..b7ac6291c 100644 --- a/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html +++ b/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html @@ -1,10 +1,9 @@
-
- +
@@ -17,14 +16,4 @@
- - - -
\ No newline at end of file diff --git a/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.ts b/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.ts index 5180c7383..42ba6ff50 100644 --- a/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.ts +++ b/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.ts @@ -1,21 +1,15 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core'; -import { map, Observable, of, shareReplay, Subject, takeUntil } from 'rxjs'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { map, Observable, shareReplay, Subject, takeUntil } from 'rxjs'; import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service'; -import { Series } from 'src/app/_models/series'; import { UserReadStatistics } from 'src/app/statistics/_models/user-read-statistics'; -import { SeriesService } from 'src/app/_services/series.service'; import { StatisticsService } from 'src/app/_services/statistics.service'; -import { SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive'; import { ReadHistoryEvent } from '../../_models/read-history-event'; import { MemberService } from 'src/app/_services/member.service'; import { AccountService } from 'src/app/_services/account.service'; import { PieDataItem } from '../../_models/pie-data-item'; -import { LibraryTypePipe } from 'src/app/pipe/library-type.pipe'; import { LibraryService } from 'src/app/_services/library.service'; import { PercentPipe } from '@angular/common'; -type SeriesWithProgress = Series & {progress: number}; - @Component({ selector: 'app-user-stats', templateUrl: './user-stats.component.html', diff --git a/UI/Web/src/app/statistics/_models/user-read-statistics.ts b/UI/Web/src/app/statistics/_models/user-read-statistics.ts index ddaf033b2..81d2b5943 100644 --- a/UI/Web/src/app/statistics/_models/user-read-statistics.ts +++ b/UI/Web/src/app/statistics/_models/user-read-statistics.ts @@ -2,6 +2,7 @@ import { StatCount } from "./stat-count"; export interface UserReadStatistics { totalPagesRead: number; + totalWordsRead: number; timeSpentReading: number; chaptersRead: number; lastActive: string; diff --git a/UI/Web/src/app/typeahead/_components/typeahead.component.ts b/UI/Web/src/app/typeahead/_components/typeahead.component.ts index 2c9984766..28dd38cae 100644 --- a/UI/Web/src/app/typeahead/_components/typeahead.component.ts +++ b/UI/Web/src/app/typeahead/_components/typeahead.component.ts @@ -14,7 +14,7 @@ import { SelectionCompareFn, TypeaheadSettings } from '../_models/typeahead-sett * @param selectedOptions Optional data elements to inform the SelectionModel of. If not passed, as toggle() occur, items are tracked. * @param propAccessor Optional string that points to a unique field within the T type. Used for quickly looking up. */ -export class SelectionModel { +export class SelectionModel { _data!: Array<{value: T, selected: boolean}>; _propAccessor: string = ''; @@ -137,13 +137,13 @@ const ANIMATION_SPEED = 200; changeDetection: ChangeDetectionStrategy.OnPush, animations: [ trigger('slideFromTop', [ - state('in', style({ height: '0px', overflow: 'hidden'})), + state('in', style({ height: '0px'})), transition('void => *', [ style({ height: '100%', overflow: 'auto' }), animate(ANIMATION_SPEED) ]), transition('* => void', [ - animate(ANIMATION_SPEED, style({ height: '0px', overflow: 'hidden' })), + animate(ANIMATION_SPEED, style({ height: '0px' })), ]) ]) ] diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html index 8a2bf01b2..e1a7c8c13 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html @@ -247,7 +247,7 @@
  - How content should be laid out. Default is as the book packs it. 1 or 2 Column fits to the height of the device and fits 1 or 2 columns of text per page + How content should be laid out. Scroll is as the book packs it. 1 or 2 Column fits to the height of the device and fits 1 or 2 columns of text per page