Word Count (#1286)

* Adding some code for Robbie

* See more on series detail metadata area is now at the bottom on the section

* Cleaned up subtitle headings to use a single class for offset with actionables

* Added some markup for the new design, waiting for Robbie to finish it off

* styling age-rating badge

* Started hooking up basic analyze file service and hooks in the UI. Basic code to implement the count is implemented and in benchmarks.

* Hooked up analyze ui to backend

* Refactored Series Detail metadata area to use a new icon/title design

* Cleaned up the new design

* Pushing for robbie to do css

* Massive performance improvement to scan series where we only need to scan folders reported that have series in them, rather than the whole library.

* Removed theme page as we no longer need it. Added WordCount to DTOs so the UI can show them. Added new pipe to format numbers in compact mode.

* Hooked up actual reading time based on user's words per hour

* Refactor some magic numbers to consts

* Hooked in progress reporting for series word count

* Hooked up analyze files

* Re-implemented time to read on comics

* Removed the word Last Read

* Show proper language name instead of iso tag on series detail page. Added some error handling on word count code.

* Reworked error handling

* Fixed some security vulnerabilities in npm.

* Handle a case where there are no text nodes and instead of returning an empty list, htmlagilitypack returns null.

* Tweaked the styles a bit on the icon-and-title

* Code cleanup

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joseph Milazzo 2022-05-25 16:53:39 -05:00 committed by GitHub
parent 0a70ac35dc
commit c1490d6e86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 2354 additions and 408 deletions

View File

@ -0,0 +1,68 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using API.Services;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using HtmlAgilityPack;
using VersOne.Epub;
namespace API.Benchmark;
[MemoryDiagnoser]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[RankColumn]
[SimpleJob(launchCount: 1, warmupCount: 3, targetCount: 5, invocationCount: 100, id: "Epub"), ShortRunJob]
public class EpubBenchmark
{
[Benchmark]
public async Task GetWordCount_PassByString()
{
using var book = await EpubReader.OpenBookAsync("Data/book-test.epub", BookService.BookReaderOptions);
foreach (var bookFile in book.Content.Html.Values)
{
Console.WriteLine(GetBookWordCount_PassByString(await bookFile.ReadContentAsTextAsync()));
;
}
}
[Benchmark]
public async Task GetWordCount_PassByRef()
{
using var book = await EpubReader.OpenBookAsync("Data/book-test.epub", BookService.BookReaderOptions);
foreach (var bookFile in book.Content.Html.Values)
{
Console.WriteLine(await GetBookWordCount_PassByRef(bookFile));
}
}
private static int GetBookWordCount_PassByString(string fileContents)
{
var doc = new HtmlDocument();
doc.LoadHtml(fileContents);
var delimiter = new char[] {' '};
return doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]")
.Select(node => node.InnerText)
.Select(text => text.Split(delimiter, StringSplitOptions.RemoveEmptyEntries)
.Where(s => char.IsLetter(s[0])))
.Select(words => words.Count())
.Where(wordCount => wordCount > 0)
.Sum();
}
private static async Task<int> GetBookWordCount_PassByRef(EpubContentFileRef bookFile)
{
var doc = new HtmlDocument();
doc.LoadHtml(await bookFile.ReadContentAsTextAsync());
var delimiter = new char[] {' '};
return doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]")
.Select(node => node.InnerText)
.Select(text => text.Split(delimiter, StringSplitOptions.RemoveEmptyEntries)
.Where(s => char.IsLetter(s[0])))
.Select(words => words.Count())
.Where(wordCount => wordCount > 0)
.Sum();
}
}

View File

@ -14,7 +14,8 @@ namespace API.Benchmark
{
//BenchmarkRunner.Run<ParseScannedFilesBenchmarks>();
//BenchmarkRunner.Run<TestBenchmark>();
BenchmarkRunner.Run<ParserBenchmarks>();
//BenchmarkRunner.Run<ParserBenchmarks>();
BenchmarkRunner.Run<EpubBenchmark>();
}
}

View File

@ -166,6 +166,14 @@ namespace API.Controllers
return Ok();
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("analyze")]
public ActionResult Analyze(int libraryId)
{
_taskScheduler.AnalyzeFilesForLibrary(libraryId);
return Ok();
}
[HttpGet("libraries")]
public async Task<ActionResult<IEnumerable<LibraryDto>>> GetLibrariesForUser()
{

View File

@ -269,6 +269,14 @@ namespace API.Controllers
return Ok();
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("analyze")]
public ActionResult AnalyzeSeries(RefreshSeriesDto refreshSeriesDto)
{
_taskScheduler.AnalyzeFilesForSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId);
return Ok();
}
[HttpGet("metadata")]
public async Task<ActionResult<SeriesMetadataDto>> GetSeriesMetadata(int seriesId)
{

View File

@ -61,5 +61,9 @@ namespace API.DTOs
/// </summary>
/// <remarks>Metadata field</remarks>
public string TitleName { get; set; }
/// <summary>
/// Number of Words for this chapter. Only applies to Epub
/// </summary>
public long WordCount { get; set; }
}
}

View File

@ -40,6 +40,10 @@ namespace API.DTOs
public bool NameLocked { get; set; }
public bool SortNameLocked { get; set; }
public bool LocalizedNameLocked { get; set; }
/// <summary>
/// Total number of words for the series. Only applies to epubs.
/// </summary>
public long WordCount { get; set; }
public int LibraryId { get; set; }
public string LibraryName { get; set; }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class WordCount : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "WordCount",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<long>(
name: "WordCount",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: 0L);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "WordCount",
table: "Series");
migrationBuilder.DropColumn(
name: "WordCount",
table: "Chapter");
}
}
}

