mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-23 15:30:34 -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);
|
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]
|
[Fact]
|
||||||
public async Task GetContinuePoint_ShouldReturnFirstNonSpecial()
|
public async Task GetContinuePoint_ShouldReturnFirstNonSpecial()
|
||||||
{
|
{
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@ -8,7 +7,6 @@ using API.DTOs.Reader;
|
|||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using HtmlAgilityPack;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using VersOne.Epub;
|
using VersOne.Epub;
|
||||||
@ -97,7 +95,7 @@ public class BookController : BaseApiController
|
|||||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||||
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions);
|
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.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");
|
if (!book.Content.AllFiles.ContainsKey(key)) return BadRequest("File was not found in book");
|
||||||
|
|
||||||
var bookFile = book.Content.AllFiles[key];
|
var bookFile = book.Content.AllFiles[key];
|
||||||
|
@ -187,7 +187,7 @@ public class ReaderController : BaseApiController
|
|||||||
|
|
||||||
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
|
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
|
||||||
if (dto == null) return BadRequest("Please perform a scan on this series or library and try again");
|
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()
|
var info = new ChapterInfoDto()
|
||||||
{
|
{
|
||||||
|
@ -143,6 +143,19 @@ public class ServerController : BaseApiController
|
|||||||
return Ok();
|
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")]
|
[HttpGet("logs")]
|
||||||
public ActionResult GetLogs()
|
public ActionResult GetLogs()
|
||||||
{
|
{
|
||||||
|
@ -8,6 +8,7 @@ public class MangaFileDto
|
|||||||
public int Id { get; init; }
|
public int Id { get; init; }
|
||||||
public string FilePath { get; init; }
|
public string FilePath { get; init; }
|
||||||
public int Pages { get; init; }
|
public int Pages { get; init; }
|
||||||
|
public long Bytes { get; init; }
|
||||||
public MangaFormat Format { get; init; }
|
public MangaFormat Format { get; init; }
|
||||||
public DateTime Created { get; init; }
|
public DateTime Created { get; init; }
|
||||||
|
|
||||||
|
@ -10,6 +10,10 @@ public class UserReadStatistics
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long TotalPagesRead { get; set; }
|
public long TotalPagesRead { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Total number of words read
|
||||||
|
/// </summary>
|
||||||
|
public long TotalWordsRead { get; set; }
|
||||||
|
/// <summary>
|
||||||
/// Total time spent reading based on estimates
|
/// Total time spent reading based on estimates
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long TimeSpentReading { get; set; }
|
public long TimeSpentReading { get; set; }
|
||||||
|
@ -18,6 +18,7 @@ public enum ChapterIncludes
|
|||||||
{
|
{
|
||||||
None = 1,
|
None = 1,
|
||||||
Volumes = 2,
|
Volumes = 2,
|
||||||
|
Files = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IChapterRepository
|
public interface IChapterRepository
|
||||||
@ -26,7 +27,7 @@ public interface IChapterRepository
|
|||||||
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None);
|
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None);
|
||||||
Task<IChapterInfoDto> GetChapterInfoDtoAsync(int chapterId);
|
Task<IChapterInfoDto> GetChapterInfoDtoAsync(int chapterId);
|
||||||
Task<int> GetChapterTotalPagesAsync(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<ChapterDto> GetChapterDtoAsync(int chapterId);
|
||||||
Task<ChapterMetadataDto> GetChapterMetadataDtoAsync(int chapterId);
|
Task<ChapterMetadataDto> GetChapterMetadataDtoAsync(int chapterId);
|
||||||
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
|
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
|
||||||
@ -34,6 +35,7 @@ public interface IChapterRepository
|
|||||||
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
|
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
|
||||||
Task<string> GetChapterCoverImageAsync(int chapterId);
|
Task<string> GetChapterCoverImageAsync(int chapterId);
|
||||||
Task<IList<string>> GetAllCoverImagesAsync();
|
Task<IList<string>> GetAllCoverImagesAsync();
|
||||||
|
Task<IList<Chapter>> GetAllChaptersWithNonWebPCovers();
|
||||||
Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync();
|
Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync();
|
||||||
}
|
}
|
||||||
public class ChapterRepository : IChapterRepository
|
public class ChapterRepository : IChapterRepository
|
||||||
@ -162,12 +164,17 @@ public class ChapterRepository : IChapterRepository
|
|||||||
/// Returns a Chapter for an Id. Includes linked <see cref="MangaFile"/>s.
|
/// Returns a Chapter for an Id. Includes linked <see cref="MangaFile"/>s.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="chapterId"></param>
|
/// <param name="chapterId"></param>
|
||||||
|
/// <param name="includes"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<Chapter> GetChapterAsync(int chapterId)
|
public async Task<Chapter> GetChapterAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files)
|
||||||
{
|
{
|
||||||
return await _context.Chapter
|
var query = _context.Chapter
|
||||||
.Include(c => c.Files)
|
.AsSplitQuery();
|
||||||
.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);
|
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,6 +214,13 @@ public class ChapterRepository : IChapterRepository
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IList<Chapter>> GetAllChaptersWithNonWebPCovers()
|
||||||
|
{
|
||||||
|
return await _context.Chapter
|
||||||
|
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp"))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns cover images for locked chapters
|
/// Returns cover images for locked chapters
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -90,7 +90,7 @@ public static class Seed
|
|||||||
Key = ServerSettingKey.Port, Value = "5000"
|
Key = ServerSettingKey.Port, Value = "5000"
|
||||||
}, // Not used from DB, but DB is sync with appSettings.json
|
}, // Not used from DB, but DB is sync with appSettings.json
|
||||||
new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"},
|
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.EnableAuthentication, Value = "true"},
|
||||||
new() {Key = ServerSettingKey.BaseUrl, Value = "/"},
|
new() {Key = ServerSettingKey.BaseUrl, Value = "/"},
|
||||||
new() {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()},
|
new() {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()},
|
||||||
|
@ -15,5 +15,6 @@ public static class LogEnricher
|
|||||||
{
|
{
|
||||||
diagnosticContext.Set("ClientIP", httpContext.Connection.RemoteIpAddress?.ToString());
|
diagnosticContext.Set("ClientIP", httpContext.Connection.RemoteIpAddress?.ToString());
|
||||||
diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].FirstOrDefault());
|
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 API.Services;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Serilog.Core;
|
using Serilog.Core;
|
||||||
@ -57,7 +60,18 @@ public static class LogLevelOptions
|
|||||||
.WriteTo.File(LogFile,
|
.WriteTo.File(LogFile,
|
||||||
shared: true,
|
shared: true,
|
||||||
rollingInterval: RollingInterval.Day,
|
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)
|
public static void SwitchLogLevel(string level)
|
||||||
|
@ -33,17 +33,6 @@ public interface IBookService
|
|||||||
{
|
{
|
||||||
int GetNumberOfPages(string filePath);
|
int GetNumberOfPages(string filePath);
|
||||||
string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP = false);
|
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);
|
ComicInfo GetComicInfo(string filePath);
|
||||||
ParserInfo ParseInfo(string filePath);
|
ParserInfo ParseInfo(string filePath);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -53,11 +42,9 @@ public interface IBookService
|
|||||||
/// <param name="fileFilePath"></param>
|
/// <param name="fileFilePath"></param>
|
||||||
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</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);
|
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<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter);
|
||||||
|
|
||||||
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl);
|
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl);
|
||||||
|
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BookService : IBookService
|
public class BookService : IBookService
|
||||||
@ -163,6 +150,14 @@ public class BookService : IBookService
|
|||||||
anchor.Attributes.Add("href", "javascript:void(0)");
|
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)
|
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
|
// @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);
|
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)
|
private static string CoalesceKey(EpubBookRef book, IDictionary<string, int> mappings, string key)
|
||||||
{
|
{
|
||||||
if (mappings.ContainsKey(CleanContentKeys(key))) return key;
|
if (mappings.ContainsKey(CleanContentKeys(key))) return key;
|
||||||
@ -731,6 +733,23 @@ public class BookService : IBookService
|
|||||||
return key;
|
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>
|
/// <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 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.
|
/// 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;
|
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.
|
// 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);
|
doc.LoadHtml(content);
|
||||||
var body = doc.DocumentNode.SelectSingleNode("//body");
|
var body = doc.DocumentNode.SelectSingleNode("//body");
|
||||||
|
@ -22,6 +22,7 @@ public interface IBookmarkService
|
|||||||
Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> bookmarkIds);
|
Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> bookmarkIds);
|
||||||
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
|
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
|
||||||
Task ConvertAllBookmarkToWebP();
|
Task ConvertAllBookmarkToWebP();
|
||||||
|
Task ConvertAllCoverToWebP();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,7 +184,9 @@ public class BookmarkService : IBookmarkService
|
|||||||
var count = 1F;
|
var count = 1F;
|
||||||
foreach (var bookmark in bookmarks)
|
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 _unitOfWork.CommitAsync();
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
MessageFactory.ConvertBookmarksProgressEvent(count / bookmarks.Count, ProgressEventType.Started));
|
MessageFactory.ConvertBookmarksProgressEvent(count / bookmarks.Count, ProgressEventType.Started));
|
||||||
@ -196,10 +199,40 @@ public class BookmarkService : IBookmarkService
|
|||||||
_logger.LogInformation("[BookmarkService] Converted bookmarks to WebP");
|
_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>
|
/// <summary>
|
||||||
/// This is a job that runs after a bookmark is saved
|
/// This is a job that runs after a bookmark is saved
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task ConvertBookmarkToWebP(int bookmarkId)
|
private async Task ConvertBookmarkToWebP(int bookmarkId)
|
||||||
{
|
{
|
||||||
var bookmarkDirectory =
|
var bookmarkDirectory =
|
||||||
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
|
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
|
||||||
@ -212,46 +245,52 @@ public class BookmarkService : IBookmarkService
|
|||||||
var bookmark = await _unitOfWork.UserRepository.GetBookmarkAsync(bookmarkId);
|
var bookmark = await _unitOfWork.UserRepository.GetBookmarkAsync(bookmarkId);
|
||||||
if (bookmark == null) return;
|
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();
|
await _unitOfWork.CommitAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="bookmarkDirectory"></param>
|
/// <param name="imageDirectory">Full Path to where files are stored</param>
|
||||||
/// <param name="bookmark"></param>
|
/// <param name="filename">The file to convert</param>
|
||||||
private async Task SaveBookmarkAsWebP(string bookmarkDirectory, AppUserBookmark bookmark)
|
/// <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 fullSourcePath = _directoryService.FileSystem.Path.Join(imageDirectory, filename);
|
||||||
var fullTargetDirectory = fullSourcePath.Replace(new FileInfo(bookmark.FileName).Name, string.Empty);
|
var fullTargetDirectory = fullSourcePath.Replace(new FileInfo(filename).Name, string.Empty);
|
||||||
var targetFolderStem = BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId);
|
|
||||||
|
|
||||||
_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
|
try
|
||||||
{
|
{
|
||||||
// Convert target file to webp then delete original target file and update bookmark
|
// Convert target file to webp then delete original target file and update bookmark
|
||||||
|
|
||||||
var originalFile = bookmark.FileName;
|
var originalFile = filename;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var targetFile = await _imageService.ConvertToWebP(fullSourcePath, fullTargetDirectory);
|
var targetFile = await _imageService.ConvertToWebP(fullSourcePath, fullTargetDirectory);
|
||||||
var targetName = new FileInfo(targetFile).Name;
|
var targetName = new FileInfo(targetFile).Name;
|
||||||
bookmark.FileName = Path.Join(targetFolderStem, targetName);
|
newFilename = Path.Join(targetFolder, targetName);
|
||||||
_directoryService.DeleteFiles(new[] {fullSourcePath});
|
_directoryService.DeleteFiles(new[] {fullSourcePath});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Could not convert file {FilePath}", bookmark.FileName);
|
_logger.LogError(ex, "Could not convert image {FilePath}", filename);
|
||||||
bookmark.FileName = originalFile;
|
newFilename = originalFile;
|
||||||
}
|
}
|
||||||
_unitOfWork.UserRepository.Update(bookmark);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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)
|
private static string BookmarkStem(int userId, int seriesId, int chapterId)
|
||||||
|
@ -82,7 +82,7 @@ public class CacheService : ICacheService
|
|||||||
PageNumber = i,
|
PageNumber = i,
|
||||||
Height = image.Height,
|
Height = image.Height,
|
||||||
Width = image.Width,
|
Width = image.Width,
|
||||||
FileName = file
|
FileName = file.Replace(path, string.Empty)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,7 +3,6 @@ using System.IO;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NetVips;
|
using NetVips;
|
||||||
using SixLabors.ImageSharp;
|
|
||||||
using Image = NetVips.Image;
|
using Image = NetVips.Image;
|
||||||
|
|
||||||
namespace API.Services;
|
namespace API.Services;
|
||||||
|
@ -479,11 +479,14 @@ public class ReaderService : IReaderService
|
|||||||
var volumeChapters = volumes
|
var volumeChapters = volumes
|
||||||
.Where(v => v.Number != 0)
|
.Where(v => v.Number != 0)
|
||||||
.SelectMany(v => v.Chapters)
|
.SelectMany(v => v.Chapters)
|
||||||
.OrderBy(c => float.Parse(c.Number))
|
//.OrderBy(c => float.Parse(c.Number))
|
||||||
.ToList();
|
.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.
|
// 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;
|
if (currentlyReadingChapter != null) return currentlyReadingChapter;
|
||||||
|
|
||||||
// Order with volume 0 last so we prefer the natural order
|
// 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))
|
.Where(c => chapterIds.Contains(c.Id))
|
||||||
.SumAsync(c => c.AvgHoursToRead);
|
.SumAsync(c => c.AvgHoursToRead);
|
||||||
|
|
||||||
|
var totalWordsRead = await _context.Chapter
|
||||||
|
.Where(c => chapterIds.Contains(c.Id))
|
||||||
|
.SumAsync(c => c.WordCount);
|
||||||
|
|
||||||
var chaptersRead = await _context.AppUserProgresses
|
var chaptersRead = await _context.AppUserProgresses
|
||||||
.Where(p => p.AppUserId == userId)
|
.Where(p => p.AppUserId == userId)
|
||||||
.Where(p => libraryIds.Contains(p.LibraryId))
|
.Where(p => libraryIds.Contains(p.LibraryId))
|
||||||
@ -90,8 +94,7 @@ public class StatisticService : IStatisticService
|
|||||||
.AsEnumerable()
|
.AsEnumerable()
|
||||||
.GroupBy(g => g.series.LibraryId)
|
.GroupBy(g => g.series.LibraryId)
|
||||||
.ToDictionary(g => g.Key, g => g.Sum(c => c.chapter.Pages));
|
.ToDictionary(g => g.Key, g => g.Sum(c => c.chapter.Pages));
|
||||||
//
|
|
||||||
//
|
|
||||||
var totalProgressByLibrary = await _context.AppUserProgresses
|
var totalProgressByLibrary = await _context.AppUserProgresses
|
||||||
.Where(p => p.AppUserId == userId)
|
.Where(p => p.AppUserId == userId)
|
||||||
.Where(p => p.LibraryId > 0)
|
.Where(p => p.LibraryId > 0)
|
||||||
@ -108,11 +111,12 @@ public class StatisticService : IStatisticService
|
|||||||
.Where(p => p.AppUserId == userId)
|
.Where(p => p.AppUserId == userId)
|
||||||
.Join(_context.Chapter, p => p.ChapterId, c => c.Id,
|
.Join(_context.Chapter, p => p.ChapterId, c => c.Id,
|
||||||
(p, c) => (p.PagesRead / (float) c.Pages) * c.AvgHoursToRead)
|
(p, c) => (p.PagesRead / (float) c.Pages) * c.AvgHoursToRead)
|
||||||
.Average() / 7;
|
.Average() / 7.0;
|
||||||
|
|
||||||
return new UserReadStatistics()
|
return new UserReadStatistics()
|
||||||
{
|
{
|
||||||
TotalPagesRead = totalPagesRead,
|
TotalPagesRead = totalPagesRead,
|
||||||
|
TotalWordsRead = totalWordsRead,
|
||||||
TimeSpentReading = timeSpentReading,
|
TimeSpentReading = timeSpentReading,
|
||||||
ChaptersRead = chaptersRead,
|
ChaptersRead = chaptersRead,
|
||||||
LastActive = lastActive,
|
LastActive = lastActive,
|
||||||
@ -314,7 +318,7 @@ public class StatisticService : IStatisticService
|
|||||||
.Select(u => new ReadHistoryEvent
|
.Select(u => new ReadHistoryEvent
|
||||||
{
|
{
|
||||||
UserId = u.AppUserId,
|
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,
|
SeriesName = _context.Series.Single(s => s.Id == u.SeriesId).Name,
|
||||||
SeriesId = u.SeriesId,
|
SeriesId = u.SeriesId,
|
||||||
LibraryId = u.LibraryId,
|
LibraryId = u.LibraryId,
|
||||||
|
@ -243,8 +243,9 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
|||||||
var doc = new HtmlDocument();
|
var doc = new HtmlDocument();
|
||||||
doc.LoadHtml(await bookFile.ReadContentAsTextAsync());
|
doc.LoadHtml(await bookFile.ReadContentAsTextAsync());
|
||||||
|
|
||||||
return doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]")
|
var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]");
|
||||||
.DefaultIfEmpty()
|
if (textNodes == null) return 0;
|
||||||
|
return textNodes
|
||||||
.Select(node => node.InnerText.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
.Select(node => node.InnerText.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Where(s => char.IsLetter(s[0])))
|
.Where(s => char.IsLetter(s[0])))
|
||||||
.Sum(words => words.Count());
|
.Sum(words => words.Count());
|
||||||
|
@ -106,6 +106,10 @@ public static class MessageFactory
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private const string ConvertBookmarksProgress = "ConvertBookmarksProgress";
|
private const string ConvertBookmarksProgress = "ConvertBookmarksProgress";
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// When bulk covers are being converted
|
||||||
|
/// </summary>
|
||||||
|
private const string ConvertCoversProgress = "ConvertBookmarksProgress";
|
||||||
|
/// <summary>
|
||||||
/// When files are being scanned to calculate word count
|
/// When files are being scanned to calculate word count
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const string WordCountAnalyzerProgress = "WordCountAnalyzerProgress";
|
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 {
|
&.full-height {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: inline-block;
|
display: flex; // changed from inline-block to fix the centering on tablets not working
|
||||||
}
|
}
|
||||||
|
|
||||||
&.original {
|
&.original {
|
||||||
|
@ -6,4 +6,5 @@ export interface MangaFile {
|
|||||||
pages: number;
|
pages: number;
|
||||||
format: MangaFormat;
|
format: MangaFormat;
|
||||||
created: string;
|
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 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 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 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}];
|
export const pageLayoutModes = [{text: 'Cards', value: PageLayoutMode.Cards}, {text: 'List', value: PageLayoutMode.List}];
|
||||||
|
@ -191,12 +191,14 @@ export class ReaderService {
|
|||||||
*/
|
*/
|
||||||
imageUrlToPageNum(imageSrc: string) {
|
imageUrlToPageNum(imageSrc: string) {
|
||||||
if (imageSrc === undefined || imageSrc === '') { return -1; }
|
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) {
|
imageUrlToChapterId(imageSrc: string) {
|
||||||
if (imageSrc === undefined || imageSrc === '') { return -1; }
|
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) {
|
getNextChapterUrl(url: string, nextChapterId: number, incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) {
|
||||||
|
@ -57,4 +57,8 @@ export class ServerService {
|
|||||||
convertBookmarks() {
|
convertBookmarks() {
|
||||||
return this.httpClient.post(this.baseUrl + 'server/convert-bookmarks', {});
|
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">
|
<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>
|
<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">
|
<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>
|
<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>
|
<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(),
|
api: this.serverService.convertBookmarks(),
|
||||||
successMessage: 'Conversion of Bookmarks has been queued'
|
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',
|
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.',
|
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">
|
<app-side-nav-companion-bar [hasFilter]="true" [filterOpenByDefault]="filterSettings.openByDefault" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||||
<h2 title>
|
<h2 title>
|
||||||
All Series
|
{{title}}
|
||||||
</h2>
|
</h2>
|
||||||
<h6 subtitle *ngIf="pagination">{{pagination.totalItems}} Series</h6>
|
<h6 subtitle *ngIf="pagination">{{pagination.totalItems}} Series</h6>
|
||||||
</app-side-nav-companion-bar>
|
</app-side-nav-companion-bar>
|
||||||
|
@ -27,6 +27,7 @@ import { SeriesService } from 'src/app/_services/series.service';
|
|||||||
})
|
})
|
||||||
export class AllSeriesComponent implements OnInit, OnDestroy {
|
export class AllSeriesComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
title: string = 'All Series';
|
||||||
series: Series[] = [];
|
series: Series[] = [];
|
||||||
loadingSeries = false;
|
loadingSeries = false;
|
||||||
pagination!: Pagination;
|
pagination!: Pagination;
|
||||||
@ -93,7 +94,9 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
|
|||||||
private readonly cdRef: ChangeDetectorRef) {
|
private readonly cdRef: ChangeDetectorRef) {
|
||||||
|
|
||||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
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.pagination = this.filterUtilityService.pagination(this.route.snapshot);
|
||||||
[this.filterSettings.presets, this.filterSettings.openByDefault] = this.filterUtilityService.filterPresetsFromUrl(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>
|
tabindex="-1" [ngStyle]="{height: PageHeightForPagination}"></div>
|
||||||
</ng-container>
|
</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}"
|
<div #readingHtml class="book-content {{ColumnLayout}}" [ngStyle]="{'max-height': ColumnHeight, 'column-width': ColumnWidth}"
|
||||||
[ngClass]="{'immersive': immersiveMode && actionBarVisible}"
|
[ngClass]="{'immersive': immersiveMode && actionBarVisible}"
|
||||||
|
@ -252,7 +252,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
private readonly onDestroy = new Subject<void>();
|
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('readingSection', {static: false}) readingSectionElemRef!: ElementRef<HTMLDivElement>;
|
||||||
@ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef<HTMLDivElement>;
|
@ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef<HTMLDivElement>;
|
||||||
@ViewChild('reader', {static: true}) reader!: ElementRef;
|
@ViewChild('reader', {static: true}) reader!: ElementRef;
|
||||||
@ -326,7 +330,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [currentVirtualPage, totalVirtualPages, _] = this.getVirtualPage();
|
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);
|
return this.pageNum + 1 >= this.maxPages && (currentVirtualPage === totalVirtualPages);
|
||||||
}
|
}
|
||||||
@ -339,7 +343,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [currentVirtualPage,,] = this.getVirtualPage();
|
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);
|
return this.pageNum === 0 && (currentVirtualPage === 0);
|
||||||
}
|
}
|
||||||
@ -378,7 +382,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
get PageHeightForPagination() {
|
get PageHeightForPagination() {
|
||||||
if (this.layoutMode === BookPageLayoutMode.Default) {
|
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';
|
if (this.immersiveMode) return this.windowHeight + 'px';
|
||||||
@ -848,9 +858,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.reader.nativeElement.children
|
this.reader.nativeElement.children
|
||||||
// We need to check if we are paging back, because we need to adjust the scroll
|
// We need to check if we are paging back, because we need to adjust the scroll
|
||||||
if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) {
|
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 {
|
} 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) {
|
if (currentVirtualPage > 1) {
|
||||||
// -2 apparently goes back 1 virtual page...
|
// -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();
|
this.handleScrollEvent();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -957,7 +967,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
if (currentVirtualPage < totalVirtualPages) {
|
if (currentVirtualPage < totalVirtualPages) {
|
||||||
// +0 apparently goes forward 1 virtual page...
|
// +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();
|
this.handleScrollEvent();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -995,10 +1005,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
getVirtualPage() {
|
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 scrollOffset = this.bookContentElemRef.nativeElement.scrollLeft;
|
||||||
const totalScroll = this.readingHtml.nativeElement.scrollWidth;
|
const totalScroll = this.bookContentElemRef.nativeElement.scrollWidth;
|
||||||
const pageWidth = this.getPageWidth();
|
const pageWidth = this.getPageWidth();
|
||||||
const delta = totalScroll - scrollOffset;
|
const delta = totalScroll - scrollOffset;
|
||||||
|
|
||||||
@ -1022,9 +1032,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
getFirstVisibleElementXPath() {
|
getFirstVisibleElementXPath() {
|
||||||
let resumeElement: string | null = null;
|
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(element => !element.classList.contains('no-observe'))
|
||||||
.filter(entry => {
|
.filter(entry => {
|
||||||
return this.utilityService.isInViewport(entry, this.topOffset);
|
return this.utilityService.isInViewport(entry, this.topOffset);
|
||||||
@ -1048,7 +1058,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
updateReaderStyles(pageStyles: PageStyle) {
|
updateReaderStyles(pageStyles: PageStyle) {
|
||||||
this.pageStyles = pageStyles;
|
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
|
// 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();
|
const resumeElement: string | null | undefined = this.getFirstVisibleElementXPath();
|
||||||
@ -1060,17 +1070,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
Object.entries(this.pageStyles).forEach(item => {
|
Object.entries(this.pageStyles).forEach(item => {
|
||||||
if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') {
|
if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') {
|
||||||
// Remove the style or skip
|
// Remove the style or skip
|
||||||
this.renderer.removeStyle(this.readingHtml.nativeElement, item[0]);
|
this.renderer.removeStyle(this.bookContentElemRef.nativeElement, item[0]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pageLevelStyles.includes(item[0])) {
|
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]));
|
const individualElementStyles = Object.entries(this.pageStyles).filter(item => elementLevelStyles.includes(item[0]));
|
||||||
for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) {
|
for(let i = 0; i < this.bookContentElemRef.nativeElement.children.length; i++) {
|
||||||
const elem = this.readingHtml.nativeElement.children.item(i);
|
const elem = this.bookContentElemRef.nativeElement.children.item(i);
|
||||||
if (elem?.tagName === 'STYLE') continue;
|
if (elem?.tagName === 'STYLE') continue;
|
||||||
individualElementStyles.forEach(item => {
|
individualElementStyles.forEach(item => {
|
||||||
if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') {
|
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);
|
this.windowWidth = Math.max(this.readingSectionElemRef.nativeElement.clientWidth, window.innerWidth);
|
||||||
|
|
||||||
// Recalculate if bottom action bar is needed
|
// 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();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1221,12 +1231,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.updateImagesWithHeight();
|
this.updateImagesWithHeight();
|
||||||
|
|
||||||
// Calulate if bottom actionbar is needed. On a timeout to get accurate heights
|
// 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);
|
setTimeout(() => this.updateLayoutMode(this.layoutMode), 10);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.scrollbarNeeded = this.readingHtml.nativeElement.clientHeight > this.reader.nativeElement.clientHeight;
|
this.scrollbarNeeded = this.bookContentElemRef?.nativeElement?.clientHeight > this.reader?.nativeElement?.clientHeight;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1252,11 +1262,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateReadingSectionHeight() {
|
updateReadingSectionHeight() {
|
||||||
|
const renderer = this.renderer;
|
||||||
|
const elem = this.readingSectionElemRef;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (renderer === undefined || elem === undefined) return;
|
||||||
if (this.immersiveMode) {
|
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 {
|
} 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">
|
<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>
|
<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">
|
<span class="visually-hidden" id="layout-help">
|
||||||
<ng-container [ngTemplateOutlet]="layoutTooltip"></ng-container>
|
<ng-container [ngTemplateOutlet]="layoutTooltip"></ng-container>
|
||||||
</span>
|
</span>
|
||||||
<br>
|
<br>
|
||||||
<div class="btn-group d-flex justify-content-center" role="group" aria-label="Layout Mode">
|
<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">
|
<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">
|
<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>
|
<label class="btn btn-outline-primary" for="layout-mode-col1">1 Column</label>
|
||||||
|
@ -369,18 +369,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row g-0 mb-2">
|
<div class="row g-0 mb-2">
|
||||||
<div class="col-md-6">Created: {{series.created | date:'shortDate'}}</div>
|
<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 Read: {{series.latestReadDate | defaultDate | timeAgo}}</div>
|
||||||
<div class="col-md-6">Last Added To: {{series.lastChapterAdded | date:'short' | defaultDate}}</div>
|
<div class="col-md-6">Last Added To: {{series.lastChapterAdded | defaultDate | timeAgo}}</div>
|
||||||
<div class="col-md-6">Last Scanned: {{series.lastFolderScanned | date:'short' | defaultDate}}</div>
|
<div class="col-md-6">Last Scanned: {{series.lastFolderScanned | defaultDate | timeAgo}}</div>
|
||||||
<div class="col-md-6">Folder Path: {{series.folderPath | defaultValue}}</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-0 mb-2">
|
||||||
|
<div class="col-auto">Folder Path: {{series.folderPath | defaultValue}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-0 mb-2" *ngIf="metadata">
|
<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">
|
||||||
<div class="col-md-6">Max Items: {{metadata.maxCount}}</div>
|
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 class="col-md-6">Total Items: {{metadata.totalCount}}</div>
|
</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">Publication Status: {{metadata.publicationStatus | publicationStatus}}</div>
|
||||||
<div class="col-md-6">Total Pages: {{series.pages}}</div>
|
<div class="col-md-6">Total Pages: {{series.pages}}</div>
|
||||||
|
<div class="col-md-6">Size: {{size | bytes}}</div>
|
||||||
</div>
|
</div>
|
||||||
<h4>Volumes</h4>
|
<h4>Volumes</h4>
|
||||||
<div class="spinner-border text-secondary" role="status" *ngIf="isLoadingVolumes">
|
<div class="spinner-border text-secondary" role="status" *ngIf="isLoadingVolumes">
|
||||||
|
@ -54,9 +54,11 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
activeTabId = TabID.General;
|
activeTabId = TabID.General;
|
||||||
editSeriesForm!: FormGroup;
|
editSeriesForm!: FormGroup;
|
||||||
libraryName: string | undefined = undefined;
|
libraryName: string | undefined = undefined;
|
||||||
|
size: number = 0;
|
||||||
private readonly onDestroy = new Subject<void>();
|
private readonly onDestroy = new Subject<void>();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Typeaheads
|
// Typeaheads
|
||||||
ageRatingSettings: TypeaheadSettings<AgeRatingDto> = new TypeaheadSettings();
|
ageRatingSettings: TypeaheadSettings<AgeRatingDto> = new TypeaheadSettings();
|
||||||
publicationStatusSettings: TypeaheadSettings<PublicationStatusDto> = new TypeaheadSettings();
|
publicationStatusSettings: TypeaheadSettings<PublicationStatusDto> = new TypeaheadSettings();
|
||||||
@ -122,7 +124,6 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.initSeries = Object.assign({}, this.series);
|
this.initSeries = Object.assign({}, this.series);
|
||||||
|
|
||||||
|
|
||||||
this.editSeriesForm = this.fb.group({
|
this.editSeriesForm = this.fb.group({
|
||||||
id: new FormControl(this.series.id, []),
|
id: new FormControl(this.series.id, []),
|
||||||
summary: new FormControl('', []),
|
summary: new FormControl('', []),
|
||||||
@ -232,6 +233,16 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
return f;
|
return f;
|
||||||
})).flat();
|
})).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();
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -146,12 +146,14 @@
|
|||||||
Added:
|
Added:
|
||||||
<!-- TODO: This data.created can be removed after v0.5.5 release -->
|
<!-- TODO: This data.created can be removed after v0.5.5 release -->
|
||||||
<ng-container *ngIf="file.created == '0001-01-01T00:00:00'; else fileDate">
|
<ng-container *ngIf="file.created == '0001-01-01T00:00:00'; else fileDate">
|
||||||
{{(data.created | date: 'short') || '-'}}
|
{{data.created | date: 'short' | defaultDate}}
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-template #fileDate>
|
<ng-template #fileDate>
|
||||||
{{(file.created | date: 'short') || '-'}}
|
{{file.created | date: 'short' | defaultDate}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
Size: {{file.bytes | bytes}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<ng-container *ngIf="chapter !== undefined && chapter.releaseDate && (chapter.releaseDate | date: 'shortDate') !== '1/1/01'">
|
<ng-container *ngIf="chapter !== undefined && chapter.releaseDate && (chapter.releaseDate | date: 'shortDate') !== '1/1/01'">
|
||||||
<div class="col-auto mb-2">
|
<div class="col-auto mb-2">
|
||||||
<app-icon-and-title label="Release Date" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release">
|
<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>
|
</app-icon-and-title>
|
||||||
</div>
|
</div>
|
||||||
<div class="vr d-none d-lg-block m-2"></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">
|
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && totalWordCount > 0">
|
||||||
<div class="col-auto mb-2">
|
<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
|
{{totalWordCount | compactNumber}} Words
|
||||||
</app-icon-and-title>
|
</app-icon-and-title>
|
||||||
</div>
|
</div>
|
||||||
@ -38,7 +38,7 @@
|
|||||||
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && totalWordCount > 0 || chapter.files[0].format !== MangaFormat.EPUB">
|
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && totalWordCount > 0 || chapter.files[0].format !== MangaFormat.EPUB">
|
||||||
<div class="col-auto mb-2">
|
<div class="col-auto mb-2">
|
||||||
<app-icon-and-title label="Read Time" [clickable]="false" fontClasses="fa-regular fa-clock">
|
<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>
|
<ng-template #normalReadTime>
|
||||||
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
|
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@ -50,7 +50,16 @@
|
|||||||
<div class="vr d-none d-lg-block m-2"></div>
|
<div class="vr d-none d-lg-block m-2"></div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<app-icon-and-title label="Date Added" [clickable]="false" fontClasses="fa-solid fa-file-import" title="Date Added">
|
<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>
|
</app-icon-and-title>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -37,6 +37,7 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy {
|
|||||||
totalPages: number = 0;
|
totalPages: number = 0;
|
||||||
totalWordCount: number = 0;
|
totalWordCount: number = 0;
|
||||||
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
|
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
|
||||||
|
size: number = 0;
|
||||||
|
|
||||||
private readonly onDestroy: Subject<void> = new Subject();
|
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];
|
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) {
|
if (this.includeMetadata) {
|
||||||
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
|
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
|
||||||
this.chapterMetadata = metadata;
|
this.chapterMetadata = metadata;
|
||||||
|
@ -61,7 +61,7 @@
|
|||||||
<ng-container *ngIf="series.format === MangaFormat.EPUB; else showPages">
|
<ng-container *ngIf="series.format === MangaFormat.EPUB; else showPages">
|
||||||
<ng-container *ngIf="series.wordCount > 0">
|
<ng-container *ngIf="series.wordCount > 0">
|
||||||
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
|
<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
|
{{series.wordCount | compactNumber}} Words
|
||||||
</app-icon-and-title>
|
</app-icon-and-title>
|
||||||
</div>
|
</div>
|
||||||
@ -81,7 +81,7 @@
|
|||||||
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0 || series.format !== MangaFormat.EPUB">
|
<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">
|
<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">
|
<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>
|
<ng-template #normalReadTime>
|
||||||
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
|
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -168,17 +168,20 @@ export class DashboardComponent implements OnInit, OnDestroy {
|
|||||||
const params: any = {};
|
const params: any = {};
|
||||||
params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc
|
params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc
|
||||||
params[FilterQueryParam.Page] = 1;
|
params[FilterQueryParam.Page] = 1;
|
||||||
|
params['title'] = 'Recently Updated';
|
||||||
this.router.navigate(['all-series'], {queryParams: params});
|
this.router.navigate(['all-series'], {queryParams: params});
|
||||||
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
params[FilterQueryParam.ReadStatus] = 'true,false,false';
|
params[FilterQueryParam.ReadStatus] = 'true,false,false';
|
||||||
params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc
|
params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc
|
||||||
params[FilterQueryParam.Page] = 1;
|
params[FilterQueryParam.Page] = 1;
|
||||||
|
params['title'] = 'On Deck';
|
||||||
this.router.navigate(['all-series'], {queryParams: params});
|
this.router.navigate(['all-series'], {queryParams: params});
|
||||||
}else if (sectionTitle.toLowerCase() === 'newly added series') {
|
}else if (sectionTitle.toLowerCase() === 'newly added series') {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
params[FilterQueryParam.SortBy] = SortField.Created + ',false'; // sort by created, desc
|
params[FilterQueryParam.SortBy] = SortField.Created + ',false'; // sort by created, desc
|
||||||
params[FilterQueryParam.Page] = 1;
|
params[FilterQueryParam.Page] = 1;
|
||||||
|
params['title'] = 'Newly Added';
|
||||||
this.router.navigate(['all-series'], {queryParams: params});
|
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
|
// NOTE: I may want to provide a different prefetcher for double renderer
|
||||||
for(let i = 0; i <= PREFETCH_PAGES - 3; i++) {
|
for(let i = 0; i <= PREFETCH_PAGES - 3; i++) {
|
||||||
const numOffset = this.pageNum + i;
|
const numOffset = this.pageNum + i;
|
||||||
|
//console.log('numOffset: ', numOffset);
|
||||||
if (numOffset > this.maxPages - 1) continue;
|
if (numOffset > this.maxPages - 1) continue;
|
||||||
|
|
||||||
const index = (numOffset % this.cachedImages.length + this.cachedImages.length) % this.cachedImages.length;
|
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] = new Image();
|
||||||
this.cachedImages[index].src = this.getPageUrl(numOffset);
|
this.cachedImages[index].src = this.getPageUrl(numOffset);
|
||||||
}
|
}
|
||||||
|
@ -629,12 +629,12 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
apply() {
|
apply() {
|
||||||
this.applyFilter.emit({filter: this.filter, isFirst: this.updateApplied === 0});
|
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.toggleSelected();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateApplied++;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import { Pipe, PipeTransform } from '@angular/core';
|
|||||||
export class DefaultDatePipe implements PipeTransform {
|
export class DefaultDatePipe implements PipeTransform {
|
||||||
|
|
||||||
transform(value: any, replacementString = 'Never'): string {
|
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;
|
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)"
|
<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)"
|
[imageUrl]="imageService.getChapterCoverImage(item.chapter.id)"
|
||||||
[read]="item.chapter.pagesRead" [total]="item.chapter.pages" [actions]="chapterActions"
|
[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)"
|
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, storyChapters.length, $event)"
|
||||||
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true"></app-card-item>
|
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true"></app-card-item>
|
||||||
</ng-template>
|
</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)"
|
<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)"
|
[imageUrl]="imageService.getChapterCoverImage(item.id)"
|
||||||
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
|
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
|
||||||
|
[count]="item.files.length"
|
||||||
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, chapters.length, $event)"
|
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, chapters.length, $event)"
|
||||||
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
|
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
|
||||||
<ng-container title>
|
<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)"
|
<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)"
|
[imageUrl]="imageService.getChapterCoverImage(item.id)"
|
||||||
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
|
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
|
||||||
|
[count]="item.files.length"
|
||||||
(selection)="bulkSelectionService.handleCardSelection('special', scroll.viewPortInfo.startIndexWithBuffer + idx, chapters.length, $event)"
|
(selection)="bulkSelectionService.handleCardSelection('special', scroll.viewPortInfo.startIndexWithBuffer + idx, chapters.length, $event)"
|
||||||
[selected]="bulkSelectionService.isCardSelected('special', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
|
[selected]="bulkSelectionService.isCardSelected('special', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
|
||||||
</app-card-item>
|
</app-card-item>
|
||||||
|
@ -11,7 +11,6 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
.side-nav-text {
|
.side-nav-text {
|
||||||
padding-left: 10px;
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
|
|
||||||
|
@ -8,6 +8,15 @@
|
|||||||
<div class="vr d-none d-lg-block m-2"></div>
|
<div class="vr d-none d-lg-block m-2"></div>
|
||||||
</ng-container>
|
</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 >
|
<ng-container >
|
||||||
<div class="col-auto mb-2">
|
<div class="col-auto mb-2">
|
||||||
<app-icon-and-title label="Time Spent Reading" [clickable]="false" fontClasses="fas fa-eye" title="Time Spent Reading">
|
<app-icon-and-title label="Time Spent Reading" [clickable]="false" fontClasses="fas fa-eye" title="Time Spent Reading">
|
||||||
@ -20,7 +29,7 @@
|
|||||||
<ng-container>
|
<ng-container>
|
||||||
<div class="col-auto mb-2">
|
<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">
|
<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>
|
</app-icon-and-title>
|
||||||
</div>
|
</div>
|
||||||
<div class="vr d-none d-lg-block m-2"></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 {
|
export class UserStatsInfoCardsComponent implements OnInit {
|
||||||
|
|
||||||
@Input() totalPagesRead: number = 0;
|
@Input() totalPagesRead: number = 0;
|
||||||
|
@Input() totalWordsRead: number = 0;
|
||||||
@Input() timeSpentReading: number = 0;
|
@Input() timeSpentReading: number = 0;
|
||||||
@Input() chaptersRead: number = 0;
|
@Input() chaptersRead: number = 0;
|
||||||
@Input() lastActive: string = '';
|
@Input() lastActive: string = '';
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
<div class="container-fluid" *ngIf="userId">
|
<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">
|
<div class="row g-0 d-flex justify-content-around">
|
||||||
<ng-container *ngIf="userStats$ | async as userStats">
|
<ng-container *ngIf="userStats$ | async as userStats">
|
||||||
<app-user-stats-info-cards [totalPagesRead]="userStats.totalPagesRead" [timeSpentReading]="userStats.timeSpentReading"
|
<app-user-stats-info-cards [totalPagesRead]="userStats.totalPagesRead" [totalWordsRead]="userStats.totalWordsRead" [timeSpentReading]="userStats.timeSpentReading"
|
||||||
[chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActive"></app-user-stats-info-cards>
|
[chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActive" [avgHoursPerWeekSpentReading]="userStats.avgHoursPerWeekSpentReading"></app-user-stats-info-cards>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -17,14 +16,4 @@
|
|||||||
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
|
<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>
|
<app-stat-list [data$]="precentageRead$" label="% Read" title="Library Read Progress"></app-stat-list>
|
||||||
</div>
|
</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>
|
</div>
|
@ -1,21 +1,15 @@
|
|||||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core';
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { map, Observable, of, shareReplay, Subject, takeUntil } from 'rxjs';
|
import { map, Observable, shareReplay, Subject, takeUntil } from 'rxjs';
|
||||||
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
|
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 { 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 { 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 { ReadHistoryEvent } from '../../_models/read-history-event';
|
||||||
import { MemberService } from 'src/app/_services/member.service';
|
import { MemberService } from 'src/app/_services/member.service';
|
||||||
import { AccountService } from 'src/app/_services/account.service';
|
import { AccountService } from 'src/app/_services/account.service';
|
||||||
import { PieDataItem } from '../../_models/pie-data-item';
|
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 { LibraryService } from 'src/app/_services/library.service';
|
||||||
import { PercentPipe } from '@angular/common';
|
import { PercentPipe } from '@angular/common';
|
||||||
|
|
||||||
type SeriesWithProgress = Series & {progress: number};
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-stats',
|
selector: 'app-user-stats',
|
||||||
templateUrl: './user-stats.component.html',
|
templateUrl: './user-stats.component.html',
|
||||||
|
@ -2,6 +2,7 @@ import { StatCount } from "./stat-count";
|
|||||||
|
|
||||||
export interface UserReadStatistics {
|
export interface UserReadStatistics {
|
||||||
totalPagesRead: number;
|
totalPagesRead: number;
|
||||||
|
totalWordsRead: number;
|
||||||
timeSpentReading: number;
|
timeSpentReading: number;
|
||||||
chaptersRead: number;
|
chaptersRead: number;
|
||||||
lastActive: string;
|
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 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.
|
* @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}>;
|
_data!: Array<{value: T, selected: boolean}>;
|
||||||
_propAccessor: string = '';
|
_propAccessor: string = '';
|
||||||
|
|
||||||
@ -137,13 +137,13 @@ const ANIMATION_SPEED = 200;
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
animations: [
|
animations: [
|
||||||
trigger('slideFromTop', [
|
trigger('slideFromTop', [
|
||||||
state('in', style({ height: '0px', overflow: 'hidden'})),
|
state('in', style({ height: '0px'})),
|
||||||
transition('void => *', [
|
transition('void => *', [
|
||||||
style({ height: '100%', overflow: 'auto' }),
|
style({ height: '100%', overflow: 'auto' }),
|
||||||
animate(ANIMATION_SPEED)
|
animate(ANIMATION_SPEED)
|
||||||
]),
|
]),
|
||||||
transition('* => void', [
|
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="row g-0">
|
||||||
<div class="col-md-6 col-sm-12 pe-2 mb-3">
|
<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>
|
<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>
|
<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">
|
<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>
|
<option *ngFor="let opt of bookLayoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>
|
||||||
|
@ -2,51 +2,3 @@
|
|||||||
background-color: var(--side-nav-bg-color);
|
background-color: var(--side-nav-bg-color);
|
||||||
box-shadow: var(--side-nav-box-shadow);
|
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-progress-bar-color: var(--primary-color);
|
||||||
--card-overlay-bg-color: rgba(0, 0, 0, 0);
|
--card-overlay-bg-color: rgba(0, 0, 0, 0);
|
||||||
--card-overlay-hover-bg-color: rgba(0, 0, 0, 0.2);
|
--card-overlay-hover-bg-color: rgba(0, 0, 0, 0.2);
|
||||||
--card-progress-triangle-size: 30px;
|
--card-progress-triangle-size: 20px;
|
||||||
|
|
||||||
/* Slider */
|
/* Slider */
|
||||||
--slider-text-color: white;
|
--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": {
|
"/api/Server/logs": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -11153,6 +11166,10 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
},
|
},
|
||||||
|
"bytes": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
"format": {
|
"format": {
|
||||||
"$ref": "#/components/schemas/MangaFormat"
|
"$ref": "#/components/schemas/MangaFormat"
|
||||||
},
|
},
|
||||||
@ -14150,6 +14167,11 @@
|
|||||||
"description": "Total number of pages read",
|
"description": "Total number of pages read",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
},
|
},
|
||||||
|
"totalWordsRead": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Total number of words read",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
"timeSpentReading": {
|
"timeSpentReading": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Total time spent reading based on estimates",
|
"description": "Total time spent reading based on estimates",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user