First PR of the new year (#1717)

* Fixed a bug on bookmark mode not finding correct image for prefetcher.

* Fixed up the edit series relationship modal on tablet viewports.

* On double page mode, only bookmark 1 page if only 1 pages is renderered on screen.

* Added percentage read of a given library and average hours read per week to user stats.

* Fixed a bug in the reader with paging in bookmark mode

* Added a "This Week" option to top readers history

* Added date ranges for reading time. Added dates that don't have anything, but might remove.

* On phone, when applying a metadata filter, when clicking apply, collapse the filter automatically.

* Disable jump bar and the resuming from last spot when a custom sort is applied.

* Ensure all Regex.Replace or Matches have timeouts set

* Fixed a long standing bug where fit to height on tablets wouldn't center the image

* Streamlined url parsing to be more reliable

* Reduced an additional db query in chapter info.

* Added a missing task to convert covers to webP and added messaging to help the user understand to run it after modifying the setting.

* Changed OPDS to be enabled by default for new installs. This should reduce issues with users being confused about it before it's enabled.

* When there are multiple files for a chapter, show a count card on the series detail to help user understand duplicates exist. Made the unread badge smaller to avoid collision.

* Added Word Count to user stats and wired up average reading per week.

* Fixed word count failing on some epubs

* Removed some debug code

* Don't give more information than is necessary about file paths for page dimensions.

* Fixed a bug where pagination area would be too small when the book's content was less that height on default mode.

* Updated Default layout mode to Scroll for books.

* Added bytes in the UI and at an API layer for CDisplayEx

* Don't log health checks to logs at all.

* Changed Word Count to Length to match the way pages work

* Made reading time more clear when min hours is 0

* Apply more aggressive coalescing when remapping bad metadata keys for epubs.

* Changed the amount of padding between icon and text for side nav item.

* Fixed a NPE on book reader (harmless)

* Fixed an ordering issue where Volume 1 was a single file but also tagged as Chapter 1 and Volume 2 was Chapter 0. Thus Volume 2 was being selected for continue point when Volume 1 should have been.

* When clicking on an activity stream header from dashboard, show the title on the resulting page.

* Removed a property that can't be animated

* Fixed a typeahead typescript issue

* Added Size into Series Info and Added some tooltip and spacing changes to better explain some fields.

* Added size for volume drawers and cleaned up some date edge case handling

* Fixed an annoying bug where when on mobile opening a view with a metadata filter, Kavita would open the filter automatically.
This commit is contained in:
Joe Milazzo 2023-01-02 15:44:29 -07:00 committed by GitHub
parent 8eb5b466ef
commit a545f96a05
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 410 additions and 187 deletions

View File

@ -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<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 3),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 1),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
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()
{

View File

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

View File

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

View File

@ -143,6 +143,19 @@ public class ServerController : BaseApiController
return Ok();
}
/// <summary>
/// Triggers the scheduling of the convert covers job. Only one job will run at a time.
/// </summary>
/// <returns></returns>
[HttpPost("convert-covers")]
public ActionResult ScheduleConvertCovers()
{
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty<object>(),
TaskScheduler.DefaultQueue, true)) return Ok();
BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllCoverToWebP());
return Ok();
}
[HttpGet("logs")]
public ActionResult GetLogs()
{

View File

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

View File

@ -10,6 +10,10 @@ public class UserReadStatistics
/// </summary>
public long TotalPagesRead { get; set; }
/// <summary>
/// Total number of words read
/// </summary>
public long TotalWordsRead { get; set; }
/// <summary>
/// Total time spent reading based on estimates
/// </summary>
public long TimeSpentReading { get; set; }

View File

@ -18,6 +18,7 @@ public enum ChapterIncludes
{
None = 1,
Volumes = 2,
Files = 4
}
public interface IChapterRepository
@ -26,7 +27,7 @@ public interface IChapterRepository
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None);
Task<IChapterInfoDto> GetChapterInfoDtoAsync(int chapterId);
Task<int> GetChapterTotalPagesAsync(int chapterId);
Task<Chapter> GetChapterAsync(int chapterId);
Task<Chapter> GetChapterAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files);
Task<ChapterDto> GetChapterDtoAsync(int chapterId);
Task<ChapterMetadataDto> GetChapterMetadataDtoAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
@ -34,6 +35,7 @@ public interface IChapterRepository
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
Task<string> GetChapterCoverImageAsync(int chapterId);
Task<IList<string>> GetAllCoverImagesAsync();
Task<IList<Chapter>> GetAllChaptersWithNonWebPCovers();
Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync();
}
public class ChapterRepository : IChapterRepository
@ -162,12 +164,17 @@ public class ChapterRepository : IChapterRepository
/// Returns a Chapter for an Id. Includes linked <see cref="MangaFile"/>s.
/// </summary>
/// <param name="chapterId"></param>
/// <param name="includes"></param>
/// <returns></returns>
public async Task<Chapter> GetChapterAsync(int chapterId)
public async Task<Chapter> 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<IList<Chapter>> GetAllChaptersWithNonWebPCovers()
{
return await _context.Chapter
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp"))
.ToListAsync();
}
/// <summary>
/// Returns cover images for locked chapters
/// </summary>

