mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
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:
parent
8eb5b466ef
commit
a545f96a05
@ -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()
|
||||
{
|
||||
|
@ -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];
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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; }
|
||||
|
||||
|
@ -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; }
|
||||
|
@ -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>
|
||||
|
@ -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()},
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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");
|
||||
|
@ -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)
|
||||
|
@ -82,7 +82,7 @@ public class CacheService : ICacheService
|
||||
PageNumber = i,
|
||||
Height = image.Height,
|
||||
Width = image.Width,
|
||||
FileName = file
|
||||
FileName = file.Replace(path, string.Empty)
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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());
|
||||
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -6,4 +6,5 @@ export interface MangaFile {
|
||||
pages: number;
|
||||
format: MangaFormat;
|
||||
created: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
@ -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}];
|
||||
|
@ -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) {
|
||||
|
@ -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', {});
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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.',
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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}"
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -108,14 +108,14 @@
|
||||
|
||||
<div class="controls">
|
||||
<label id="layout-mode" class="form-label" style="margin-bottom:0.5rem">Layout Mode <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>
|
||||
|
@ -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">
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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"><1 Hour</ng-container>
|
||||
<ng-container *ngIf="readingTime.maxHours === 0 || readingTime.minHours === 0; else normalReadTime"><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>
|
||||
|
@ -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;
|
||||
|
@ -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"><1 Hour</ng-container>
|
||||
<ng-container *ngIf="readingTime.maxHours === 0 || readingTime.minHours === 0; else normalReadTime"><1 Hour</ng-container>
|
||||
<ng-template #normalReadTime>
|
||||
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
|
||||
</ng-template>
|
||||
|
@ -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});
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -11,7 +11,6 @@
|
||||
cursor: pointer;
|
||||
|
||||
.side-nav-text {
|
||||
padding-left: 10px;
|
||||
opacity: 1;
|
||||
min-width: 100px;
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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 = '';
|
||||
|
@ -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>
|
@ -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',
|
||||
|
@ -2,6 +2,7 @@ import { StatCount } from "./stat-count";
|
||||
|
||||
export interface UserReadStatistics {
|
||||
totalPagesRead: number;
|
||||
totalWordsRead: number;
|
||||
timeSpentReading: number;
|
||||
chaptersRead: number;
|
||||
lastActive: string;
|
||||
|
@ -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' })),
|
||||
])
|
||||
])
|
||||
]
|
||||
|
@ -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> <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>
|
||||
|
@ -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;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
@ -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;
|
||||
|
22
openapi.json
22
openapi.json
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user