View File

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.4");
modelBuilder.HasAnnotation("ProductVersion", "6.0.5");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -368,6 +368,9 @@ namespace API.Data.Migrations
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.Property<long>("WordCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
@ -777,6 +780,9 @@ namespace API.Data.Migrations
b.Property<bool>("SortNameLocked")
.HasColumnType("INTEGER");
b.Property<long>("WordCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId");

View File

@ -72,6 +72,11 @@ namespace API.Entities
/// </summary>
public int Count { get; set; } = 0;
/// <summary>
/// Total words in a Chapter (books only)
/// </summary>
public long WordCount { get; set; }
/// <summary>
/// All people attached at a Chapter level. Usually Comics will have different people per issue.

View File

@ -65,6 +65,11 @@ public class Series : IEntityDate
/// </summary>
public DateTime LastChapterAdded { get; set; }
/// <summary>
/// Total words in a Series (books only)
/// </summary>
public long WordCount { get; set; }
public SeriesMetadata Metadata { get; set; }
public ICollection<AppUserRating> Ratings { get; set; } = new List<AppUserRating>();

View File

@ -3,6 +3,7 @@ using API.Data;
using API.Helpers;
using API.Services;
using API.Services.Tasks;
using API.Services.Tasks.Metadata;
using API.SignalR;
using API.SignalR.Presence;
using Kavita.Common;
@ -20,15 +21,18 @@ namespace API.Extensions
public static void AddApplicationServices(this IServiceCollection services, IConfiguration config, IWebHostEnvironment env)
{
services.AddAutoMapper(typeof(AutoMapperProfiles).Assembly);
services.AddScoped<IStatsService, StatsService>();
services.AddScoped<ITaskScheduler, TaskScheduler>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<IDirectoryService, DirectoryService>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<IFileSystem, FileSystem>();
services.AddScoped<IFileService, FileService>();
services.AddScoped<ICacheHelper, CacheHelper>();
services.AddScoped<IStatsService, StatsService>();
services.AddScoped<ITaskScheduler, TaskScheduler>();
services.AddScoped<ICacheService, CacheService>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IArchiveService, ArchiveService>();
services.AddScoped<IMetadataService, MetadataService>();
services.AddScoped<IBackupService, BackupService>();
services.AddScoped<ICleanupService, CleanupService>();
services.AddScoped<IBookService, BookService>();
@ -43,10 +47,11 @@ namespace API.Extensions
services.AddScoped<IThemeService, ThemeService>();
services.AddScoped<ISeriesService, SeriesService>();
services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IMetadataService, MetadataService>();
services.AddScoped<IWordCountAnalyzerService, WordCountAnalyzerService>();
services.AddScoped<IFileSystem, FileSystem>();
services.AddScoped<IFileService, FileService>();
services.AddScoped<ICacheHelper, CacheHelper>();
services.AddScoped<IPresenceTracker, PresenceTracker>();
services.AddScoped<IEventHub, EventHub>();

View File

@ -12,7 +12,9 @@ using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Services.Tasks.Metadata;
using API.SignalR;
using Hangfire;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
@ -194,6 +196,8 @@ public class MetadataService : IMetadataService
/// <remarks>This can be heavy on memory first run</remarks>
/// <param name="libraryId"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task RefreshMetadata(int libraryId, bool forceUpdate = false)
{
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None);
@ -256,10 +260,10 @@ public class MetadataService : IMetadataService
await RemoveAbandonedMetadataKeys();
_logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime);
}
private async Task RemoveAbandonedMetadataKeys()
{
await _unitOfWork.TagRepository.RemoveAllTagNoLongerAssociated();

View File

@ -6,6 +6,7 @@ using API.Data;
using API.Entities.Enums;
using API.Helpers.Converters;
using API.Services.Tasks;
using API.Services.Tasks.Metadata;
using Hangfire;
using Hangfire.Storage;
using Microsoft.Extensions.Logging;
@ -22,6 +23,8 @@ public interface ITaskScheduler
void RefreshMetadata(int libraryId, bool forceUpdate = true);
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false);
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false);
void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false);
void CancelStatsTasks();
Task RunStatCollection();
void ScanSiteThemes();
@ -41,6 +44,7 @@ public class TaskScheduler : ITaskScheduler
private readonly IStatsService _statsService;
private readonly IVersionUpdaterService _versionUpdaterService;
private readonly IThemeService _themeService;
private readonly IWordCountAnalyzerService _wordCountAnalyzerService;
public static BackgroundJobServer Client => new BackgroundJobServer();
private static readonly Random Rnd = new Random();
@ -49,7 +53,7 @@ public class TaskScheduler : ITaskScheduler
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService,
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
IThemeService themeService)
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService)
{
_cacheService = cacheService;
_logger = logger;
@ -61,6 +65,7 @@ public class TaskScheduler : ITaskScheduler
_statsService = statsService;
_versionUpdaterService = versionUpdaterService;
_themeService = themeService;
_wordCountAnalyzerService = wordCountAnalyzerService;
}
public async Task ScheduleTasks()
@ -111,6 +116,11 @@ public class TaskScheduler : ITaskScheduler
RecurringJob.AddOrUpdate("report-stats", () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), TimeZoneInfo.Local);
}
public void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false)
{
BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanLibrary(libraryId, forceUpdate));
}
public void CancelStatsTasks()
{
_logger.LogDebug("Cancelling/Removing StatsTasks");
@ -182,6 +192,12 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _scannerService.ScanSeries(libraryId, seriesId, CancellationToken.None));
}
public void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false)
{
_logger.LogInformation("Enqueuing analyze files scan for: {SeriesId}", seriesId);
BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(libraryId, seriesId, forceUpdate));
}
public void BackupDatabase()
{
BackgroundJob.Enqueue(() => _backupService.BackupDatabase());

View File

@ -0,0 +1,218 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.SignalR;
using Hangfire;
using HtmlAgilityPack;
using Microsoft.Extensions.Logging;
using VersOne.Epub;
namespace API.Services.Tasks.Metadata;
public interface IWordCountAnalyzerService
{
Task ScanLibrary(int libraryId, bool forceUpdate = false);
Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
}
/// <summary>
/// This service is a metadata task that generates information around time to read
/// </summary>
public class WordCountAnalyzerService : IWordCountAnalyzerService
{
private readonly ILogger<WordCountAnalyzerService> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _eventHub;
private readonly ICacheHelper _cacheHelper;
public WordCountAnalyzerService(ILogger<WordCountAnalyzerService> logger, IUnitOfWork unitOfWork, IEventHub eventHub,
ICacheHelper cacheHelper)
{
_logger = logger;
_unitOfWork = unitOfWork;
_eventHub = eventHub;
_cacheHelper = cacheHelper;
}
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibrary(int libraryId, bool forceUpdate = false)
{
var sw = Stopwatch.StartNew();
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(libraryId, 0F, ProgressEventType.Started, string.Empty));
var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id);
var stopwatch = Stopwatch.StartNew();
var totalTime = 0L;
_logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(library.Id, 0F, ProgressEventType.Started, $"Starting {library.Name}"));
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++)
{
if (chunkInfo.TotalChunks == 0) continue;
totalTime += stopwatch.ElapsedMilliseconds;
stopwatch.Restart();
_logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}",
chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize);
var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id,
new UserParams()
{
PageNumber = chunk,
PageSize = chunkInfo.ChunkSize
});
_logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count);
var seriesIndex = 0;
foreach (var series in nonLibrarySeries)
{
var index = chunk * seriesIndex;
var progress = Math.Max(0F, Math.Min(1F, index * 1F / chunkInfo.TotalSize));
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(library.Id, progress, ProgressEventType.Updated, series.Name));
try
{
await ProcessSeries(series, forceUpdate, false);
}
catch (Exception ex)
{
_logger.LogError(ex, "[MetadataService] There was an exception during metadata refresh for {SeriesName}", series.Name);
}
seriesIndex++;
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
}
_logger.LogInformation(
"[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}",
chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name);
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(library.Id, 1F, ProgressEventType.Ended, $"Complete"));
_logger.LogInformation("[WordCountAnalyzerService] Updated metadata for {LibraryName} in {ElapsedMilliseconds} milliseconds", library.Name, sw.ElapsedMilliseconds);
}
public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)
{
var sw = Stopwatch.StartNew();
var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId);
if (series == null)
{
_logger.LogError("[WordCountAnalyzerService] Series {SeriesId} was not found on Library {LibraryId}", seriesId, libraryId);
return;
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(libraryId, 0F, ProgressEventType.Started, series.Name));
await ProcessSeries(series);
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(libraryId, 1F, ProgressEventType.Ended, series.Name));
_logger.LogInformation("[WordCountAnalyzerService] Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds);
}
private async Task ProcessSeries(Series series, bool forceUpdate = false, bool useFileName = true)
{
if (series.Format != MangaFormat.Epub) return;
long totalSum = 0;
foreach (var chapter in series.Volumes.SelectMany(v => v.Chapters))
{
// This compares if it's changed since a file scan only
if (!_cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false,
chapter.Files.FirstOrDefault()) && chapter.WordCount != 0)
continue;
long sum = 0;
var fileCounter = 1;
foreach (var file in chapter.Files.Select(file => file.FilePath))
{
var pageCounter = 1;
try
{
using var book = await EpubReader.OpenBookAsync(file, BookService.BookReaderOptions);
var totalPages = book.Content.Html.Values;
foreach (var bookPage in totalPages)
{
var progress = Math.Max(0F,
Math.Min(1F, (fileCounter * pageCounter) * 1F / (chapter.Files.Count * totalPages.Count)));
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress,
ProgressEventType.Updated, useFileName ? file : series.Name));
sum += await GetWordCountFromHtml(bookPage);
pageCounter++;
}
fileCounter++;
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an error reading an epub file for word count, series skipped");
await _eventHub.SendMessageAsync(MessageFactory.Error,
MessageFactory.ErrorEvent("There was an issue counting words on an epub",
$"{series.Name} - {file}"));
return;
}
}
chapter.WordCount = sum;
_unitOfWork.ChapterRepository.Update(chapter);
totalSum += sum;
}
series.WordCount = totalSum;
_unitOfWork.SeriesRepository.Update(series);
}
private static async Task<int> GetWordCountFromHtml(EpubContentFileRef bookFile)
{
var doc = new HtmlDocument();
doc.LoadHtml(await bookFile.ReadContentAsTextAsync());
var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]");
if (textNodes == null) return 0;
return textNodes
.Select(node => node.InnerText)
.Select(text => text.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(s => char.IsLetter(s[0])))
.Select(words => words.Count())
.Where(wordCount => wordCount > 0)
.Sum();
}
}