View File

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

View File

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

View File

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

View File

@ -33,17 +33,6 @@ public interface IBookService
{
int GetNumberOfPages(string filePath);
string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP = false);
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
/// <summary>
/// Scopes styles to .reading-section and replaces img src to the passed apiBase
/// </summary>
/// <param name="stylesheetHtml"></param>
/// <param name="apiBase"></param>
/// <param name="filename">If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath.</param>
/// <param name="book">Book Reference, needed for if you expect Import statements</param>
/// <returns></returns>
Task<string> ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book);
ComicInfo GetComicInfo(string filePath);
ParserInfo ParseInfo(string filePath);
/// <summary>
@ -53,11 +42,9 @@ public interface IBookService
/// <param name="fileFilePath"></param>
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
void ExtractPdfImages(string fileFilePath, string targetDirectory);
Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page);
Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter);
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl);
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
}
public class BookService : IBookService
@ -163,6 +150,14 @@ public class BookService : IBookService
anchor.Attributes.Add("href", "javascript:void(0)");
}
/// <summary>
/// Scopes styles to .reading-section and replaces img src to the passed apiBase
/// </summary>
/// <param name="stylesheetHtml"></param>
/// <param name="apiBase"></param>
/// <param name="filename">If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath.</param>
/// <param name="book">Book Reference, needed for if you expect Import statements</param>
/// <returns></returns>
public async Task<string> 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);
}
/// <summary>
/// Tries to find the correct key by applying cleaning and remapping if the epub has bad data. Only works for HTML files.
/// </summary>
/// <param name="book"></param>
/// <param name="mappings"></param>
/// <param name="key"></param>
/// <returns></returns>
private static string CoalesceKey(EpubBookRef book, IDictionary<string, int> 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;
}
/// <summary>
/// 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");

View File

@ -22,6 +22,7 @@ public interface IBookmarkService
Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> 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");
}
/// <summary>
/// This is a long-running job that will convert all covers into WebP. Do not invoke anyway except via Hangfire.
/// </summary>
[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");
}
/// <summary>
/// This is a job that runs after a bookmark is saved
/// </summary>
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();
}
/// <summary>
/// Converts bookmark file, deletes original, marks bookmark as dirty. Does not commit.
/// Converts an image file, deletes original and returns the new path back
/// </summary>
/// <param name="bookmarkDirectory"></param>
/// <param name="bookmark"></param>
private async Task SaveBookmarkAsWebP(string bookmarkDirectory, AppUserBookmark bookmark)
/// <param name="imageDirectory">Full Path to where files are stored</param>
/// <param name="filename">The file to convert</param>
/// <param name="targetFolder">Full path to where files should be stored or any stem</param>
/// <returns></returns>
private async Task<string> 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)

View File

@ -82,7 +82,7 @@ public class CacheService : ICacheService
PageNumber = i,
Height = image.Height,
Width = image.Width,
FileName = file
FileName = file.Replace(path, string.Empty)
});
}

View File

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

View File

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

View File

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

View File

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

View File

@ -106,6 +106,10 @@ public static class MessageFactory
/// </summary>
private const string ConvertBookmarksProgress = "ConvertBookmarksProgress";
/// <summary>
/// When bulk covers are being converted
/// </summary>
private const string ConvertCoversProgress = "ConvertBookmarksProgress";
/// <summary>
/// When files are being scanned to calculate word count
/// </summary>
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
}
};
}
}

View File

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

View File

@ -6,4 +6,5 @@ export interface MangaFile {
pages: number;
format: MangaFormat;
created: string;
bytes: number;
}

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@
<div class="row g-0">
<p>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 <a href="https://caniuse.com/?search=webp" target="_blank" rel="noopener noreferrer">Can I Use</a>.</p>
<div *ngIf="settingsForm.dirty" class="alert alert-danger" role="alert">You must trigger the conversion to WebP task in Tasks Tab.</div>
<div class="col-md-6 col-sm-12 mb-3">
<label for="bookmark-webp" class="form-label me-1" aria-describedby="settings-convertBookmarkToWebP-help">Save Bookmarks as WebP</label>
<i class="fa fa-info-circle" placement="right" [ngbTooltip]="convertBookmarkToWebPTooltip" role="button" tabindex="0"></i>

View File

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

View File

@ -1,6 +1,6 @@
<app-side-nav-companion-bar [hasFilter]="true" [filterOpenByDefault]="filterSettings.openByDefault" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h2 title>
All Series
{{title}}
</h2>
<h6 subtitle *ngIf="pagination">{{pagination.totalItems}} Series</h6>
</app-side-nav-companion-bar>

View File

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

View File

@ -84,7 +84,7 @@
tabindex="-1" [ngStyle]="{height: PageHeightForPagination}"></div>
</ng-container>
<div class="book-container" [ngClass]="{'immersive' : immersiveMode}">
<div #bookContainer class="book-container" [ngClass]="{'immersive' : immersiveMode}">
<div #readingHtml class="book-content {{ColumnLayout}}" [ngStyle]="{'max-height': ColumnHeight, 'column-width': ColumnWidth}"
[ngClass]="{'immersive': immersiveMode && actionBarVisible}"

View File

@ -252,7 +252,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly onDestroy = new Subject<void>();
@ViewChild('readingHtml', {static: false}) readingHtml!: ElementRef<HTMLDivElement>;
@ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef<HTMLDivElement>;
/**
* book-content class
*/
@ViewChild('bookContentElemRef', {static: false}) bookContentElemRef!: ElementRef<HTMLDivElement>;
@ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef<HTMLDivElement>;
@ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef<HTMLDivElement>;
@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);
}
});
}

View File

@ -108,14 +108,14 @@
<div class="controls">
<label id="layout-mode" class="form-label" style="margin-bottom:0.5rem">Layout Mode&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="top" [ngbTooltip]="layoutTooltip" role="button" tabindex="1" aria-describedby="layout-help"></i></label>
<ng-template #layoutTooltip>Default: Mirrors epub file (usually one long scrolling page per chapter).<br/>1 Column: Creates a single virtual page at a time.<br/>2 Column: Creates two virtual pages at a time laid out side-by-side.</ng-template>
<ng-template #layoutTooltip>Scroll: Mirrors epub file (usually one long scrolling page per chapter).<br/>1 Column: Creates a single virtual page at a time.<br/>2 Column: Creates two virtual pages at a time laid out side-by-side.</ng-template>
<span class="visually-hidden" id="layout-help">
<ng-container [ngTemplateOutlet]="layoutTooltip"></ng-container>
</span>
<br>
<div class="btn-group d-flex justify-content-center" role="group" aria-label="Layout Mode">
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Default" class="btn-check" id="layout-mode-default" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-default">Default</label>
<label class="btn btn-outline-primary" for="layout-mode-default">Scroll</label>
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column1" class="btn-check" id="layout-mode-col1" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col1">1 Column</label>