View File

@ -14,6 +14,7 @@ using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Parser;
using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner;
using API.SignalR;
using Hangfire;
@ -43,11 +44,12 @@ public class ScannerService : IScannerService
private readonly IDirectoryService _directoryService;
private readonly IReadingItemService _readingItemService;
private readonly ICacheHelper _cacheHelper;
private readonly IWordCountAnalyzerService _wordCountAnalyzerService;
public ScannerService(IUnitOfWork unitOfWork, ILogger<ScannerService> logger,
IMetadataService metadataService, ICacheService cacheService, IEventHub eventHub,
IFileService fileService, IDirectoryService directoryService, IReadingItemService readingItemService,
ICacheHelper cacheHelper)
ICacheHelper cacheHelper, IWordCountAnalyzerService wordCountAnalyzerService)
{
_unitOfWork = unitOfWork;
_logger = logger;
@ -58,6 +60,7 @@ public class ScannerService : IScannerService
_directoryService = directoryService;
_readingItemService = readingItemService;
_cacheHelper = cacheHelper;
_wordCountAnalyzerService = wordCountAnalyzerService;
}
[DisableConcurrentExecution(timeoutInSeconds: 360)]
@ -71,6 +74,15 @@ public class ScannerService : IScannerService
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders);
var folderPaths = library.Folders.Select(f => f.Path).ToList();
var seriesFolderPaths = (await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId))
.Select(f => _directoryService.FileSystem.FileInfo.FromFileName(f.FilePath).Directory.FullName)
.ToList();
if (!await CheckMounts(library.Name, seriesFolderPaths))
{
_logger.LogCritical("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted");
return;
}
if (!await CheckMounts(library.Name, library.Folders.Select(f => f.Path).ToList()))
{
@ -82,10 +94,15 @@ public class ScannerService : IScannerService
var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync();
var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync();
var dirs = _directoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList());
var seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(seriesFolderPaths, files.Select(f => f.FilePath).ToList());
if (seriesDirs.Keys.Count == 0)
{
_logger.LogDebug("Scan Series has files spread outside a main series folder. Defaulting to library folder");
seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList());
}
_logger.LogInformation("Beginning file scan on {SeriesName}", series.Name);
var (totalFiles, scanElapsedTime, parsedSeries) = await ScanFiles(library, dirs.Keys);
var (totalFiles, scanElapsedTime, parsedSeries) = await ScanFiles(library, seriesDirs.Keys);
@ -117,10 +134,10 @@ public class ScannerService : IScannerService
// We need to do an additional check for an edge case: If the scan ran and the files do not match the existing Series name, then it is very likely,
// the files have crap naming and if we don't correct, the series will get deleted due to the parser not being able to fallback onto folder parsing as the root
// is the series folder.
var existingFolder = dirs.Keys.FirstOrDefault(key => key.Contains(series.OriginalName));
if (dirs.Keys.Count == 1 && !string.IsNullOrEmpty(existingFolder))
var existingFolder = seriesDirs.Keys.FirstOrDefault(key => key.Contains(series.OriginalName));
if (seriesDirs.Keys.Count == 1 && !string.IsNullOrEmpty(existingFolder))
{
dirs = new Dictionary<string, string>();
seriesDirs = new Dictionary<string, string>();
var path = Directory.GetParent(existingFolder)?.FullName;
if (!folderPaths.Contains(path) || !folderPaths.Any(p => p.Contains(path ?? string.Empty)))
{
@ -131,11 +148,11 @@ public class ScannerService : IScannerService
}
if (!string.IsNullOrEmpty(path))
{
dirs[path] = string.Empty;
seriesDirs[path] = string.Empty;
}
}
var (totalFiles2, scanElapsedTime2, parsedSeries2) = await ScanFiles(library, dirs.Keys);
var (totalFiles2, scanElapsedTime2, parsedSeries2) = await ScanFiles(library, seriesDirs.Keys);
_logger.LogInformation("{SeriesName} has bad naming convention, forcing rescan at a higher directory", series.OriginalName);
totalFiles += totalFiles2;
scanElapsedTime += scanElapsedTime2;
@ -303,10 +320,8 @@ public class ScannerService : IScannerService
await CleanupDbEntities();
// await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress,
// MessageFactory.ScanLibraryProgressEvent(libraryId, 1F));
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false));
BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanLibrary(libraryId, false));
}
private async Task<Tuple<int, long, Dictionary<ParsedSeries, List<ParserInfo>>>> ScanFiles(Library library, IEnumerable<string> dirs)

View File

@ -102,7 +102,11 @@ namespace API.SignalR
/// <summary>
/// When bulk bookmarks are being converted
/// </summary>
public const string ConvertBookmarksProgress = "ConvertBookmarksProgress";
private const string ConvertBookmarksProgress = "ConvertBookmarksProgress";
/// <summary>
/// When files are being scanned to calculate word count
/// </summary>
private const string WordCountAnalyzerProgress = "WordCountAnalyzerProgress";
@ -149,6 +153,25 @@ namespace API.SignalR
};
}
public static SignalRMessage WordCountAnalyzerProgressEvent(int libraryId, float progress, string eventType, string subtitle = "")
{
return new SignalRMessage()
{
Name = WordCountAnalyzerProgress,
Title = "Analyzing Word count",
SubTitle = subtitle,
EventType = eventType,
Progress = ProgressType.Determinate,
Body = new
{
LibraryId = libraryId,
Progress = progress,
EventTime = DateTime.Now
}
};
}
public static SignalRMessage CoverUpdateProgressEvent(int libraryId, float progress, string eventType, string subtitle = "")
{
return new SignalRMessage()

View File

@ -5757,9 +5757,9 @@
"dev": true
},
"eventsource": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.0.tgz",
"integrity": "sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.1.tgz",
"integrity": "sha512-qV5ZC0h7jYIAOhArFJgSfdyz6rALJyb270714o7ZtNnw2WSJ+eexhKtE0O8LYPRsHZHf2osHKZBxGPvm3kPkCA==",
"requires": {
"original": "^1.0.0"
}
@ -10973,8 +10973,7 @@
"dependencies": {
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"resolved": "",
"dev": true
},
"strip-ansi": {
@ -11179,8 +11178,7 @@
"dependencies": {
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"resolved": "",
"dev": true
},
"ansi-styles": {
@ -11536,6 +11534,11 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true
},
"requires": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/requires/-/requires-1.0.2.tgz",
"integrity": "sha1-djBOghNFYi/j+sCwcRoeTygo8Po="
},
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",

View File

@ -33,12 +33,14 @@
"@types/file-saver": "^2.0.5",
"bootstrap": "^5.1.2",
"bowser": "^2.11.0",
"eventsource": "^1.1.1",
"file-saver": "^2.0.5",
"lazysizes": "^5.3.2",
"ng-circle-progress": "^1.6.0",
"ngx-color-picker": "^12.0.0",
"ngx-file-drop": "^13.0.0",
"ngx-toastr": "^14.2.1",
"requires": "^1.0.2",
"rxjs": "~7.5.4",
"swiper": "^8.0.6",
"tslib": "^2.3.1",

View File

@ -48,4 +48,8 @@ export interface Series {
* DateTime representing last time a chapter was added to the Series
*/
lastChapterAdded: string;
/**
* Number of words in the series
*/
wordCount: number;
}

View File

@ -9,20 +9,53 @@ import { Volume } from '../_models/volume';
import { AccountService } from './account.service';
export enum Action {
/**
* Mark entity as read
*/
MarkAsRead = 0,
/**
* Mark entity as unread
*/
MarkAsUnread = 1,
/**
* Invoke a Scan Library
*/
ScanLibrary = 2,
/**
* Delete the entity
*/
Delete = 3,
/**
* Open edit modal
*/
Edit = 4,
/**
* Open details modal
*/
Info = 5,
/**
* Invoke a refresh covers
*/
RefreshMetadata = 6,
/**
* Download the entity
*/
Download = 7,
/**
* @deprecated This is no longer supported. Use the dedicated page instead
* Invoke an Analyze Files which calculates word count
*/
AnalyzeFiles = 8,
/**
* Read in incognito mode aka no progress tracking
*/
Bookmarks = 8,
IncognitoRead = 9,
/**
* Add to reading list
*/
AddToReadingList = 10,
/**
* Add to collection
*/
AddToCollection = 11,
/**
* Essentially a download, but handled differently. Needed so card bubbles it up for handling
@ -31,7 +64,7 @@ export enum Action {
/**
* Open Series detail page for said series
*/
ViewSeries = 13
ViewSeries = 13,
}
export interface ActionItem<T> {
@ -97,6 +130,13 @@ export class ActionFactoryService {
requiresAdmin: true
});
this.seriesActions.push({
action: Action.AnalyzeFiles,
title: 'Analyze Files',
callback: this.dummyCallback,
requiresAdmin: true
});
this.seriesActions.push({
action: Action.Delete,
title: 'Delete',
@ -131,6 +171,13 @@ export class ActionFactoryService {
callback: this.dummyCallback,
requiresAdmin: true
});
this.libraryActions.push({
action: Action.AnalyzeFiles,
title: 'Analyze Files',
callback: this.dummyCallback,
requiresAdmin: true
});
this.chapterActions.push({
action: Action.Edit,
@ -200,11 +247,6 @@ export class ActionFactoryService {
return actions;
}
filterBookmarksForFormat(action: ActionItem<Series>, series: Series) {
if (action.action === Action.Bookmarks && series?.format === MangaFormat.EPUB) return false;
return true;
}
dummyCallback(action: Action, data: any) {}
_resetActions() {

View File

@ -64,6 +64,7 @@ export class ActionService implements OnDestroy {
});
}
/**
* Request a refresh of Metadata for a given Library
* @param library Partial Library, must have id and name populated
@ -90,6 +91,32 @@ export class ActionService implements OnDestroy {
});
}
/**
* Request an analysis of files for a given Library (currently just word count)
* @param library Partial Library, must have id and name populated
* @param callback Optional callback to perform actions after API completes
* @returns
*/
async analyzeFiles(library: Partial<Library>, callback?: LibraryActionCallback) {
if (!library.hasOwnProperty('id') || library.id === undefined) {
return;
}
if (!await this.confirmService.alert('This is a long running process. Please give it the time to complete before invoking again.')) {
if (callback) {
callback(library);
}
return;
}
this.libraryService.analyze(library?.id).pipe(take(1)).subscribe((res: any) => {
this.toastr.info('Library file analysis queued for ' + library.name);
if (callback) {
callback(library);
}
});
}
/**
* Mark a series as read; updates the series pagesRead
* @param series Series, must have id and name populated
@ -121,7 +148,7 @@ export class ActionService implements OnDestroy {
}
/**
* Start a file scan for a Series (currently just does the library not the series directly)
* Start a file scan for a Series
* @param series Series, must have libraryId and name populated
* @param callback Optional callback to perform actions after API completes
*/
@ -134,6 +161,20 @@ export class ActionService implements OnDestroy {
});
}
/**
* Start a file scan for analyze files for a Series
* @param series Series, must have libraryId and name populated
* @param callback Optional callback to perform actions after API completes
*/
analyzeFilesForSeries(series: Series, callback?: SeriesActionCallback) {
this.seriesService.analyzeFiles(series.libraryId, series.id).pipe(take(1)).subscribe((res: any) => {
this.toastr.info('Scan queued for ' + series.name);
if (callback) {
callback(series);
}
});
}
/**
* Start a metadata refresh for a Series
* @param series Series, must have libraryId, id and name populated

View File

@ -74,6 +74,10 @@ export class LibraryService {
return this.httpClient.post(this.baseUrl + 'library/scan?libraryId=' + libraryId, {});
}
analyze(libraryId: number) {
return this.httpClient.post(this.baseUrl + 'library/analyze?libraryId=' + libraryId, {});
}
refreshMetadata(libraryId: number) {
return this.httpClient.post(this.baseUrl + 'library/refresh-metadata?libraryId=' + libraryId, {});
}

View File

@ -20,6 +20,7 @@ export class MetadataService {
baseUrl = environment.apiUrl;
private ageRatingTypes: {[key: number]: string} | undefined = undefined;
private validLanguages: Array<Language> = [];
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
@ -81,7 +82,12 @@ export class MetadataService {
* All the potential language tags there can be
*/
getAllValidLanguages() {
return this.httpClient.get<Array<Language>>(this.baseUrl + 'metadata/all-languages');
if (this.validLanguages != undefined && this.validLanguages.length > 0) {
return of(this.validLanguages);
}
return this.httpClient.get<Array<Language>>(this.baseUrl + 'metadata/all-languages').pipe(map(l => this.validLanguages = l));
//return this.httpClient.get<Array<Language>>(this.baseUrl + 'metadata/all-languages').pipe();
}
getAllPeople(libraries?: Array<number>) {

View File

@ -145,6 +145,10 @@ export class SeriesService {
return this.httpClient.post(this.baseUrl + 'series/scan', {libraryId: libraryId, seriesId: seriesId});
}
analyzeFiles(libraryId: number, seriesId: number) {
return this.httpClient.post(this.baseUrl + 'series/analyze', {libraryId: libraryId, seriesId: seriesId});
}
getMetadata(seriesId: number) {
return this.httpClient.get<SeriesMetadata>(this.baseUrl + 'series/metadata?seriesId=' + seriesId).pipe(map(items => {
items?.collectionTags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id));

View File

@ -4,8 +4,6 @@ import { AuthGuard } from './_guards/auth.guard';
import { LibraryAccessGuard } from './_guards/library-access.guard';
import { AdminGuard } from './_guards/admin.guard';
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
// TODO: Use Prefetching of LazyLoaded Modules
const routes: Routes = [
{
path: 'admin',
@ -72,13 +70,9 @@ const routes: Routes = [
},
]
},
{
path: 'theme',
loadChildren: () => import('../app/dev-only/dev-only.module').then(m => m.DevOnlyModule)
},
{path: 'login', loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule)},
{path: '**', pathMatch: 'full', redirectTo: 'libraries'},
{path: '', pathMatch: 'full', redirectTo: 'libraries'},
{path: '', pathMatch: 'full', redirectTo: 'login'},
];
@NgModule({

View File

@ -71,7 +71,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
ngOnChanges(changes: any) {
if (this.data) {
this.actions = this.actionFactoryService.getSeriesActions((action: Action, series: Series) => this.handleSeriesActionCallback(action, series)).filter(action => this.actionFactoryService.filterBookmarksForFormat(action, this.data));
this.actions = this.actionFactoryService.getSeriesActions((action: Action, series: Series) => this.handleSeriesActionCallback(action, series));
this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id));
}
}
@ -102,10 +102,13 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
this.openEditModal(series);
break;
case(Action.AddToReadingList):
this.actionService.addSeriesToReadingList(series, (series) => {/* No Operation */ });
this.actionService.addSeriesToReadingList(series);
break;
case(Action.AddToCollection):
this.actionService.addMultipleSeriesToCollectionTag([series], () => {/* No Operation */ });
this.actionService.addMultipleSeriesToCollectionTag([series]);
break;
case (Action.AnalyzeFiles):
this.actionService.analyzeFilesForSeries(series);
break;
default:
break;

View File

@ -1,18 +0,0 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ThemeTestComponent } from './theme-test/theme-test.component';
const routes: Routes = [
{
path: '',
component: ThemeTestComponent,
}
];
@NgModule({
imports: [RouterModule.forChild(routes), ],
exports: [RouterModule]
})
export class DevOnlyRoutingModule { }