View File

@ -369,18 +369,24 @@
</div>
<div class="row g-0 mb-2">
<div class="col-md-6">Created: {{series.created | date:'shortDate'}}</div>
<div class="col-md-6">Last Read: {{series.latestReadDate | date:'shortDate' | defaultDate}}</div>
<div class="col-md-6">Last Added To: {{series.lastChapterAdded | date:'short' | defaultDate}}</div>
<div class="col-md-6">Last Scanned: {{series.lastFolderScanned | date:'short' | defaultDate}}</div>
<div class="col-md-6">Folder Path: {{series.folderPath | defaultValue}}</div>
<div class="col-md-6">Last Read: {{series.latestReadDate | defaultDate | timeAgo}}</div>
<div class="col-md-6">Last Added To: {{series.lastChapterAdded | defaultDate | timeAgo}}</div>
<div class="col-md-6">Last Scanned: {{series.lastFolderScanned | defaultDate | timeAgo}}</div>
</div>
<div class="row g-0 mb-2">
<div class="col-auto">Folder Path: {{series.folderPath | defaultValue}}</div>
</div>
<div class="row g-0 mb-2" *ngIf="metadata">
<!-- TODO: Put tooltips in here to explain to the user what these are (ComicInfo tags) -->
<div class="col-md-6">Max Items: {{metadata.maxCount}}</div>
<div class="col-md-6">Total Items: {{metadata.totalCount}}</div>
<div class="col-md-6">
Max Items: {{metadata.maxCount}} <i class="fa fa-info-circle ms-1" placement="right" ngbTooltip="Max of Volume/Issue field in ComicInfo. Used in conjunction with total items to determine publication status." role="button" tabindex="0"></i>
</div>
<div class="col-md-6" title="">
Total Items: {{metadata.totalCount}} <i class="fa fa-info-circle ms-1" placement="right" ngbTooltip="Total number of issues/volumes in the series" role="button" tabindex="0"></i>
</div>
<div class="col-md-6">Publication Status: {{metadata.publicationStatus | publicationStatus}}</div>
<div class="col-md-6">Total Pages: {{series.pages}}</div>
<div class="col-md-6">Size: {{size | bytes}}</div>
</div>
<h4>Volumes</h4>
<div class="spinner-border text-secondary" role="status" *ngIf="isLoadingVolumes">

View File

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

View File

@ -146,12 +146,14 @@
Added:
<!-- TODO: This data.created can be removed after v0.5.5 release -->
<ng-container *ngIf="file.created == '0001-01-01T00:00:00'; else fileDate">
{{(data.created | date: 'short') || '-'}}
{{data.created | date: 'short' | defaultDate}}
</ng-container>
<ng-template #fileDate>
{{(file.created | date: 'short') || '-'}}
{{file.created | date: 'short' | defaultDate}}
</ng-template>
</div>
<div class="col">
Size: {{file.bytes | bytes}}
</div>
</div>
</li>

View File