View File

@ -1,37 +0,0 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { CardsModule } from '../cards/cards.module';
import { TypeaheadModule } from '../typeahead/typeahead.module';
import { ThemeTestComponent } from './theme-test/theme-test.component';
import { SharedModule } from '../shared/shared.module';
import { PipeModule } from '../pipe/pipe.module';
import { DevOnlyRoutingModule } from './dev-only-routing.module';
import { FormsModule } from '@angular/forms';
/**
* This module contains components that aren't meant to ship with main code. They are there to test things out. This module may be deleted in future updates.
*/
@NgModule({
declarations: [
ThemeTestComponent
],
imports: [
CommonModule,
FormsModule,
TypeaheadModule,
CardsModule,
NgbAccordionModule,
NgbNavModule,
SharedModule,
PipeModule,
DevOnlyRoutingModule
]
})
export class DevOnlyModule { }

View File

@ -1,188 +0,0 @@
<h1>Themes</h1>
<button class="btn btn-primary" (click)="themeService?.setTheme('dark')">Dark</button>
<button class="btn btn-primary" (click)="themeService?.setTheme('light')">Light</button>
<button class="btn btn-primary" (click)="themeService?.setTheme('E-Ink')">E-ink</button>
<button class="btn btn-primary" (click)="themeService?.setTheme('custom')">Custom</button>
<h2>Buttons</h2>
<button class="btn btn-primary">Primary</button>
<button class="btn btn-secondary">secondary</button>
<button class="btn btn-secondary alt">secondary alt</button>
<button class="btn btn-outline-primary">outline primary</button>
<button class="btn btn-outline-secondary">outline secondary</button>
<button class="btn btn-link">btn link</button>
<button class="btn btn-icon">
<i class="fa fa-angle-left"></i>&nbsp;Icon
</button>
<h2>Toastr</h2>
<button class="btn btn-primary" (click)="toastr.success('Test')">Success</button>
<button class="btn btn-danger" (click)="toastr.error('Test')">Error</button>
<button class="btn btn-secondary" (click)="toastr.warning('Test')">Warning</button>
<button class="btn btn-link" (click)="toastr.info('Test')">Info</button>
<h2>Inputs</h2>
<p>Inputs should always have class="form-control" on them</p>
<label>Normal</label>
<input type="text" class="form-control">
<label>Readonly</label>
<input type="text" readonly class="form-control">
<label>Placeholder</label>
<input type="text" placeholder="Hello, I'm a placeholder" class="form-control">
<label>Disabled</label>
<input type="text" placeholder="Hello, I'm a placeholder" [disabled]="true" class="form-control">
<h2>Checkbox</h2>
<div class="mb-3">
<label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<div class="form-check">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input">
<label for="stat-collection" class="form-check-label">Normal Checkbox</label>
</div>
</div>
<div class="mb-3">
<label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<div class="form-check">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" [disabled]="true">
<label for="stat-collection" class="form-check-label">Disabled Checkbox</label>
</div>
</div>
<h2>Radio</h2>
<p>Labels should have form-check-label on them and inputs form-check-input</p>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="radio" id="site-dark-mode" [value]="true" aria-labelledby="site-dark-mode-label">
<label class="form-check-label" for="site-dark-mode">True</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" id="site-not-dark-mode2" [value]="false" aria-labelledby="site-dark-mode-label">
<label class="form-check-label" for="site-not-dark-mode2">False</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" id="site-not-dark-mode3" [disabled]="true" [value]="false" aria-labelledby="site-dark-mode-label">
<label class="form-check-label" for="site-not-dark-mode3">Disabled</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" id="site-not-dark-mode4" readonly [value]="false" aria-labelledby="site-dark-mode-label">
<label class="form-check-label" for="site-not-dark-mode4">Readonly</label>
</div>
</div>
<h2>Nav tabs</h2>
<h3>Tabs</h3>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs nav-pills">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<a ngbNavLink routerLink=".">{{ tab.title | sentenceCase }}</a>
<ng-template ngbNavContent>
<ng-container>
Tab 1
</ng-container>
<ng-container>
Tab 2
</ng-container>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
<h3>Tabs</h3>
<nav role="navigation">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-pills justify-content-center mt-3" role="tab">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab" class="nav-item">
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | titlecase }}</a>
<ng-template ngbNavContent>
<ng-container>
Tab 1
</ng-container>
<ng-container>
Tab 2
</ng-container>
</ng-template>
</li>
</ul>
</nav>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
<h2>Tag Badge</h2>
<div class="g-2">
<app-tag-badge [selectionMode]="TagBadgeCursor.Selectable">Selectable</app-tag-badge>
<app-tag-badge [selectionMode]="TagBadgeCursor.Clickable">Clickable</app-tag-badge>
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed">Non Allowed</app-tag-badge>
</div>
<h2>Person Badge with Expander</h2>
<div class="g-2">
<app-person-badge></app-person-badge>
<app-badge-expander [items]="people" [itemsTillExpander]="1">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<h2>Switch</h2>
<form>
<div class="mb-3">
<label id="auto-close-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" id="auto-close" class="form-check-input" aria-labelledby="auto-close-label">
<label class="form-check-label" for="auto-close">Auto Close Menu</label>
</div>
</div>
</div>
</form>
<h2>Dropdown/List Group</h2>
<div class="dropdown" >
<ul class="list-group" role="listbox" id="dropdown">
<li class="list-group-item">Item 1</li>
<li class="list-group-item">Item 2</li>
</ul>
</div>
<h2>Accordion</h2>
<ngb-accordion [closeOthers]="true" activeIds="reading-panel" #acc="ngbAccordion">
<ngb-panel id="reading-panel" title="Reading">
<ng-template ngbPanelHeader>
<h2 class="accordion-header">
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded('reading-panel')" aria-controls="collapseOne">
Reading
</button>
</h2>
</ng-template>
<ng-template ngbPanelContent>
<p>This is the body of the accordion...........This is the body of the accordion asdfasdf asThis is the body of the accordion asdfasdf asThis is the body of the accordion asdfasdf asThis is the body of the accordion asdfasdf asThis is the body of the accordion asdfasdf as</p>
</ng-template>
</ngb-panel>
<ngb-panel id="reading-panel2">
<ng-template ngbPanelHeader>
<h2 class="accordion-header">
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded('reading-panel')" aria-controls="collapseOne">
Header 2
</button>
</h2>
</ng-template>
<ng-template ngbPanelContent>
<p>This is the body of the accordion asdfasdf as
dfas
f asdfasdfasdf asdfasdfaaff asdf
as fd
asfasf asdfasdfafd
</p>
</ng-template>
</ngb-panel>
</ngb-accordion>
<h2>Cards</h2>
<app-card-item [entity]="seriesNotRead"></app-card-item>
<app-card-item [entity]="seriesNotRead" [count]="10"></app-card-item>
<app-card-item [entity]="seriesWithProgress"></app-card-item>

View File

@ -1,84 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { TagBadgeCursor } from '../../shared/tag-badge/tag-badge.component';
import { ThemeService } from '../../_services/theme.service';
import { MangaFormat } from '../../_models/manga-format';
import { Person, PersonRole } from '../../_models/person';
import { Series } from '../../_models/series';
import { NavService } from '../../_services/nav.service';
@Component({
selector: 'app-theme-test',
templateUrl: './theme-test.component.html',
styleUrls: ['./theme-test.component.scss']
})
export class ThemeTestComponent implements OnInit {
tabs: Array<{title: string, fragment: string}> = [
{title: 'General', fragment: ''},
{title: 'Users', fragment: 'users'},
{title: 'Libraries', fragment: 'libraries'},
{title: 'System', fragment: 'system'},
{title: 'Changelog', fragment: 'changelog'},
];
active = this.tabs[0];
people: Array<Person> = [
{id: 1, name: 'Joe', role: PersonRole.Artist},
{id: 2, name: 'Joe 2', role: PersonRole.Artist},
];
seriesNotRead: Series = {
id: 1,
name: 'Test Series',
pages: 0,
pagesRead: 10,
format: MangaFormat.ARCHIVE,
libraryId: 1,
coverImageLocked: false,
created: '',
latestReadDate: '',
localizedName: '',
originalName: '',
sortName: '',
userRating: 0,
userReview: '',
volumes: [],
localizedNameLocked: false,
nameLocked: false,
sortNameLocked: false,
lastChapterAdded: '',
}
seriesWithProgress: Series = {
id: 1,
name: 'Test Series',
pages: 5,
pagesRead: 10,
format: MangaFormat.ARCHIVE,
libraryId: 1,
coverImageLocked: false,
created: '',
latestReadDate: '',
localizedName: '',
originalName: '',
sortName: '',
userRating: 0,
userReview: '',
volumes: [],
localizedNameLocked: false,
nameLocked: false,
sortNameLocked: false,
lastChapterAdded: '',
}
get TagBadgeCursor(): typeof TagBadgeCursor {
return TagBadgeCursor;
}
constructor(public toastr: ToastrService, public navService: NavService, public themeService: ThemeService) { }
ngOnInit(): void {
}
}

View File

@ -3,7 +3,7 @@
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
{{libraryName}}
</h2>
<h6 subtitle style="margin-left:40px;" *ngIf="active.fragment === ''">{{pagination?.totalItems}} Series</h6>
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{pagination?.totalItems}} Series</h6>
<div main>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-pills" style="flex-wrap: nowrap;">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">

View File

@ -0,0 +1,18 @@
import { Pipe, PipeTransform } from '@angular/core';
const formatter = new Intl.NumberFormat('en-GB', {
//@ts-ignore
notation: 'compact' // https://github.com/microsoft/TypeScript/issues/36533
});
@Pipe({
name: 'compactNumber'
})
export class CompactNumberPipe implements PipeTransform {
transform(value: number): string {
return formatter.format(value);
}
}

View File