@ -2,7 +2,7 @@
<ng-container *ngIf="chapter !== undefined && chapter.releaseDate && (chapter.releaseDate | date: 'shortDate') !== '1/1/01'">
<div class="col-auto mb-2">
<app-icon-and-title label="Release Date" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release">
{{chapter.releaseDate | date:'shortDate'}}
{{chapter.releaseDate | date:'shortDate' | defaultDate}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
@ -28,7 +28,7 @@
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && totalWordCount > 0">
<div class="col-auto mb-2">
<app-icon-and-title label="Word Count" [clickable]="false" fontClasses="fa-solid fa-book-open">
<app-icon-and-title label="Length" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{totalWordCount | compactNumber}} Words
</app-icon-and-title>
</div>
@ -38,7 +38,7 @@
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && totalWordCount > 0 || chapter.files[0].format !== MangaFormat.EPUB">
<div class="col-auto mb-2">
<app-icon-and-title label="Read Time" [clickable]="false" fontClasses="fa-regular fa-clock">
<ng-container *ngIf="readingTime.maxHours === 0; else normalReadTime">&lt;1 Hour</ng-container>
<ng-container *ngIf="readingTime.maxHours === 0 || readingTime.minHours === 0; else normalReadTime">&lt;1 Hour</ng-container>
<ng-template #normalReadTime>
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
</ng-template>
@ -50,7 +50,16 @@
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title label="Date Added" [clickable]="false" fontClasses="fa-solid fa-file-import" title="Date Added">
{{chapter.created | date:'short' || '-'}}
{{chapter.created | date:'short' | defaultDate}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="showExtendedProperties && size > 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title label="Size" [clickable]="false" fontClasses="fa-solid fa-scale-unbalanced" title="ID">
{{size | bytes}}
</app-icon-and-title>
</div>
</ng-container>

View File

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

View File

@ -61,7 +61,7 @@
<ng-container *ngIf="series.format === MangaFormat.EPUB; else showPages">
<ng-container *ngIf="series.wordCount > 0">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Word Count" [clickable]="false" fontClasses="fa-solid fa-book-open">
<app-icon-and-title label="Length" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{series.wordCount | compactNumber}} Words
</app-icon-and-title>
</div>
@ -81,7 +81,7 @@
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0 || series.format !== MangaFormat.EPUB">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Read Time" [clickable]="false" fontClasses="fa-regular fa-clock">
<ng-container *ngIf="readingTime.maxHours === 0; else normalReadTime">&lt;1 Hour</ng-container>
<ng-container *ngIf="readingTime.maxHours === 0 || readingTime.minHours === 0; else normalReadTime">&lt;1 Hour</ng-container>
<ng-template #normalReadTime>
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
</ng-template>

View File

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

View File

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

View File

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

View File

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

View File

@ -148,6 +148,7 @@
<app-card-item class="col-auto mt-2 mb-2" *ngIf="!item.chapter.isSpecial" [entity]="item.chapter" [title]="item.chapter.title" (click)="openChapter(item.chapter)"
[imageUrl]="imageService.getChapterCoverImage(item.chapter.id)"
[read]="item.chapter.pagesRead" [total]="item.chapter.pages" [actions]="chapterActions"
[count]="item.chapter.files.length"
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, storyChapters.length, $event)"
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true"></app-card-item>
</ng-template>
@ -227,6 +228,7 @@
<app-card-item class="col-auto mt-2 mb-2" *ngIf="!item.isSpecial" [entity]="item" [title]="item.title" (click)="openChapter(item)"
[imageUrl]="imageService.getChapterCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
[count]="item.files.length"
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, chapters.length, $event)"
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
<ng-container title>
@ -263,6 +265,7 @@
<app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.title || item.range" (click)="openChapter(item)"
[imageUrl]="imageService.getChapterCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
[count]="item.files.length"
(selection)="bulkSelectionService.handleCardSelection('special', scroll.viewPortInfo.startIndexWithBuffer + idx, chapters.length, $event)"
[selected]="bulkSelectionService.isCardSelected('special', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
</app-card-item>

View File