@ -5,8 +5,8 @@ import { Pipe, PipeTransform } from '@angular/core';
})
export class DefaultValuePipe implements PipeTransform {
transform(value: any): string {
if (value === null || value === undefined || value === '' || value === Infinity || value === NaN || value === {}) return '—';
transform(value: any, replacementString = '—'): string {
if (value === null || value === undefined || value === '' || value === Infinity || value === NaN || value === {}) return replacementString;
return value;
}

View File

@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import { map, Observable } from 'rxjs';
import { MetadataService } from '../_services/metadata.service';
@Pipe({
name: 'languageName'
})
export class LanguageNamePipe implements PipeTransform {
constructor(private metadataService: MetadataService) {
}
transform(isoCode: string): Observable<string> {
return this.metadataService.getAllValidLanguages().pipe(map(lang => {
return lang.filter(l => l.isoCode === isoCode)[0].title;
}));
}
}

View File

@ -7,6 +7,8 @@ import { PersonRolePipe } from './person-role.pipe';
import { SafeHtmlPipe } from './safe-html.pipe';
import { RelationshipPipe } from './relationship.pipe';
import { DefaultValuePipe } from './default-value.pipe';
import { CompactNumberPipe } from './compact-number.pipe';
import { LanguageNamePipe } from './language-name.pipe';
@ -18,7 +20,9 @@ import { DefaultValuePipe } from './default-value.pipe';
SentenceCasePipe,
SafeHtmlPipe,
RelationshipPipe,
DefaultValuePipe
DefaultValuePipe,
CompactNumberPipe,
LanguageNamePipe
],
imports: [
CommonModule,
@ -30,7 +34,9 @@ import { DefaultValuePipe } from './default-value.pipe';
SentenceCasePipe,
SafeHtmlPipe,
RelationshipPipe,
DefaultValuePipe
DefaultValuePipe,
CompactNumberPipe,
LanguageNamePipe
]
})
export class PipeModule { }

View File

@ -5,7 +5,7 @@
</span>
{{readingList?.title}}&nbsp;<span *ngIf="readingList?.promoted">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
</h2>
<h6 subtitle style="margin-left: 40px">{{items.length}} Items</h6>
<h6 subtitle class="subtitle-with-actionables">{{items.length}} Items</h6>
</app-side-nav-companion-bar>
<div class="container-fluid mt-2" *ngIf="readingList">

View File

@ -6,7 +6,7 @@
</h2>
</ng-container>
<ng-container subtitle *ngIf="series?.localizedName !== series?.name">
<h6 style="margin-left:40px;" title="Localized Name">{{series?.localizedName}}</h6>
<h6 class="subtitle-with-actionables" title="Localized Name">{{series?.localizedName}}</h6>
</ng-container>
</app-side-nav-companion-bar>
<div class="container-fluid pt-2" *ngIf="series !== undefined">
@ -62,6 +62,26 @@
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"></app-series-metadata-detail>
</div>
<!-- <ng-container>
<div class="row g-0">
<div class="col-2">
<i class="fa-regular fa-file-lines" aria-hidden="true"></i>
{{series.pages}} Pages
</div>
|
<div class="col-2">
<i class="fa-regular fa-clock" aria-hidden="true"></i>
1-2 Hours to Read
</div>
<ng-container *ngIf="utilityService.mangaFormat(series.format) === 'EPUB'">
|
<div class="col-2">
<i class="fa-regular fa-book-open" aria-hidden="true"></i>
10K Total Words
</div>
</ng-container>
</div>
</ng-container> -->
</div>
</div>

View File

@ -290,6 +290,9 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
case(Action.AddToCollection):
this.actionService.addMultipleSeriesToCollectionTag([series], () => this.actionInProgress = false);
break;
case (Action.AnalyzeFiles):
this.actionService.analyzeFilesForSeries(series, () => this.actionInProgress = false);
break;
default:
break;
}
@ -372,12 +375,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.titleService.setTitle('Kavita - ' + this.series.name + ' Details');
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
.filter(action => action.action !== Action.Edit)
.filter(action => this.actionFactoryService.filterBookmarksForFormat(action, this.series));
.filter(action => action.action !== Action.Edit);
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
// TODO: Move this to a forkJoin?
this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => {
this.relations = [
...relations.prequels.map(item => this.createRelatedSeries(item, RelationKind.Prequel)),

View File

@ -3,22 +3,85 @@
</div>
<!-- This first row will have random information about the series-->
<div class="row g-0 mb-2">
<app-tag-badge title="Age Rating" *ngIf="seriesMetadata.ageRating" a11y-click="13,32" class="clickable col-auto" (click)="goTo(FilterQueryParam.AgeRating, seriesMetadata.ageRating)" [selectionMode]="TagBadgeCursor.Clickable">{{metadataService.getAgeRating(this.seriesMetadata.ageRating) | async}}</app-tag-badge>
<div class="row g-0 mb-4 mt-3">
<ng-container *ngIf="seriesMetadata.ageRating">
<div class="col-auto">
<app-icon-and-title [clickable]="true" fontClasses="fas fa-eye" (click)="goTo(FilterQueryParam.AgeRating, seriesMetadata.ageRating)" title="Age Rating">
{{metadataService.getAgeRating(this.seriesMetadata.ageRating) | async}}
</app-icon-and-title>
</div>
<div class="vr m-2"></div>
</ng-container>
<ng-container *ngIf="series">
<app-tag-badge *ngIf="seriesMetadata.releaseYear > 0" title="Release date" class="col-auto">{{seriesMetadata.releaseYear}}</app-tag-badge>
<app-tag-badge *ngIf="seriesMetadata.language !== null && seriesMetadata.language !== ''" title="Language" a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Languages, seriesMetadata.language)" [selectionMode]="TagBadgeCursor.Clickable">{{seriesMetadata.language}}</app-tag-badge>
<app-tag-badge title="Publication Status ({{seriesMetadata.maxCount}} / {{seriesMetadata.totalCount}})" [fillStyle]="seriesMetadata.maxCount != 0 && seriesMetadata.totalCount != 0 && seriesMetadata.maxCount >= seriesMetadata.totalCount ? 'filled' : 'outline'" a11y-click="13,32" class="col-auto"
(click)="goTo(FilterQueryParam.PublicationStatus, seriesMetadata.publicationStatus)"
[selectionMode]="TagBadgeCursor.Clickable">{{seriesMetadata.publicationStatus | publicationStatus}}</app-tag-badge>
<ng-container *ngIf="seriesMetadata.releaseYear > 0">
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release Year">
{{seriesMetadata.releaseYear}}
</app-icon-and-title>
</div>
<div class="vr m-2"></div>
</ng-container>
<app-tag-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Format, series.format)" [selectionMode]="TagBadgeCursor.Clickable">
<app-series-format [format]="series.format">{{utilityService.mangaFormat(series.format)}}</app-series-format>
</app-tag-badge>
<app-tag-badge title="Last Read" class="col-auto" *ngIf="series.latestReadDate && series.latestReadDate !== '' && (series.latestReadDate | date: 'shortDate') !== '1/1/01'" [selectionMode]="TagBadgeCursor.Selectable">
Last Read: {{series.latestReadDate | date:'shortDate'}}
</app-tag-badge>
<ng-container *ngIf="seriesMetadata.language !== null">
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="true" fontClasses="fas fa-language" (click)="goTo(FilterQueryParam.Languages, seriesMetadata.language)" title="Language">
{{seriesMetadata.language | defaultValue:'en' | languageName | async}}
</app-icon-and-title>
</div>
<div class="vr m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="true" fontClasses="fa-solid fa-hourglass-empty" (click)="goTo(FilterQueryParam.PublicationStatus, seriesMetadata.publicationStatus)" title="Publication Status ({{seriesMetadata.maxCount}} / {{seriesMetadata.totalCount}})">
{{seriesMetadata.publicationStatus | publicationStatus}}
</app-icon-and-title>
</div>
<div class="vr m-2 mb-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="true" [fontClasses]="'fa ' + utilityService.mangaFormatIcon(series.format)" (click)="goTo(FilterQueryParam.Format, series.format)" title="Format">
{{utilityService.mangaFormat(series.format)}}
</app-icon-and-title>
</div>
<div class="vr m-2"></div>
</ng-container>
<ng-container *ngIf="series.latestReadDate && series.latestReadDate !== '' && (series.latestReadDate | date: 'shortDate') !== '1/1/01'">
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-regular fa-clock" title="Last Read">
{{series.latestReadDate | date:'shortDate'}}
</app-icon-and-title>
</div>
<div class="vr m-2"></div>
</ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-regular fa-file-lines">
{{series.pages}} Pages
</app-icon-and-title>
</div>
<div class="vr m-2"></div>
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0 || series.format !== MangaFormat.EPUB">
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-regular fa-clock">
{{minHoursToRead}}{{maxHoursToRead !== minHoursToRead ? ('-' + maxHoursToRead) : ''}} Hours
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0">
<div class="vr m-2"></div>
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-solid fa-book-open">
{{series.wordCount | compactNumber}} Words
</app-icon-and-title>
</div>
</ng-container>
</ng-container>
</div>
@ -92,11 +155,6 @@
</div>
</div>
<div class="row g-0">
<hr class="col mt-3" *ngIf="hasExtendedProperites" >
<a [class.hidden]="hasExtendedProperites" *ngIf="hasExtendedProperites" class="col col-md-auto align-self-end read-more-link" (click)="toggleView()">&nbsp;<i aria-hidden="true" class="fa fa-caret-{{isCollapsed ? 'down' : 'up'}}" aria-controls="extended-series-metadata"></i>&nbsp;See {{isCollapsed ? 'More' : 'Less'}}</a>
</div>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isCollapsed" id="extended-series-metadata">
<div class="row g-0 mt-1" *ngIf="seriesMetadata.coverArtists && seriesMetadata.coverArtists.length > 0">
<div class="col-md-4">
@ -213,4 +271,13 @@
</app-badge-expander>
</div>
</div>
</div>
<div class="row g-0">
<hr class="col mt-3" *ngIf="hasExtendedProperites" >
<a [class.hidden]="hasExtendedProperites" *ngIf="hasExtendedProperites"
class="col col-md-auto align-self-end read-more-link" (click)="toggleView()">
<i aria-hidden="true" class="fa fa-caret-{{isCollapsed ? 'down' : 'up'}} me-1" aria-controls="extended-series-metadata"></i>
See {{isCollapsed ? 'More' : 'Less'}}
</a>
</div>

View File

@ -9,6 +9,12 @@ import { Series } from '../../_models/series';
import { SeriesMetadata } from '../../_models/series-metadata';
import { MetadataService } from '../../_services/metadata.service';
const MAX_WORDS_PER_HOUR = 30_000;
const MIN_WORDS_PER_HOUR = 10_260;
const MAX_PAGES_PER_MINUTE = 2.75;
const MIN_PAGES_PER_MINUTE = 3.33;
@Component({
selector: 'app-series-metadata-detail',
templateUrl: './series-metadata-detail.component.html',
@ -26,6 +32,9 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
isCollapsed: boolean = true;
hasExtendedProperites: boolean = false;
minHoursToRead: number = 1;
maxHoursToRead: number = 1;
/**
* Html representation of Series Summary
*/
@ -58,8 +67,19 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
if (this.seriesMetadata !== null) {
this.seriesSummary = (this.seriesMetadata.summary === null ? '' : this.seriesMetadata.summary).replace(/\n/g, '<br>');
}
if (this.series !== null && this.series.wordCount > 0) {
if (this.series.format === MangaFormat.EPUB) {
this.minHoursToRead = parseInt(Math.round(this.series.wordCount / MAX_WORDS_PER_HOUR) + '', 10);
this.maxHoursToRead = parseInt(Math.round(this.series.wordCount / MIN_WORDS_PER_HOUR) + '', 10);
} else if (this.series.format === MangaFormat.IMAGE || this.series.format === MangaFormat.ARCHIVE) {
this.minHoursToRead = parseInt(Math.round((this.series.wordCount * MAX_PAGES_PER_MINUTE) / 60) + '', 10);
this.maxHoursToRead = parseInt(Math.round((this.series.wordCount * MIN_PAGES_PER_MINUTE) / 60) + '', 10);
}
}
}
ngOnInit(): void {

View File

@ -0,0 +1,8 @@
<div class="d-flex justify-content-center align-self-center align-items-center icon-and-title" [ngClass]="{'clickable': clickable}" [attr.role]="clickable ? 'button' : ''"
(click)="handleClick($event)">
<i class="{{fontClasses}} mx-auto icon" aria-hidden="true" [title]="title"></i>
<div style="padding-top: 5px">
<ng-content></ng-content>
</div>
</div>

View File

@ -0,0 +1,9 @@
.icon-and-title {
flex-direction: column;
min-width: 60px;
}
.icon {
width: 20px;
height: 20px;
}

View File

@ -0,0 +1,32 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
@Component({
selector: 'app-icon-and-title',
templateUrl: './icon-and-title.component.html',
styleUrls: ['./icon-and-title.component.scss']
})
export class IconAndTitleComponent implements OnInit {
/**
* If the component is clickable and should emit click events
*/
@Input() clickable: boolean = true;
@Input() title: string = '';
/**
* Font classes used to display font
*/
@Input() fontClasses: string = '';
@Output() click: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>();
constructor() { }
ngOnInit(): void {
}
handleClick(event: MouseEvent) {
if (this.clickable) this.click.emit(event);
}
}

View File

@ -17,6 +17,7 @@ import { PersonBadgeComponent } from './person-badge/person-badge.component';
import { BadgeExpanderComponent } from './badge-expander/badge-expander.component';
import { ImageComponent } from './image/image.component';
import { PipeModule } from '../pipe/pipe.module';
import { IconAndTitleComponent } from './icon-and-title/icon-and-title.component';
@NgModule({
declarations: [
@ -32,6 +33,7 @@ import { PipeModule } from '../pipe/pipe.module';
PersonBadgeComponent,
BadgeExpanderComponent,
ImageComponent,
IconAndTitleComponent,
],
imports: [
CommonModule,
@ -55,6 +57,8 @@ import { PipeModule } from '../pipe/pipe.module';
PersonBadgeComponent, // Used Series Detail
BadgeExpanderComponent, // Used Series Detail/Metadata
IconAndTitleComponent // Used in Series Detail/Metadata
],
})

View File

@ -84,6 +84,9 @@ export class SideNavComponent implements OnInit, OnDestroy {
case(Action.RefreshMetadata):
this.actionService.refreshMetadata(library);
break;
case (Action.AnalyzeFiles):
this.actionService.analyzeFiles(library);
break;
default:
break;
}

View File

@ -25,3 +25,7 @@ hr {
.text-muted {
color: var(--text-muted-color) !important;
}
.subtitle-with-actionables {
margin-left: 32px;
}