@ -11,7 +11,6 @@
cursor: pointer;
.side-nav-text {
padding-left: 10px;
opacity: 1;
min-width: 100px;

View File

@ -8,6 +8,15 @@
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Words Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Total Words Read">
{{totalWordsRead | compactNumber}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container >
<div class="col-auto mb-2">
<app-icon-and-title label="Time Spent Reading" [clickable]="false" fontClasses="fas fa-eye" title="Time Spent Reading">
@ -20,7 +29,7 @@
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Average Hours Read / Week" [clickable]="false" fontClasses="fas fa-eye" title="Average Hours Read / Week">
{{avgHoursPerWeekSpentReading | compactNumber}} hours
{{avgHoursPerWeekSpentReading | compactNumber | number: '1.0-2'}} hours
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>

View File

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

View File

@ -1,10 +1,9 @@
<div class="container-fluid" *ngIf="userId">
<!-- High level stats (use same design as series metadata info cards)-->
<div class="row g-0 d-flex justify-content-around">
<ng-container *ngIf="userStats$ | async as userStats">
<app-user-stats-info-cards [totalPagesRead]="userStats.totalPagesRead" [timeSpentReading]="userStats.timeSpentReading"
[chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActive"></app-user-stats-info-cards>
<app-user-stats-info-cards [totalPagesRead]="userStats.totalPagesRead" [totalWordsRead]="userStats.totalWordsRead" [timeSpentReading]="userStats.timeSpentReading"
[chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActive" [avgHoursPerWeekSpentReading]="userStats.avgHoursPerWeekSpentReading"></app-user-stats-info-cards>
</ng-container>
</div>
@ -17,14 +16,4 @@
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
<app-stat-list [data$]="precentageRead$" label="% Read" title="Library Read Progress"></app-stat-list>
</div>
<!-- <div class="row g-0">
Books Read (this can be chapters read fully)
Number of bookmarks
Last Active Time
Average days reading on server a week
Total Series in want to read list?
</div> -->
</div>

View File

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

View File

@ -2,6 +2,7 @@ import { StatCount } from "./stat-count";
export interface UserReadStatistics {
totalPagesRead: number;
totalWordsRead: number;
timeSpentReading: number;
chaptersRead: number;
lastActive: string;

View File

@ -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<T> {
export class SelectionModel<T extends object> {
_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' })),
])
])
]

View File

@ -247,7 +247,7 @@
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-book-layout-mode" class="form-label">Layout Mode</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookLayoutModeTooltip" role="button" tabindex="0"></i>
<ng-template #bookLayoutModeTooltip>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</ng-template>
<ng-template #bookLayoutModeTooltip>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</ng-template>
<span class="visually-hidden" id="settings-book-layout-mode-help"><ng-container [ngTemplateOutlet]="bookLayoutModeTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-book-layout-mode-help" formControlName="bookReaderLayoutMode" id="settings-book-layout-mode">
<option *ngFor="let opt of bookLayoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>

View File

@ -2,51 +2,3 @@
background-color: var(--side-nav-bg-color);
box-shadow: var(--side-nav-box-shadow);
}
// @media (max-width: 576px) {
// .side-nav-item {
// align-items: center;
// display: flex;
// justify-content: space-between;
// padding: 15px 10px;
// width: 100%;
// height: 70px;
// min-height: 40px;
// overflow: hidden;
// font-size: 1rem;
// cursor: pointer; // This needs to be based a flag
// .side-nav-text {
// padding-left: 10px;
// opacity: 1;
// min-width: 100px;
// width: 100%;
// div {
// min-width: 102px;
// width: 100%
// }
// }
// &.closed {
// .side-nav-text {
// opacity: 0;
// }
// .card-actions {
// opacity: 0;
// font-size: inherit
// }
// }
// span {
// &:last-child {
// flex-grow: 1;
// justify-content: end;
// }
// }
// }
// }

View File

@ -194,7 +194,7 @@
--card-progress-bar-color: var(--primary-color);
--card-overlay-bg-color: rgba(0, 0, 0, 0);
--card-overlay-hover-bg-color: rgba(0, 0, 0, 0.2);
--card-progress-triangle-size: 30px;
--card-progress-triangle-size: 20px;
/* Slider */
--slider-text-color: white;

View File

@ -7146,6 +7146,19 @@
}
}
},
"/api/Server/convert-covers": {
"post": {
"tags": [
"Server"
],
"summary": "Triggers the scheduling of the convert covers job. Only one job will run at a time.",
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Server/logs": {
"get": {
"tags": [
@ -11153,6 +11166,10 @@
"type": "integer",
"format": "int32"
},
"bytes": {
"type": "integer",
"format": "int64"
},
"format": {
"$ref": "#/components/schemas/MangaFormat"
},
@ -14150,6 +14167,11 @@
"description": "Total number of pages read",
"format": "int64"
},
"totalWordsRead": {
"type": "integer",
"description": "Total number of words read",
"format": "int64"
},
"timeSpentReading": {
"type": "integer",
"description": "Total time spent reading based on estimates",