From f5d0ad2ed25809f55bf714893fe3cac9c30353df Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sun, 4 Jan 2026 12:17:56 -0700 Subject: [PATCH] Koreader Progress Sync for all files (#4323) Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com> --- API.Tests/Parsing/ParsingTests.cs | 31 ++ API/Controllers/AccountController.cs | 11 +- API/Controllers/DeprecatedController.cs | 84 +++++ API/Controllers/KoreaderController.cs | 7 +- API/Controllers/LibraryController.cs | 19 +- API/Controllers/MetadataController.cs | 2 +- API/Controllers/ReaderController.cs | 4 +- API/Controllers/RecommendedController.cs | 108 ------- API/Controllers/SeriesController.cs | 14 +- API/Controllers/StatsController.cs | 28 +- API/Controllers/StreamController.cs | 8 +- API/Controllers/WantToReadController.cs | 7 +- API/DTOs/Filtering/v2/FilterV2Dto.cs | 2 +- API/DTOs/MangaFileDto.cs | 4 + API/DTOs/ScanFolderDto.cs | 5 + API/DTOs/Settings/ServerSettingDTO.cs | 4 + API/DTOs/Stats/V3/ServerInfoV3Dto.cs | 4 +- .../v0.8.9/MigrateFormatToActivityData.cs | 8 +- .../MigrateIncorrectUtcMidnightRollovers.cs | 56 ++++ API/Data/Repositories/SeriesRepository.cs | 20 +- API/Data/Repositories/UserRepository.cs | 13 + API/Data/Seed.cs | 1 + API/Entities/Enums/ServerSettingKey.cs | 8 +- API/Entities/MangaFile.cs | 1 - API/Helpers/Builders/MangaFileBuilder.cs | 2 +- .../Converters/ServerSettingConverter.cs | 3 + API/Helpers/KoreaderHelper.cs | 2 +- API/Helpers/PdfComicInfoExtractor.cs | 2 +- .../Attribute/DisallowRoleAttribute.cs | 2 - API/Services/AccountService.cs | 2 +- API/Services/BookService.cs | 5 +- API/Services/EmailService.cs | 4 +- API/Services/EntityDisplayService.cs | 10 +- API/Services/KoreaderService.cs | 11 +- API/Services/Plus/ExternalMetadataService.cs | 9 +- API/Services/Reading/ReadingSessionService.cs | 3 +- API/Services/ReadingListService.cs | 6 +- API/Services/SeriesService.cs | 49 +-- API/Services/SettingsService.cs | 1 + API/Services/StatisticService.cs | 119 ++++++-- API/Services/TaskScheduler.cs | 6 +- .../Tasks/Scanner/Parser/BasicParser.cs | 4 +- .../Tasks/Scanner/Parser/BookParser.cs | 17 +- .../Tasks/Scanner/Parser/ComicVineParser.cs | 2 +- .../Tasks/Scanner/Parser/DefaultParser.cs | 26 +- API/Services/Tasks/Scanner/Parser/Parser.cs | 17 +- .../Tasks/Scanner/Parser/ParserInfo.cs | 8 +- .../Tasks/Scanner/Parser/PdfParser.cs | 4 +- API/Services/Tasks/ScannerService.cs | 7 +- API/Services/Tasks/StatsService.cs | 18 ++ API/Startup.cs | 3 +- UI/Web/src/_card-item-common.scss | 6 +- UI/Web/src/app/_models/chapter.ts | 3 - UI/Web/src/app/_models/manga-file.ts | 15 +- .../src/app/_models/metadata/series-filter.ts | 3 + .../_pipes/reading-progress-icon-pipe.pipe.ts | 23 ++ .../reading-progress-status-pipe.pipe.ts | 9 +- UI/Web/src/app/_services/account.service.ts | 9 +- .../app/_services/external-source.service.ts | 4 +- UI/Web/src/app/_services/reader.service.ts | 3 +- UI/Web/src/app/_services/series.service.ts | 7 +- .../src/app/_services/statistics.service.ts | 15 +- .../card-actionables.component.html | 4 +- .../card-actionables.component.ts | 8 +- .../details-tab/details-tab.component.html | 32 +- .../details-tab/details-tab.component.ts | 23 +- .../profile-icon/profile-icon.component.html | 29 +- .../profile-icon/profile-icon.component.ts | 11 +- .../review-card/review-card.component.html | 2 +- .../user-scrobble-history.component.html | 7 +- .../app/admin/license/license.component.ts | 14 +- .../manage-library.component.html | 232 +++++++++----- .../manage-library.component.scss | 2 +- .../manage-library.component.ts | 17 +- .../manage-users/manage-users.component.html | 286 +++++++++++------- .../manage-users/manage-users.component.scss | 20 -- .../manage-users/manage-users.component.ts | 16 +- .../server-activity.component.ts | 4 +- .../server-devices.component.ts | 8 +- .../annotation-card.component.html | 2 +- .../annotation-card.component.ts | 3 +- .../epub-highlight.component.ts | 5 +- .../view-bookmark-drawer.component.ts | 6 +- ...view-edit-annotation-drawer.component.html | 2 +- .../view-edit-annotation-drawer.component.ts | 8 +- .../view-toc-drawer.component.ts | 8 +- .../book-line-overlay.component.ts | 4 +- .../book-reader/book-reader.component.html | 1 - .../book-reader/book-reader.component.ts | 36 +-- .../personal-table-of-contents.component.ts | 6 +- .../table-of-contents.component.ts | 11 +- .../carousel-reel/carousel-reel.component.ts | 2 +- .../chapter-detail.component.html | 71 +++-- .../chapter-detail.component.ts | 73 ++--- .../stats-no-data.component.html | 4 + .../stats-no-data.component.scss | 5 + .../stats-no-data/stats-no-data.component.ts | 21 ++ .../infinite-scroller.component.ts | 29 +- .../manga-reader/manga-reader.component.ts | 5 +- .../metadata-filter.component.ts | 3 +- .../nav-header/nav-header.component.html | 15 +- .../nav-header/nav-header.component.ts | 41 ++- .../nav-link-modal.component.html | 2 +- .../nav-link-modal.component.ts | 11 +- .../profile-image/profile-image.component.ts | 2 +- .../profile-overview.component.ts | 10 +- .../profile-review-list.component.ts | 2 +- .../profile-stat-bar.component.ts | 5 +- .../_components/profile/profile.component.ts | 26 +- .../reading-list-detail.component.ts | 8 +- .../series-detail.component.html | 6 +- .../series-detail/series-detail.component.ts | 3 +- .../settings/settings.component.ts | 10 +- .../line-chart/line-chart.component.ts | 6 +- .../list-select-modal.component.ts | 10 +- .../responsive-table.component.html | 63 ++-- .../responsive-table.component.scss | 3 + .../responsive-table.component.ts | 24 +- .../_services/filter-utilities.service.ts | 2 - .../side-nav/side-nav.component.ts | 1 - .../library-settings-modal.component.ts | 7 +- .../preference-nav.component.ts | 2 +- .../generic-table-modal.component.ts | 4 - .../active-user-card.component.html | 2 +- .../active-user-card.component.ts | 4 +- ...-time-spend-reading-by-hour.component.html | 2 +- ...-time-spend-reading-by-hour.component.scss | 3 - ...vg-time-spend-reading-by-hour.component.ts | 4 +- .../bucket-spread-chart.component.html | 2 +- .../bucket-spread-chart.component.ts | 4 +- .../day-breakdown.component.html | 2 +- .../day-breakdown/day-breakdown.component.ts | 11 +- .../favorite-authors.component.html | 3 +- .../favorite-authors.component.ts | 4 +- .../file-breakdown-stats.component.html | 204 +++++++------ .../file-breakdown-stats.component.ts | 3 +- .../files-over-time.component.html | 11 +- .../files-over-time.component.ts | 23 +- .../manga-format-stats.component.html | 50 --- .../manga-format-stats.component.scss | 15 - .../manga-format-stats.component.ts | 42 --- .../most-active-users.component.html | 5 +- .../most-active-users.component.scss | 5 - .../most-active-users.component.ts | 18 +- .../preferred-format.component.html | 2 +- .../preferred-format.component.ts | 4 +- .../publication-status-stats.component.html | 90 +++--- .../publication-status-stats.component.ts | 10 +- .../rating-spread.component.html | 1 - .../rating-spread.component.scss | 0 .../rating-spread/rating-spread.component.ts | 12 - .../reading-activity.component.html | 2 +- .../reading-activity.component.scss | 14 - .../reading-activity.component.ts | 6 +- .../reading-pace/reading-pace.component.html | 2 +- .../reading-pace/reading-pace.component.ts | 4 +- .../reads-by-month.component.html | 2 +- .../reads-by-month.component.ts | 4 +- .../server-stats-mgmt-tab.component.html | 1 - .../server-stats-stats-tab.component.html | 75 +++-- .../server-stats-stats-tab.component.ts | 124 ++++++-- .../server-stats/server-stats.component.html | 13 +- .../server-stats/server-stats.component.ts | 130 +------- .../stat-list/stat-list.component.scss | 51 +++- .../stat-list/stat-list.component.ts | 8 +- .../_components/typeahead.component.ts | 19 +- .../create-auth-key.component.ts | 4 +- .../change-age-restriction.component.html | 4 +- .../change-age-restriction.component.ts | 25 +- .../manage-devices.component.ts | 8 +- .../volume-detail.component.html | 39 +-- .../volume-detail/volume-detail.component.ts | 13 +- UI/Web/src/assets/langs/en.json | 78 +---- UI/Web/src/theme/components/_stat-card.scss | 1 + UI/Web/src/theme/components/_table.scss | 1 + 175 files changed, 1900 insertions(+), 1435 deletions(-) delete mode 100644 API/Controllers/RecommendedController.cs create mode 100644 API/Data/ManualMigrations/v0.8.9/MigrateIncorrectUtcMidnightRollovers.cs create mode 100644 UI/Web/src/app/_pipes/reading-progress-icon-pipe.pipe.ts create mode 100644 UI/Web/src/app/common/stats-no-data/stats-no-data.component.html create mode 100644 UI/Web/src/app/common/stats-no-data/stats-no-data.component.scss create mode 100644 UI/Web/src/app/common/stats-no-data/stats-no-data.component.ts delete mode 100644 UI/Web/src/app/statistics/_components/manga-format-stats/manga-format-stats.component.html delete mode 100644 UI/Web/src/app/statistics/_components/manga-format-stats/manga-format-stats.component.scss delete mode 100644 UI/Web/src/app/statistics/_components/manga-format-stats/manga-format-stats.component.ts delete mode 100644 UI/Web/src/app/statistics/_components/rating-spread/rating-spread.component.html delete mode 100644 UI/Web/src/app/statistics/_components/rating-spread/rating-spread.component.scss delete mode 100644 UI/Web/src/app/statistics/_components/rating-spread/rating-spread.component.ts diff --git a/API.Tests/Parsing/ParsingTests.cs b/API.Tests/Parsing/ParsingTests.cs index 7d5da4f9c..5a91ab281 100644 --- a/API.Tests/Parsing/ParsingTests.cs +++ b/API.Tests/Parsing/ParsingTests.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Linq; +using API.Services.Tasks.Scanner.Parser; using Xunit; using static API.Services.Tasks.Scanner.Parser.Parser; @@ -19,6 +20,36 @@ public class ParsingTests Assert.Equal(6.5f, a); } + [Theory] + [InlineData("", false)] + [InlineData(null, false)] + [InlineData(DefaultChapter, true)] + public void IsDefaultChapterTest(string input, bool expected) + { + Assert.Equal(expected, IsDefaultChapter(input)); + } + + [Fact] + public void IsDefaultChapterTest_FloatString() + { + Assert.True(IsDefaultChapter($"{DefaultChapterNumber}")); + } + + [Theory] + [InlineData("", false)] + [InlineData(null, false)] + [InlineData(LooseLeafVolume, true)] + public void IsLooseLeafTest(string input, bool expected) + { + Assert.Equal(expected, IsLooseLeafVolume(input)); + } + + [Fact] + public void IsLooseLeafTest_FloatString() + { + Assert.True(IsLooseLeafVolume($"{LooseLeafVolumeNumber}")); + } + // [Theory] // [InlineData("de-DE")] // [InlineData("en-US")] diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 7a911f3f6..302302e6a 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -244,11 +244,7 @@ public class AccountController : BaseApiController AppUser? user; if (!string.IsNullOrEmpty(loginDto.ApiKey)) { - user = await _userManager.Users - .Include(u => u.UserPreferences) - .Include(u => u.AuthKeys) - .AsSplitQuery() - .SingleOrDefaultAsync(x => x.GetOpdsAuthKey() == loginDto.ApiKey); + user = await _unitOfWork.UserRepository.GetUserByAuthKey(loginDto.ApiKey); } else { @@ -518,7 +514,8 @@ public class AccountController : BaseApiController } var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - if (!await _accountService.CanChangeAgeRestriction(user)) return BadRequest(await _localizationService.Translate(UserId, "permission-denied")); + var hasRole = await _accountService.CanChangeAgeRestriction(user); + if (!hasRole) return BadRequest(await _localizationService.Translate(UserId, "permission-denied")); user.AgeRestriction = isAdmin ? AgeRating.NotApplicable : dto.AgeRating; user.AgeRestrictionIncludeUnknowns = isAdmin || dto.IncludeUnknowns; @@ -1285,7 +1282,7 @@ public class AccountController : BaseApiController _unitOfWork.UserRepository.Delete(authKey); await _unitOfWork.CommitAsync(); - await _eventHub.SendMessageToAsync(MessageFactory.AuthKeyUpdate, MessageFactory.AuthKeyDeletedEvent(authKeyId), UserId); + await _eventHub.SendMessageToAsync(MessageFactory.AuthKeyDeleted, MessageFactory.AuthKeyDeletedEvent(authKeyId), UserId); return Ok(); } diff --git a/API/Controllers/DeprecatedController.cs b/API/Controllers/DeprecatedController.cs index a9fed3ba7..5792c96b2 100644 --- a/API/Controllers/DeprecatedController.cs +++ b/API/Controllers/DeprecatedController.cs @@ -266,7 +266,91 @@ public class DeprecatedController : BaseApiController { var userId = User.IsInRole(PolicyConstants.AdminRole) ? 0 : UserId; return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, userId)); + } + /// + /// Quick Reads are series that should be readable in less than 10 in total and are not Ongoing in release. + /// + /// Library to restrict series to + /// Pagination + /// + [HttpGet("recommended/quick-reads")] + public async Task>> GetQuickReads(int libraryId, [FromQuery] UserParams? userParams) + { + userParams ??= UserParams.Default; + var series = await _unitOfWork.SeriesRepository.GetQuickReads(UserId, libraryId, userParams); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } + + /// + /// Quick Catchup Reads are series that should be readable in less than 10 in total and are Ongoing in release. + /// + /// Library to restrict series to + /// + /// + [HttpGet("recommended/quick-catchup-reads")] + public async Task>> GetQuickCatchupReads(int libraryId, [FromQuery] UserParams? userParams) + { + userParams ??= UserParams.Default; + var series = await _unitOfWork.SeriesRepository.GetQuickCatchupReads(UserId, libraryId, userParams); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } + + /// + /// Highly Rated based on other users ratings. Will pull series with ratings > 4.0, weighted by count of other users. + /// + /// Library to restrict series to + /// Pagination + /// + [HttpGet("recommended/highly-rated")] + public async Task>> GetHighlyRated(int libraryId, [FromQuery] UserParams? userParams) + { + var userId = UserId; + userParams ??= UserParams.Default; + var series = await _unitOfWork.SeriesRepository.GetHighlyRated(userId, libraryId, userParams); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } + + /// + /// Chooses a random genre and shows series that are in that without reading progress + /// + /// Library to restrict series to + /// Genre Id + /// Pagination + /// + [HttpGet("recommended/more-in")] + public async Task>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams? userParams) + { + var userId = UserId; + + userParams ??= UserParams.Default; + var series = await _unitOfWork.SeriesRepository.GetMoreIn(userId, libraryId, genreId, userParams); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } + + /// + /// Series that are fully read by the user in no particular order + /// + /// Library to restrict series to + /// Pagination + /// + [HttpGet("recommended/rediscover")] + public async Task>> GetRediscover(int libraryId, [FromQuery] UserParams? userParams) + { + userParams ??= UserParams.Default; + var series = await _unitOfWork.SeriesRepository.GetRediscover(UserId, libraryId, userParams); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); } } diff --git a/API/Controllers/KoreaderController.cs b/API/Controllers/KoreaderController.cs index dd0c38ebb..42b46ad6d 100644 --- a/API/Controllers/KoreaderController.cs +++ b/API/Controllers/KoreaderController.cs @@ -21,16 +21,11 @@ namespace API.Controllers; /// public class KoreaderController : BaseApiController { - private readonly IUnitOfWork _unitOfWork; - private readonly ILocalizationService _localizationService; private readonly IKoreaderService _koreaderService; private readonly ILogger _logger; - public KoreaderController(IUnitOfWork unitOfWork, ILocalizationService localizationService, - IKoreaderService koreaderService, ILogger logger) + public KoreaderController(IKoreaderService koreaderService, ILogger logger) { - _unitOfWork = unitOfWork; - _localizationService = localizationService; _koreaderService = koreaderService; _logger = logger; } diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 4fdedb471..318144e6a 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -234,13 +234,10 @@ public class LibraryController : BaseApiController [HttpGet("user-libraries")] public async Task>> GetLibrariesForUser(int userId) { - var ownUserName = Username!; - if (string.IsNullOrEmpty(ownUserName)) return Unauthorized(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null || string.IsNullOrEmpty(user.UserName)) return BadRequest(); - var ownLibraries = await GetLibrariesForUser(ownUserName); + var ownLibraries = await GetLibrariesForUser(Username!); var otherLibraries = await GetLibrariesForUser(user.UserName); var sharedLibraries = otherLibraries.IntersectBy(ownLibraries.Select(l => l.Id), l => l.Id).ToList(); @@ -448,26 +445,28 @@ public class LibraryController : BaseApiController [HttpPost("scan-folder")] public async Task ScanFolder(ScanFolderDto dto) { - var userId = await _unitOfWork.UserRepository.GetUserIdByAuthKeyAsync(dto.ApiKey); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + var user = await _unitOfWork.UserRepository.GetUserByAuthKey(dto.ApiKey); if (user == null) return Unauthorized(); // Validate user has Admin privileges var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); if (!isAdmin) return BadRequest("API key must belong to an admin"); - if (dto.FolderPath.Contains("..")) return BadRequest(await _localizationService.Translate(user.Id, "invalid-path")); + if (dto.FolderPath.Contains("..")) + { + return BadRequest(await _localizationService.Translate(UserId, "invalid-path")); + } - dto.FolderPath = Services.Tasks.Scanner.Parser.Parser.NormalizePath(dto.FolderPath); + dto.FolderPath = Parser.NormalizePath(dto.FolderPath); var libraryFolder = (await _unitOfWork.LibraryRepository.GetLibraryDtosAsync()) .SelectMany(l => l.Folders) .Distinct() - .Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath); + .Select(Parser.NormalizePath); var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, [dto.FolderPath]); - _taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath); + _taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath, dto.AbortOnNoSeriesMatch); return Ok(); } diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index 8cf4a3ab0..8129e7e81 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -269,7 +269,7 @@ public class MetadataController(IUnitOfWork unitOfWork, IExternalMetadataService ret.Recommendations.ExternalSeries = []; } - if (ret.Recommendations != null && user != null) + if (ret?.Recommendations != null && user != null) { ret.Recommendations.OwnedSeries ??= []; await unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, ret.Recommendations.OwnedSeries); diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index a30c8f26f..dc3e19ee7 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -260,14 +260,14 @@ public class ReaderController : BaseApiController if (info.IsSpecial) { info.Subtitle = Path.GetFileNameWithoutExtension(info.FileName); - } else if (!info.IsSpecial && info.VolumeNumber.Equals(Parser.LooseLeafVolume)) + } else if (!info.IsSpecial && Parser.IsLooseLeafVolume(info.VolumeNumber)) { info.Subtitle = ReaderService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber; } else { info.Subtitle = await _localizationService.Translate(UserId, "volume-num", info.VolumeNumber); - if (!info.ChapterNumber.Equals(Parser.DefaultChapter)) + if (!Parser.IsDefaultChapter(info.ChapterNumber)) { info.Subtitle += " " + ReaderService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber; diff --git a/API/Controllers/RecommendedController.cs b/API/Controllers/RecommendedController.cs deleted file mode 100644 index a71896813..000000000 --- a/API/Controllers/RecommendedController.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Threading.Tasks; -using API.Data; -using API.DTOs; -using API.Extensions; -using API.Helpers; -using Microsoft.AspNetCore.Mvc; - -namespace API.Controllers; - -#nullable enable - -public class RecommendedController : BaseApiController -{ - private readonly IUnitOfWork _unitOfWork; - - public const string CacheKey = "recommendation_"; - - public RecommendedController(IUnitOfWork unitOfWork) - { - _unitOfWork = unitOfWork; - } - - /// - /// Quick Reads are series that should be readable in less than 10 in total and are not Ongoing in release. - /// - /// Library to restrict series to - /// Pagination - /// - [HttpGet("quick-reads")] - public async Task>> GetQuickReads(int libraryId, [FromQuery] UserParams? userParams) - { - userParams ??= UserParams.Default; - var series = await _unitOfWork.SeriesRepository.GetQuickReads(UserId, libraryId, userParams); - - Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); - return Ok(series); - } - - /// - /// Quick Catchup Reads are series that should be readable in less than 10 in total and are Ongoing in release. - /// - /// Library to restrict series to - /// - /// - [HttpGet("quick-catchup-reads")] - public async Task>> GetQuickCatchupReads(int libraryId, [FromQuery] UserParams? userParams) - { - userParams ??= UserParams.Default; - var series = await _unitOfWork.SeriesRepository.GetQuickCatchupReads(UserId, libraryId, userParams); - - Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); - return Ok(series); - } - - /// - /// Highly Rated based on other users ratings. Will pull series with ratings > 4.0, weighted by count of other users. - /// - /// Library to restrict series to - /// Pagination - /// - [HttpGet("highly-rated")] - public async Task>> GetHighlyRated(int libraryId, [FromQuery] UserParams? userParams) - { - var userId = UserId; - userParams ??= UserParams.Default; - var series = await _unitOfWork.SeriesRepository.GetHighlyRated(userId, libraryId, userParams); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); - Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); - return Ok(series); - } - - /// - /// Chooses a random genre and shows series that are in that without reading progress - /// - /// Library to restrict series to - /// Genre Id - /// Pagination - /// - [HttpGet("more-in")] - public async Task>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams? userParams) - { - var userId = UserId; - - userParams ??= UserParams.Default; - var series = await _unitOfWork.SeriesRepository.GetMoreIn(userId, libraryId, genreId, userParams); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); - - Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); - return Ok(series); - } - - /// - /// Series that are fully read by the user in no particular order - /// - /// Library to restrict series to - /// Pagination - /// - [HttpGet("rediscover")] - public async Task>> GetRediscover(int libraryId, [FromQuery] UserParams? userParams) - { - userParams ??= UserParams.Default; - var series = await _unitOfWork.SeriesRepository.GetRediscover(UserId, libraryId, userParams); - - Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); - return Ok(series); - } - -} diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 2b96e0690..eda507271 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -258,18 +258,24 @@ public class SeriesController : BaseApiController /// /// /// + /// Optional user id to request the OnDeck for someone else. They must have profile sharing enabled when doing so /// This is not in use + /// /// [HttpPost("all-v2")] + [ProfilePrivacy(allowMissingUserId: true)] public async Task>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, - [FromQuery] int libraryId = 0, [FromQuery] QueryContext context = QueryContext.None) + [FromQuery] int? userId = null, [FromQuery] int libraryId = 0, [FromQuery] QueryContext context = QueryContext.None) { - var userId = UserId; + var seriesForUser = userId ?? UserId; + + filterDto.Statements.AddRange(await _seriesService.GetProfilePrivacyStatements(seriesForUser, UserId)); + var series = - await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto, context); + await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(seriesForUser, userParams, filterDto, context); // Apply progress/rating information (I can't work out how to do this in initial query) - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(seriesForUser, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index f2d18e74f..8099f70af 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -89,6 +89,18 @@ public class StatsController( return Ok(await statService.GetPopularSeries()); } + /// + /// Gets the top 5 most popular reading lists. Counts a reading list as active if a user has read at least some + /// + /// + [Authorize(PolicyGroups.AdminPolicy)] + [HttpGet("popular-reading-list")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Statistics)] + public async Task>>> GetPopularReadingList() + { + return Ok(await statService.GetPopularReadingList()); + } + [Authorize(PolicyGroups.AdminPolicy)] [HttpGet("popular-genres")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Statistics)] @@ -106,23 +118,13 @@ public class StatsController( } [Authorize(PolicyGroups.AdminPolicy)] - [HttpGet("popular-authors")] + [HttpGet("popular-people")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Statistics)] - public async Task>>> GetPopularAuthors() + public async Task>>> GetPopularPeople(PersonRole role) { - return Ok(await statService.GetPopularPerson(PersonRole.Writer)); + return Ok(await statService.GetPopularPerson(role)); } - [Authorize(PolicyGroups.AdminPolicy)] - [HttpGet("popular-artists")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Statistics)] - public async Task>>> GetPopularArtists() - { - return Ok(await statService.GetPopularPerson(PersonRole.CoverArtist)); - } - - - /// /// Top 5 most active readers for the given timeframe /// diff --git a/API/Controllers/StreamController.cs b/API/Controllers/StreamController.cs index 81565304e..35dab90c8 100644 --- a/API/Controllers/StreamController.cs +++ b/API/Controllers/StreamController.cs @@ -84,13 +84,13 @@ public class StreamController : BaseApiController /// /// Validates the external source by host is unique (for this user) /// - /// + /// /// - [HttpGet("external-source-exists")] + [HttpPost("external-source-exists")] [DisallowRole(PolicyConstants.ReadOnlyRole)] - public async Task> ExternalSourceExists(string host, string name, string apiKey) + public async Task> ExternalSourceExists(ExternalSourceDto dto) { - return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(UserId, name, host, apiKey)); + return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(UserId, dto.Name, dto.Host, dto.ApiKey)); } /// diff --git a/API/Controllers/WantToReadController.cs b/API/Controllers/WantToReadController.cs index f5b4035c3..8e78f49e8 100644 --- a/API/Controllers/WantToReadController.cs +++ b/API/Controllers/WantToReadController.cs @@ -29,13 +29,15 @@ public class WantToReadController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IScrobblingService _scrobblingService; private readonly ILocalizationService _localizationService; + private readonly ISeriesService _seriesService; public WantToReadController(IUnitOfWork unitOfWork, IScrobblingService scrobblingService, - ILocalizationService localizationService) + ILocalizationService localizationService, ISeriesService seriesService) { _unitOfWork = unitOfWork; _scrobblingService = scrobblingService; _localizationService = localizationService; + _seriesService = seriesService; } /// @@ -52,6 +54,9 @@ public class WantToReadController : BaseApiController var wantToReadForUser = userId ?? UserId; userParams ??= new UserParams(); + // Add profile privacy filter + filterDto.Statements.AddRange(await _seriesService.GetProfilePrivacyStatements(wantToReadForUser, UserId)); + var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserV2Async(wantToReadForUser, userParams, filterDto); Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); diff --git a/API/DTOs/Filtering/v2/FilterV2Dto.cs b/API/DTOs/Filtering/v2/FilterV2Dto.cs index a247a17a6..dc30b26e2 100644 --- a/API/DTOs/Filtering/v2/FilterV2Dto.cs +++ b/API/DTOs/Filtering/v2/FilterV2Dto.cs @@ -16,7 +16,7 @@ public sealed record FilterV2Dto /// The name of the filter /// public string? Name { get; set; } - public ICollection Statements { get; set; } = []; + public List Statements { get; set; } = []; public FilterCombination Combination { get; set; } = FilterCombination.And; public SortOptions? SortOptions { get; set; } diff --git a/API/DTOs/MangaFileDto.cs b/API/DTOs/MangaFileDto.cs index 23bb37467..645c9ad32 100644 --- a/API/DTOs/MangaFileDto.cs +++ b/API/DTOs/MangaFileDto.cs @@ -25,5 +25,9 @@ public sealed record MangaFileDto /// File extension /// public string? Extension { get; set; } + /// + /// A hash of the document using Koreader's unique hashing algorithm + /// + public string? KoreaderHash { get; set; } } diff --git a/API/DTOs/ScanFolderDto.cs b/API/DTOs/ScanFolderDto.cs index 141f7f0b5..bfa669eec 100644 --- a/API/DTOs/ScanFolderDto.cs +++ b/API/DTOs/ScanFolderDto.cs @@ -14,4 +14,9 @@ public sealed record ScanFolderDto /// /// JSON cannot accept /, so you may need to use // escaping on paths public string FolderPath { get; set; } = default!; + + /// + /// If true, only runs the scan if a matches series is found. I.e. prevent library scans + /// + public bool AbortOnNoSeriesMatch { get; set; } = false; } diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index d7a471e5c..dbc894412 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -108,6 +108,10 @@ public sealed record ServerSettingDto /// The Version of Kavita on the first run /// public string? FirstInstallVersion { get; set; } + /// + /// How many times Kavita has hit the Stats API + /// + public int StatsApiHits { get; set; } /// /// Are at least some basics filled in diff --git a/API/DTOs/Stats/V3/ServerInfoV3Dto.cs b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs index b19d173d9..464179ca7 100644 --- a/API/DTOs/Stats/V3/ServerInfoV3Dto.cs +++ b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs @@ -45,11 +45,11 @@ public sealed record ServerInfoV3Dto /// /// Milliseconds to open a random archive (zip/cbz) for reading /// - public long TimeToOpeCbzMs { get; set; } + public long? TimeToOpeCbzMs { get; set; } /// /// Number of pages for said archive (zip/cbz) /// - public long TimeToOpenCbzPages { get; set; } + public long? TimeToOpenCbzPages { get; set; } /// /// Milliseconds to get a response from KavitaStats API /// diff --git a/API/Data/ManualMigrations/v0.8.9/MigrateFormatToActivityData.cs b/API/Data/ManualMigrations/v0.8.9/MigrateFormatToActivityData.cs index ef6087f56..1ce4d9340 100644 --- a/API/Data/ManualMigrations/v0.8.9/MigrateFormatToActivityData.cs +++ b/API/Data/ManualMigrations/v0.8.9/MigrateFormatToActivityData.cs @@ -10,13 +10,13 @@ namespace API.Data.ManualMigrations; /// /// v0.8.8.16 - Needed to add Format to the ActivityData to optimize a query /// -public class MigrateFormatToActivityData : ManualMigration +public class MigrateFormatToActivityDataV2 : ManualMigration { - protected override string MigrationName => nameof(MigrateFormatToActivityData); + protected override string MigrationName => nameof(MigrateFormatToActivityDataV2); protected override async Task ExecuteAsync(DataContext context, ILogger logger) { var activitiesWithoutFormat = await context.AppUserReadingSessionActivityData - .Where(d => d.Format == MangaFormat.Unknown) + .Where(d => d.Format == MangaFormat.Unknown || d.Format == 0) .Select(d => d.ChapterId) .Distinct() .ToListAsync(); @@ -47,7 +47,7 @@ public class MigrateFormatToActivityData : ManualMigration foreach (var chapterIdBatch in activitiesWithoutFormat.Chunk(batchSize)) { var activities = await context.AppUserReadingSessionActivityData - .Where(d => d.Format == MangaFormat.Unknown && chapterIdBatch.Contains(d.ChapterId)) + .Where(d => (d.Format == MangaFormat.Unknown || d.Format == 0) && chapterIdBatch.Contains(d.ChapterId)) .ToListAsync(); foreach (var activity in activities) diff --git a/API/Data/ManualMigrations/v0.8.9/MigrateIncorrectUtcMidnightRollovers.cs b/API/Data/ManualMigrations/v0.8.9/MigrateIncorrectUtcMidnightRollovers.cs new file mode 100644 index 000000000..e4ff54c45 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.9/MigrateIncorrectUtcMidnightRollovers.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Data.Misc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +public class MigrateIncorrectUtcMidnightRollovers: ManualMigration +{ + private const int BatchSize = 1000; + protected override string MigrationName { get; } = nameof(MigrateIncorrectUtcMidnightRollovers); + + protected override async Task ExecuteAsync(DataContext context, ILogger logger) + { + var skip = 0; + var correctedEntries = 0; + + while (true) + { + var batch = await context.AppUserReadingSession + .Where(s => s.EndTime != null && s.EndTimeUtc != null) + .OrderBy(s => s.Id) + .Skip(skip) + .Take(BatchSize) + .ToListAsync(); + + if (batch.Count == 0) + break; + + foreach (var session in batch) + { + if (session.EndTimeUtc == null || session.EndTime == null) + { + continue; + } + + var wantedUtc = TimeZoneInfo.ConvertTimeToUtc(session.EndTime.Value); + if (session.EndTimeUtc != wantedUtc) + { + session.EndTimeUtc = wantedUtc; + context.Entry(session).State = EntityState.Modified; + + correctedEntries++; + } + } + + await context.SaveChangesAsync(); + + skip += BatchSize; + } + + logger.LogInformation("Corrected {Count} session records", correctedEntries); + } +} diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index a6a7cebb1..41c103c0e 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -446,18 +446,18 @@ public class SeriesRepository : ISeriesRepository .Where(b => EF.Functions.Like(b.Series.Name, $"%{searchQuery}%") || (b.Series.OriginalName != null && EF.Functions.Like(b.Series.OriginalName, $"%{searchQuery}%")) || (b.Series.LocalizedName != null && EF.Functions.Like(b.Series.LocalizedName, $"%{searchQuery}%"))) - .OrderBy(b => b.Series.NormalizedName.Length) - .ThenBy(b => b.Series.NormalizedName) - .Select(b => new BookmarkSearchResultDto + .GroupBy(b => new { b.SeriesId, b.Series.LibraryId, b.Series.Name, b.Series.LocalizedName, b.Series.NormalizedName }) + .OrderBy(g => g.Key.NormalizedName.Length) + .ThenBy(g => g.Key.NormalizedName) + .Select(g => new BookmarkSearchResultDto { - SeriesName = b.Series.Name, - LocalizedSeriesName = b.Series.LocalizedName, - LibraryId = b.Series.LibraryId, - SeriesId = b.SeriesId, - ChapterId = b.ChapterId, - VolumeId = b.VolumeId + SeriesName = g.Key.Name, + LocalizedSeriesName = g.Key.LocalizedName, + LibraryId = g.Key.LibraryId, + SeriesId = g.Key.SeriesId, + ChapterId = g.First().ChapterId, + VolumeId = g.First().VolumeId }) - .Distinct() .Take(maxRecords) .ToListAsync(); diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 3240c8c3e..43e037866 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -88,6 +88,7 @@ public interface IUserRepository Task GetUserDtoById(int userId); Task GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None); Task GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None); + Task GetUserByAuthKey(string authKey, AppUserIncludes includeFlags = AppUserIncludes.None); Task GetUserIdByUsernameAsync(string username); Task> GetAllBookmarksByIds(IList bookmarkIds); Task GetUserByEmailAsync(string email, AppUserIncludes includes = AppUserIncludes.None); @@ -259,6 +260,18 @@ public class UserRepository : IUserRepository .FirstOrDefaultAsync(); } + public async Task GetUserByAuthKey(string authKey, AppUserIncludes includeFlags = AppUserIncludes.None) + { + if (string.IsNullOrEmpty(authKey)) return null; + + return await _context.AppUserAuthKey + .Where(ak => ak.Key == authKey) + .HasNotExpired() + .Select(ak => ak.AppUser) + .Includes(includeFlags) + .FirstOrDefaultAsync(); + } + public async Task> GetAllBookmarksAsync() { return await _context.AppUserBookmark.ToListAsync(); diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index a9fded9f4..a7afc07af 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -422,6 +422,7 @@ public static class Seed new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"}, new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()}, new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)}, + new() {Key = ServerSettingKey.StatsApiHits, Value = "0"}, }.ToArray() ]; diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 4923390a2..43fc63ed2 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -205,6 +205,12 @@ public enum ServerSettingKey /// /// The resolution to render PDFs as when delivering them as images. /// - [Description("pdfRenderResolution")] + [Description("PdfRenderResolution")] PdfRenderResolution = 41, + /// + /// How many times Kavita has pinged the Stats API + /// + /// After a set amount, the Stats API will stop recording some information, like Average I/O time + [Description("StatsApiHits")] + StatsApiHits = 42 } diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index afcb23e97..2f1708226 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -23,7 +23,6 @@ public class MangaFile : IEntityDate /// /// A hash of the document using Koreader's unique hashing algorithm /// - /// KoreaderHash is only available for epub types public string? KoreaderHash { get; set; } /// /// Number of pages for the given file diff --git a/API/Helpers/Builders/MangaFileBuilder.cs b/API/Helpers/Builders/MangaFileBuilder.cs index ea3ff0c6d..480785d8f 100644 --- a/API/Helpers/Builders/MangaFileBuilder.cs +++ b/API/Helpers/Builders/MangaFileBuilder.cs @@ -67,7 +67,7 @@ public class MangaFileBuilder : IEntityBuilder /// Only applicable to Epubs public MangaFileBuilder WithHash() { - if (_mangaFile.Format != MangaFormat.Epub) return this; + //if (_mangaFile.Format != MangaFormat.Epub) return this; _mangaFile.KoreaderHash = KoreaderHelper.HashContents(_mangaFile.FilePath); diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 4601127a2..15a3d9c99 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -133,6 +133,9 @@ public class ServerSettingConverter : ITypeConverter, case ServerSettingKey.FirstInstallVersion: destination.FirstInstallVersion = row.Value; break; + case ServerSettingKey.StatsApiHits: + destination.StatsApiHits = int.Parse(row.Value); + break; case ServerSettingKey.OidcConfiguration: destination.OidcConfig = JsonSerializer.Deserialize(row.Value)!; break; diff --git a/API/Helpers/KoreaderHelper.cs b/API/Helpers/KoreaderHelper.cs index b69a8c241..ef69443a9 100644 --- a/API/Helpers/KoreaderHelper.cs +++ b/API/Helpers/KoreaderHelper.cs @@ -24,7 +24,7 @@ public static class KoreaderHelper /// The path to the file to hash public static string HashContents(string filePath) { - if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath) || !Parser.IsEpub(filePath)) + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) { return null; } diff --git a/API/Helpers/PdfComicInfoExtractor.cs b/API/Helpers/PdfComicInfoExtractor.cs index 861bb564f..f0e0b1832 100644 --- a/API/Helpers/PdfComicInfoExtractor.cs +++ b/API/Helpers/PdfComicInfoExtractor.cs @@ -116,7 +116,7 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor info.Volume = MaybeGetMetadata(metadata, "Volume") ?? string.Empty; // If this is a single book and not a collection, set publication status to Completed - if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) + if (string.IsNullOrEmpty(info.Volume) && Parser.IsLooseLeafVolume(Parser.ParseVolume(filePath, LibraryType.Manga))) { info.Count = 1; } diff --git a/API/Middleware/Attribute/DisallowRoleAttribute.cs b/API/Middleware/Attribute/DisallowRoleAttribute.cs index 7a95a0b3b..7cc1a7be2 100644 --- a/API/Middleware/Attribute/DisallowRoleAttribute.cs +++ b/API/Middleware/Attribute/DisallowRoleAttribute.cs @@ -39,8 +39,6 @@ public class DisallowRoleAttribute(params string[] roles) : Attribute, IAsyncAut Content = message, ContentType = "text/plain" }; - - } } } diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index bf8f8ee93..6bffb864c 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -184,7 +184,7 @@ public partial class AccountService : IAccountService var roles = await _userManager.GetRolesAsync(user); if (roles.Contains(PolicyConstants.ReadOnlyRole)) return false; - return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole); + return roles.Contains(PolicyConstants.ChangeRestrictionRole) || roles.Contains(PolicyConstants.AdminRole); } public async Task ChangeIdentityProvider(int actingUserId, AppUser user, IdentityProvider identityProvider) diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index eb034a6f5..c15276819 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -661,7 +661,7 @@ public partial class BookService : IBookService // If this is a single book and not a collection, set publication status to Completed if (string.IsNullOrEmpty(info.Volume) && - Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) + Parser.IsLooseLeafVolume(Parser.ParseVolume(filePath, LibraryType.Manga))) { info.Count = 1; } @@ -670,8 +670,7 @@ public partial class BookService : IBookService info.Writer = string.Join(",", epubBook?.Schema.Package.Metadata.Creators.Select(c => Parser.CleanAuthor(c.Creator)) ?? []); - var hasVolumeInSeries = !Parser.ParseVolume(info.Title, LibraryType.Manga) - .Equals(Parser.LooseLeafVolume); + var hasVolumeInSeries = !Parser.IsLooseLeafVolume(Parser.ParseVolume(info.Title, LibraryType.Manga)); if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series))) diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index ce20abb05..f2988a342 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -392,10 +392,10 @@ public class EmailService : IEmailService var emailOptions = new EmailOptionsDto() { - Subject = UpdatePlaceHolders("You've been invited to join {{InvitingUser}}'s Server", placeholders), + Subject = UpdatePlaceHolders("You've been invited to join {{InvitingUser}}'s Kavita Server", placeholders), Template = EmailConfirmTemplate, Body = UpdatePlaceHolders(await GetEmailBody(EmailConfirmTemplate), placeholders), - Preheader = UpdatePlaceHolders("You've been invited to join {{InvitingUser}}'s Server", placeholders), + Preheader = UpdatePlaceHolders("You've been invited to join {{InvitingUser}}'s Kavita Server", placeholders), ToEmails = new List() { data.EmailAddress diff --git a/API/Services/EntityDisplayService.cs b/API/Services/EntityDisplayService.cs index a7985415d..f0e653e32 100644 --- a/API/Services/EntityDisplayService.cs +++ b/API/Services/EntityDisplayService.cs @@ -66,8 +66,8 @@ public class EntityDisplayService(ILocalizationService localizationService, IUni return (firstChapter.TitleName, neededRename); } - // Fallback: extract from Range if it's not a loose leaf marker - if (!firstChapter.Range.Equals(Parser.LooseLeafVolume)) + // Fallback: extract from Range if it's not a loose-leaf marker + if (!Parser.IsLooseLeafVolume(firstChapter.Range)) { var title = Path.GetFileNameWithoutExtension(firstChapter.Range); if (!string.IsNullOrEmpty(title)) @@ -123,7 +123,7 @@ public class EntityDisplayService(ILocalizationService localizationService, IUni /// /// Smart method that generates display name for a chapter, automatically detecting if it needs - /// to fetch the volume name instead (for loose leaf volumes in book libraries). + /// to fetch the volume name instead (for loose-leaf volumes in book libraries). /// This is the recommended method for most scenarios as it handles internal encodings. /// /// The chapter to generate a name for @@ -132,8 +132,8 @@ public class EntityDisplayService(ILocalizationService localizationService, IUni /// User-friendly display name public async Task GetEntityDisplayName( ChapterDto chapter, int userId, EntityDisplayOptions options) { - // Detect if this is a loose leaf volume that should be displayed as a volume name - if (chapter.Title == Parser.LooseLeafVolume) + // Detect if this is a loose-leaf volume that should be displayed as a volume name + if (Parser.IsLooseLeafVolume(chapter.Title)) { var volume = await unitOfWork.VolumeRepository.GetVolumeDtoAsync(chapter.VolumeId, userId); if (volume != null) diff --git a/API/Services/KoreaderService.cs b/API/Services/KoreaderService.cs index 209157113..b5f9d38ef 100644 --- a/API/Services/KoreaderService.cs +++ b/API/Services/KoreaderService.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Tasks; using API.Data; using API.DTOs.Koreader; @@ -59,6 +60,7 @@ public class KoreaderService : IKoreaderService ChapterId = file.ChapterId, VolumeId = chapterDto.VolumeId, SeriesId = volumeDto.SeriesId, + PageNum = int.Parse(koreaderBookDto.progress) }; } // Update the bookScrollId if possible @@ -82,14 +84,17 @@ public class KoreaderService : IKoreaderService var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(bookHash); - if (file == null) throw new KavitaException(await _localizationService.Translate(userId, "file-missing")); var progressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId); var originalScrollId = progressDto?.BookScrollId; - var koreaderProgress = KoreaderHelper.GetKoreaderPosition(progressDto); - _logger.LogDebug("Converting KOReader progress from {KavitaProgress} to {KOReaderProgress}", originalScrollId?.Sanitize() ?? string.Empty, progressDto?.BookScrollId?.Sanitize() ?? string.Empty); + var koreaderProgress = $"{progressDto?.PageNum ?? 0}"; // Non-epubs will just encode as a simple number + if (!string.IsNullOrEmpty(originalScrollId)) + { + koreaderProgress = KoreaderHelper.GetKoreaderPosition(progressDto); + _logger.LogDebug("Converting KOReader progress from {KavitaProgress} to {KOReaderProgress}", originalScrollId?.Sanitize() ?? string.Empty, progressDto?.BookScrollId?.Sanitize() ?? string.Empty); + } return new KoreaderBookDtoBuilder(bookHash) .WithProgress(koreaderProgress) diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index ed4d8a9f7..0010bca82 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -191,14 +191,7 @@ public class ExternalMetadataService : IExternalMetadataService _logger.LogDebug("Fetching Kavita+ for MAL Stacks for user {UserName}", user.MalUserName); var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var result = await _kavitaPlusApiService.GetMalStacks(user.MalUserName, license); - - if (result == null) - { - return ArraySegment.Empty; - } - - return result; + return await _kavitaPlusApiService.GetMalStacks(user.MalUserName, license); } catch (Exception ex) { diff --git a/API/Services/Reading/ReadingSessionService.cs b/API/Services/Reading/ReadingSessionService.cs index 83c0884f3..9bbcb5a40 100644 --- a/API/Services/Reading/ReadingSessionService.cs +++ b/API/Services/Reading/ReadingSessionService.cs @@ -305,6 +305,7 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, WordsRead = 0, ClientInfo = null, DeviceIds = [], + Format = format, }; } @@ -460,7 +461,7 @@ public sealed class ReadingSessionService : IReadingSessionService, IDisposable, private async Task OnMidnightRolloverAsync() { var endOfYesterday = DateTime.Now.Date.AddTicks(-1); // 23:59:59.9999999 - var endOfYesterdayUtc = DateTime.UtcNow.Date.AddTicks(-1); // 23:59:59.9999999 + var endOfYesterdayUtc = TimeZoneInfo.ConvertTimeToUtc(endOfYesterday); var sessionsToClose = _activeSessions.ToArray(); if (sessionsToClose.Length > 0) diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index c20c7919b..5263d270b 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -88,13 +88,13 @@ public class ReadingListService : IReadingListService public static string FormatTitle(ReadingListItemDto item) { var title = string.Empty; - if (item.ChapterNumber == Parser.DefaultChapter && item.VolumeNumber != Parser.LooseLeafVolume) { + if (Parser.IsDefaultChapter(item.ChapterNumber) && !Parser.IsLooseLeafVolume(item.VolumeNumber)) { title = $"Volume {item.VolumeNumber}"; } if (item.SeriesFormat == MangaFormat.Epub) { var specialTitle = Parser.CleanSpecialTitle(item.ChapterNumber); - if (specialTitle == Parser.DefaultChapter) + if (Parser.IsDefaultChapter(specialTitle)) { if (!string.IsNullOrEmpty(item.ChapterTitleName)) { @@ -123,7 +123,7 @@ public class ReadingListService : IReadingListService if (title != string.Empty) return title; // item.ChapterNumber is Range - if (item.ChapterNumber == Parser.DefaultChapter && + if (Parser.IsDefaultChapter(item.ChapterNumber) && !string.IsNullOrEmpty(item.ChapterTitleName)) { title = item.ChapterTitleName; diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index fbc257fd0..fd0e7e665 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -42,7 +42,7 @@ public interface ISeriesService Task FormatChapterName(int userId, LibraryType libraryType, bool withHash = false); Task GetEstimatedChapterCreationDate(int seriesId, int userId); Task> GetCurrentlyReading(int userId, int requestingUserId, UserParams userParams); - + Task> GetProfilePrivacyStatements(int userId, int requestingUserId); } public class SeriesService : ISeriesService @@ -627,7 +627,7 @@ public class SeriesService : ISeriesService // } if (string.IsNullOrEmpty(firstChapter.TitleName)) { - if (firstChapter.Range.Equals(Parser.LooseLeafVolume)) return false; + if (Parser.IsLooseLeafVolume(firstChapter.Range)) return false; var title = Path.GetFileNameWithoutExtension(firstChapter.Range); if (string.IsNullOrEmpty(title)) return false; volume.Name += $" - {title}"; // OPDS smart list 7 (just pdfs) triggered this @@ -962,9 +962,6 @@ public class SeriesService : ISeriesService { var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - var socialPreferences = await _unitOfWork.UserRepository.GetSocialPreferencesForUser(userId); - var requestingUser = (await _unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId))!; - var filter = new FilterV2Dto { Combination = FilterCombination.And, @@ -995,28 +992,46 @@ public class SeriesService : ISeriesService ], }; - if (userId == requestingUserId) - return await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filter); + filter.Statements.AddRange(await GetProfilePrivacyStatements(userId, requestingUserId)); + + return await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filter); + } + + public async Task> GetProfilePrivacyStatements(int userId, int requestingUserId) + { + if (userId == requestingUserId) return []; + + var socialPreferences = await _unitOfWork.UserRepository.GetSocialPreferencesForUser(userId); + var requestingUser = (await _unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId))!; var librariesUser = await _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId); var librariesRequestingUser = await _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(requestingUserId); - var libIds = librariesRequestingUser.Intersect(librariesUser).Except(socialPreferences.SocialLibraries); + var libIds = librariesRequestingUser.Intersect(librariesUser); + if (socialPreferences.SocialLibraries.Count > 0) + { + libIds = libIds.Intersect(socialPreferences.SocialLibraries); + } + var libraries = libIds.Select(id => id.ToString()); var ageRating = socialPreferences.SocialMaxAgeRating < requestingUser.AgeRestriction ? socialPreferences.SocialMaxAgeRating : requestingUser.AgeRestriction; var includeUnknowns = socialPreferences.SocialIncludeUnknowns && requestingUser.AgeRestrictionIncludeUnknowns; - filter.Statements.Add(new FilterStatementDto - { - Comparison = FilterComparison.Contains, - Field = FilterField.Libraries, - Value = string.Join(",", libraries), - }); + List filters = + [ + new() + { + Comparison = FilterComparison.Contains, + Field = FilterField.Libraries, + Value = string.Join(",", libraries), + } + + ]; if (!includeUnknowns) { - filter.Statements.Add(new FilterStatementDto + filters.Add(new FilterStatementDto { Comparison = FilterComparison.NotEqual, Field = FilterField.AgeRating, @@ -1026,7 +1041,7 @@ public class SeriesService : ISeriesService if (ageRating != AgeRating.NotApplicable) { - filter.Statements.Add(new FilterStatementDto + filters.Add(new FilterStatementDto { Comparison = FilterComparison.LessThanEqual, Field = FilterField.AgeRating, @@ -1034,7 +1049,7 @@ public class SeriesService : ISeriesService }); } - return await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filter); + return filters; } private static double ExponentialSmoothing(IList data, double alpha) diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs index 96f86502a..9f961ee68 100644 --- a/API/Services/SettingsService.cs +++ b/API/Services/SettingsService.cs @@ -405,6 +405,7 @@ public class SettingsService : ISettingsService setting.Value = ((int)updateSettingsDto.CoverImageSize).ToString(); _unitOfWork.SettingsRepository.Update(setting); } + if (setting.Key == ServerSettingKey.PdfRenderResolution && ((int)updateSettingsDto.PdfRenderResolution).ToString() != setting.Value) { diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 12c94268a..547149c3d 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -7,6 +7,7 @@ using API.Data.ManualMigrations; using API.DTOs; using API.DTOs.Metadata; using API.DTOs.Person; +using API.DTOs.ReadingLists; using API.DTOs.Statistics; using API.DTOs.Stats; using API.DTOs.Stats.V3.ClientDevice; @@ -25,6 +26,8 @@ using Microsoft.Extensions.Logging; namespace API.Services; #nullable enable +internal sealed record UserReadCount(int ReadingListId, int AppUserId, int ChaptersRead); + public interface IStatisticService { Task GetServerStatistics(); @@ -34,6 +37,7 @@ public interface IStatisticService Task> GetPopularDecades(); Task>> GetPopularLibraries(); Task>> GetPopularSeries(); + Task>> GetPopularReadingList(int take = 5); Task>> GetPopularGenres(); Task>> GetPopularTags(); Task>> GetPopularPerson(PersonRole role); @@ -52,7 +56,7 @@ public interface IStatisticService Task GetClientTypeBreakdown(DateTime fromDateUtc); Task>> GetDeviceTypeCounts(DateTime fromDateUtc); Task GetReadingActivityGraphData(StatsFilterDto filter, int userId, int year, int requestingUserId); - Task GetReadingPaceForUser(StatsFilterDto filter, int userId, int year, bool booksOnly, int requestingUserID); + Task GetReadingPaceForUser(StatsFilterDto filter, int userId, int year, bool booksOnly, int requestingUserId); Task> GetGenreBreakdownForUser(StatsFilterDto filter, int userId, int requestingUserId); Task> GetTagBreakdownForUser(StatsFilterDto filter, int userId, int requestingUserId); Task GetPageSpreadForUser(StatsFilterDto filter, int userId, int requestingUserId); @@ -241,6 +245,87 @@ public class StatisticService(ILogger logger, DataContext cont .ToList(); } + public async Task>> GetPopularReadingList(int take = 5) + { + var readingListChapterCounts = await context.ReadingList + .Where(rl => rl.Promoted) + .Select(rl => new + { + ReadingListId = rl.Id, + TotalChapters = rl.Items.Count + }) + .Where(x => x.TotalChapters > 0) + .ToDictionaryAsync(x => x.ReadingListId, x => x.TotalChapters); + + if (readingListChapterCounts.Count == 0) return []; + + var userReadCounts = await context.ReadingListItem + .Where(rli => readingListChapterCounts.Keys.Contains(rli.ReadingListId)) + .Join(context.AppUserProgresses, + rli => rli.ChapterId, + p => p.ChapterId, + (rli, p) => new { rli.ReadingListId, p.AppUserId, p.ChapterId, p.PagesRead }) + .Join(context.Chapter, + x => x.ChapterId, + c => c.Id, + (x, c) => new { x.ReadingListId, x.AppUserId, x.ChapterId, x.PagesRead, c.Pages }) + .Where(x => x.PagesRead >= x.Pages) + .GroupBy(x => new { x.ReadingListId, x.AppUserId }) + .Select(g => new UserReadCount( + g.Key.ReadingListId, + g.Key.AppUserId, + g.Select(x => x.ChapterId).Distinct().Count())) + .ToListAsync(); + + if (userReadCounts.Count == 0) return []; + + var counts = RankReadingLists(userReadCounts, readingListChapterCounts, take); + + if (counts.Count == 0) return []; + + var readingListIds = counts.Select(c => c.ReadingListId).ToList(); + var readingLists = await context.ReadingList + .Where(rl => readingListIds.Contains(rl.Id)) + .ProjectTo(mapper.ConfigurationProvider) + .ToDictionaryAsync(rl => rl.Id); + + return counts + .Where(c => readingLists.ContainsKey(c.ReadingListId)) + .Select(c => new StatCount + { + Value = readingLists[c.ReadingListId], + Count = c.Count + }) + .ToList(); + } + + private static List<(int ReadingListId, int Count)> RankReadingLists( + IReadOnlyList userReadCounts, + Dictionary readingListChapterCounts, + int take) + { + double[] thresholds = [0.5, 0.25, 0.0]; + + foreach (var threshold in thresholds) + { + var counts = userReadCounts + .Where(x => readingListChapterCounts.TryGetValue(x.ReadingListId, out var total) + && x.ChaptersRead >= Math.Ceiling(total * threshold)) + .GroupBy(x => x.ReadingListId) + .Select(g => (ReadingListId: g.Key, Count: g.Count())) + .OrderByDescending(x => x.Count) + .Take(take) + .ToList(); + + if (counts.Count >= take || threshold == 0.0) + { + return counts; + } + } + + return []; + } + /// /// Top 5 genres where there is some reading activity /// @@ -563,7 +648,7 @@ public class StatisticService(ILogger logger, DataContext cont Value = g.Key.Day, Format = g.Key.Format, Count = (long)g.Sum(a => - (double)(a.EndTimeUtc!.Value.Ticks - a.StartTimeUtc.Ticks) / TimeSpan.TicksPerHour) + (double)(a.EndTimeUtc!.Value.Ticks - a.StartTimeUtc.Ticks) / TimeSpan.TicksPerMinute) }) .OrderBy(d => d.Value) .ToListAsync(); @@ -724,7 +809,7 @@ public class StatisticService(ILogger logger, DataContext cont public async Task GetClientTypeBreakdown(DateTime fromDateUtc) { var devices = await context.ClientDevice - .Where(d => d.IsActive && d.FirstSeenUtc >= fromDateUtc) + .Where(d => d.IsActive && d.LastSeenUtc >= fromDateUtc) .Select(d => d.CurrentClientInfo.ClientType) .ToListAsync(); @@ -749,7 +834,7 @@ public class StatisticService(ILogger logger, DataContext cont public async Task>> GetDeviceTypeCounts(DateTime fromDateUtc) { var devices = await context.ClientDevice - .Where(d => d.IsActive && d.FirstSeenUtc >= fromDateUtc) + .Where(d => d.IsActive && d.LastSeenUtc >= fromDateUtc) .Select(d => d.CurrentClientInfo.DeviceType) .ToListAsync(); @@ -885,7 +970,7 @@ public class StatisticService(ILogger logger, DataContext cont filter.EndDate = filter.EndDate < DateTime.UtcNow ? filter.EndDate : DateTime.UtcNow; var activities = await context.AppUserReadingSessionActivityData - .ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true) + .ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true, onlyCompleted: false) .Select(a => new { a.PagesRead, @@ -894,7 +979,8 @@ public class StatisticService(ILogger logger, DataContext cont a.SeriesId, SeriesFormat = a.Series.Format, SessionStart = a.ReadingSession.StartTimeUtc, - SessionEnd = a.ReadingSession.EndTimeUtc + SessionEnd = a.ReadingSession.EndTimeUtc, + Finished = a.EndPage >= a.Chapter.Pages, }) .WhereIf(booksOnly, d => d.SeriesFormat == MangaFormat.Pdf || d.SeriesFormat == MangaFormat.Epub) .WhereIf(!booksOnly, d => d.SeriesFormat != MangaFormat.Pdf && d.SeriesFormat != MangaFormat.Epub) @@ -915,6 +1001,8 @@ public class StatisticService(ILogger logger, DataContext cont pagesRead += activity.PagesRead; wordsRead += activity.WordsRead; + if (!activity.Finished) continue; + if (activity.SeriesFormat is MangaFormat.Epub or MangaFormat.Pdf) booksRead.Add(activity.ChapterId); else @@ -1285,13 +1373,14 @@ public class StatisticService(ILogger logger, DataContext cont var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId); var chapterData = await context.AppUserReadingSessionActivityData - .ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true) + .ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true, onlyCompleted: false) .Select(d => new { d.ChapterId, FormatType = d.Chapter.Files.First().Format, d.PagesRead, d.WordsRead, + Finished = d.EndPage >= d.Chapter.Pages }) .ToListAsync(); @@ -1314,9 +1403,9 @@ public class StatisticService(ILogger logger, DataContext cont { ChapterId = g.Key, g.First().FormatType, - // Take max to handle potential duplicates with different values - PagesRead = g.Max(x => x.PagesRead), - WordsRead = g.Max(x => x.WordsRead) + PagesRead = g.Sum(x => x.PagesRead), + WordsRead = g.Sum(x => x.WordsRead), + Finished = g.Any(x => x.Finished) }) .ToList(); @@ -1334,6 +1423,8 @@ public class StatisticService(ILogger logger, DataContext cont pagesRead += ch.PagesRead; wordsRead += ch.WordsRead; + if (!ch.Finished) continue; + switch (ch.FormatType) { case MangaFormat.Pdf or MangaFormat.Epub: @@ -1581,14 +1672,6 @@ public class StatisticService(ILogger logger, DataContext cont var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId); var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId); - // It makes no sense to filter this in by month etc. Trim to year - filter.StartDate = filter.StartDate.HasValue - ? new DateTime(filter.StartDate.Value.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc) - : null; - filter.EndDate = filter.EndDate.HasValue - ? new DateTime(filter.EndDate.Value.Year, 12, 31, 23, 59, 59, 0, 0, DateTimeKind.Utc) - : null; - return await context.AppUserReadingSessionActivityData .ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true) .GroupBy(s => new {s.ReadingSession.CreatedUtc.Year, s.ReadingSession.CreatedUtc.Month}) diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index d2a967957..389825066 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -31,7 +31,7 @@ public interface ITaskScheduler void ScheduleUpdaterTasks(); Task ScheduleKavitaPlusTasks(); void ScanFolder(string folderPath, string originalPath, TimeSpan delay); - void ScanFolder(string folderPath); + void ScanFolder(string folderPath, bool abortOnNoSeriesMatch = false); Task ScanLibrary(int libraryId, bool force = false); Task ScanLibraries(bool force = false); void CleanupChapters(int[] chapterIds); @@ -391,7 +391,7 @@ public class TaskScheduler : ITaskScheduler BackgroundJob.Schedule(() => _scannerService.ScanFolder(normalizedFolder, normalizedOriginal), delay); } - public void ScanFolder(string folderPath) + public void ScanFolder(string folderPath, bool abortOnNoSeriesMatch = false) { var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath); if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty])) @@ -402,7 +402,7 @@ public class TaskScheduler : ITaskScheduler } _logger.LogInformation("Scheduling ScanFolder for {Folder}", normalizedFolder); - _scannerService.ScanFolder(normalizedFolder, string.Empty); + _scannerService.ScanFolder(normalizedFolder, string.Empty, abortOnNoSeriesMatch); } #endregion diff --git a/API/Services/Tasks/Scanner/Parser/BasicParser.cs b/API/Services/Tasks/Scanner/Parser/BasicParser.cs index 168ca7f01..11cb51bcd 100644 --- a/API/Services/Tasks/Scanner/Parser/BasicParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BasicParser.cs @@ -51,7 +51,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag var isSpecial = Parser.IsSpecial(fileName, type); // We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that // could cause a problem as Omake is a special term, but there is valid volume/chapter information. - if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && isSpecial) + if (Parser.IsDefaultChapter(ret.Chapters) && Parser.IsLooseLeafVolume(ret.Volumes) && isSpecial) { ret.IsSpecial = true; ParseFromFallbackFolders(filePath, rootPath, type, ref ret); // NOTE: This can cause some complications, we should try to be a bit less aggressive to fallback to folder @@ -108,7 +108,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag - if (ret.Volumes == Parser.LooseLeafVolume && ret.Chapters == Parser.DefaultChapter) + if (Parser.IsLooseLeafVolume(ret.Volumes) && Parser.IsDefaultChapter(ret.Chapters)) { ret.IsSpecial = true; } diff --git a/API/Services/Tasks/Scanner/Parser/BookParser.cs b/API/Services/Tasks/Scanner/Parser/BookParser.cs index 76cbf9211..89b142faa 100644 --- a/API/Services/Tasks/Scanner/Parser/BookParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BookParser.cs @@ -38,26 +38,27 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer } // This catches when original library type is Manga/Comic and when parsing with non - if (Parser.ParseVolume(info.Series, type) != Parser.LooseLeafVolume) + if (!Parser.IsLooseLeafVolume(Parser.ParseVolume(info.Series, type))) { - var hasVolumeInTitle = !Parser.ParseVolume(info.Title, type) - .Equals(Parser.LooseLeafVolume); - var hasVolumeInSeries = !Parser.ParseVolume(info.Series, type) - .Equals(Parser.LooseLeafVolume); + var parsedVolumeFromTitle = Parser.ParseVolume(info.Title, type); + var parsedVolumeFromSeries = Parser.ParseVolume(info.Series, type); + + var hasVolumeInTitle = !Parser.IsLooseLeafVolume(parsedVolumeFromTitle); + var hasVolumeInSeries = !Parser.IsLooseLeafVolume(parsedVolumeFromSeries); if (string.IsNullOrEmpty(info.ComicInfo?.Volume) && hasVolumeInTitle && (hasVolumeInSeries || string.IsNullOrEmpty(info.Series))) { // NOTE: I'm not sure the comment is true. I've never seen this triggered // This is likely a light novel for which we can set series from parsed title info.Series = Parser.ParseSeries(info.Title, type); - info.Volumes = Parser.ParseVolume(info.Title, type); + info.Volumes = parsedVolumeFromTitle; } else { var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, enableMetadata, comicInfo); info.Merge(info2); - if (hasVolumeInSeries && info2 != null && Parser.ParseVolume(info2.Series, type) - .Equals(Parser.LooseLeafVolume)) + + if (hasVolumeInSeries && info2 != null && Parser.IsLooseLeafVolume(Parser.ParseVolume(info2.Series, type))) { // Override the Series name so it groups appropriately info.Series = info2.Series; diff --git a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs index b60f28aee..4b0878504 100644 --- a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs +++ b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs @@ -121,7 +121,7 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser if (!string.IsNullOrEmpty(info.ComicInfo.Number)) { info.Chapters = info.ComicInfo.Number; - if (info.IsSpecial && Parser.DefaultChapter != info.Chapters) + if (info.IsSpecial && !Parser.IsDefaultChapter(info.Chapters)) { info.IsSpecial = false; info.Volumes = $"{Parser.SpecialVolumeNumber}"; diff --git a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs index 175fd6fc3..20b48271c 100644 --- a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs +++ b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs @@ -67,18 +67,18 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau var parsedVolume = Parser.ParseVolume(folder, type); var parsedChapter = Parser.ParseChapter(folder, type); - if (!parsedVolume.Equals(Parser.LooseLeafVolume) || !parsedChapter.Equals(Parser.DefaultChapter)) + var isLooseLeafVolume = Parser.IsLooseLeafVolume(parsedVolume); + var isDefaultChapter = Parser.IsDefaultChapter(parsedChapter); + + if ((string.IsNullOrEmpty(ret.Volumes) || Parser.IsLooseLeafVolume(ret.Volumes)) + && !string.IsNullOrEmpty(parsedVolume) && !isLooseLeafVolume) { - if ((string.IsNullOrEmpty(ret.Volumes) || ret.Volumes.Equals(Parser.LooseLeafVolume)) - && !string.IsNullOrEmpty(parsedVolume) && !parsedVolume.Equals(Parser.LooseLeafVolume)) - { - ret.Volumes = parsedVolume; - } - if ((string.IsNullOrEmpty(ret.Chapters) || ret.Chapters.Equals(Parser.DefaultChapter)) - && !string.IsNullOrEmpty(parsedChapter) && !parsedChapter.Equals(Parser.DefaultChapter)) - { - ret.Chapters = parsedChapter; - } + ret.Volumes = parsedVolume; + } + if ((string.IsNullOrEmpty(ret.Chapters) || ret.Chapters.Equals(Parser.DefaultChapter)) + && !string.IsNullOrEmpty(parsedChapter) && !isDefaultChapter) + { + ret.Chapters = parsedChapter; } // Generally users group in series folders. Let's try to parse series from the top folder @@ -141,7 +141,7 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau protected static bool IsEmptyOrDefault(string volumes, string chapters) { - return (string.IsNullOrEmpty(chapters) || chapters == Parser.DefaultChapter) && - (string.IsNullOrEmpty(volumes) || volumes == Parser.LooseLeafVolume); + return (string.IsNullOrEmpty(chapters) || Parser.IsDefaultChapter(chapters)) && + (string.IsNullOrEmpty(volumes) || Parser.IsLooseLeafVolume(volumes)); } } diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 9402fc498..381654b8a 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -1083,10 +1083,10 @@ public static partial class Parser /// Responsible for preparing special title for rendering to the UI. Replaces _ with ' ' and strips out SP\d+ /// /// - /// - public static string CleanSpecialTitle(string name) + /// Always returns a non-null string + public static string CleanSpecialTitle(string? name) { - if (string.IsNullOrEmpty(name)) return name; + if (string.IsNullOrEmpty(name)) return string.Empty; var cleaned = SpecialTokenRegex.Replace(name.Replace('_', ' '), string.Empty).Trim(); return string.IsNullOrEmpty(cleaned) ? name : cleaned; @@ -1322,4 +1322,15 @@ public static partial class Parser private static partial Regex SupportedExtensionsRegex(); [GeneratedRegex(@"\d-{1}\d")] private static partial Regex NumberRangeRegex(); + + public static bool IsDefaultChapter(string? chapterNumber) + { + // Note: If chapterNumber is using minNumber, it will have a .0 at the end. + return !string.IsNullOrEmpty(chapterNumber) && (chapterNumber.Equals(DefaultChapter) || chapterNumber.Equals(DefaultChapter + ".0")); + } + + public static bool IsLooseLeafVolume(string? volumeNumber) + { + return !string.IsNullOrEmpty(volumeNumber) && (volumeNumber.Equals(LooseLeafVolume) || volumeNumber.Equals(LooseLeafVolume + ".0")); + } } diff --git a/API/Services/Tasks/Scanner/Parser/ParserInfo.cs b/API/Services/Tasks/Scanner/Parser/ParserInfo.cs index 2a1540234..524547d8b 100644 --- a/API/Services/Tasks/Scanner/Parser/ParserInfo.cs +++ b/API/Services/Tasks/Scanner/Parser/ParserInfo.cs @@ -83,7 +83,7 @@ public class ParserInfo /// public bool IsSpecialInfo() { - return (IsSpecial || (Volumes == Parser.LooseLeafVolume && Chapters == Parser.DefaultChapter)); + return IsSpecial || (Parser.IsLooseLeafVolume(Volumes) && Parser.IsDefaultChapter(Chapters)); } /// @@ -93,15 +93,15 @@ public class ParserInfo public ComicInfo? ComicInfo { get; set; } /// - /// Merges non empty/null properties from info2 into this entity. + /// Merges non-empty/null properties from info2 into this entity. /// /// This does not merge ComicInfo as they should always be the same /// public void Merge(ParserInfo? info2) { if (info2 == null) return; - Chapters = string.IsNullOrEmpty(Chapters) || Chapters == Parser.DefaultChapter ? info2.Chapters: Chapters; - Volumes = string.IsNullOrEmpty(Volumes) || Volumes == Parser.LooseLeafVolume ? info2.Volumes : Volumes; + Chapters = Parser.IsDefaultChapter(Chapters) ? info2.Chapters: Chapters; + Volumes = Parser.IsLooseLeafVolume(Volumes) ? info2.Volumes : Volumes; Edition = string.IsNullOrEmpty(Edition) ? info2.Edition : Edition; Title = string.IsNullOrEmpty(Title) ? info2.Title : Title; Series = string.IsNullOrEmpty(Series) ? info2.Series : Series; diff --git a/API/Services/Tasks/Scanner/Parser/PdfParser.cs b/API/Services/Tasks/Scanner/Parser/PdfParser.cs index 80bfa9a48..1e43f3bb4 100644 --- a/API/Services/Tasks/Scanner/Parser/PdfParser.cs +++ b/API/Services/Tasks/Scanner/Parser/PdfParser.cs @@ -44,7 +44,7 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc var isSpecial = Parser.IsSpecial(fileName, type); // We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that // could cause a problem as Omake is a special term, but there is valid volume/chapter information. - if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && isSpecial) + if (Parser.IsDefaultChapter(ret.Chapters) && Parser.IsLooseLeafVolume(ret.Volumes) && isSpecial) { ret.IsSpecial = true; // NOTE: This can cause some complications, we should try to be a bit less aggressive to fallback to folder @@ -80,7 +80,7 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc } - if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && type == LibraryType.Book) + if (Parser.IsDefaultChapter(ret.Chapters) && Parser.IsLooseLeafVolume(ret.Volumes) && type == LibraryType.Book) { ret.IsSpecial = true; ret.Chapters = Parser.DefaultChapter; diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 4618f6fb2..adc86e4ca 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -49,7 +49,7 @@ public interface IScannerService [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true); - Task ScanFolder(string folder, string originalPath); + Task ScanFolder(string folder, string originalPath, bool abortOnNoSeriesMatch = false); Task AnalyzeFiles(); } @@ -142,7 +142,8 @@ public class ScannerService : IScannerService /// This will Schedule the job to run 1 minute in the future to allow for any close-by duplicate requests to be dropped /// Normalized folder /// If invoked from LibraryWatcher, this maybe a nested folder and can allow for optimization - public async Task ScanFolder(string folder, string originalPath) + /// + public async Task ScanFolder(string folder, string originalPath, bool abortOnNoSeriesMatch = false) { Series? series = null; try @@ -173,6 +174,8 @@ public class ScannerService : IScannerService return; } + if (abortOnNoSeriesMatch) return; + // This is basically rework of what's already done in Library Watcher but is needed if invoked via API var parentDirectory = _directoryService.GetParentDirectoryName(folder); diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index d2da87a13..7b725114a 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -111,6 +111,15 @@ public class StatsService : IStatsService { _logger.LogError("KavitaStats did not respond successfully. {Content}", response); } + + // Increment stats api hits + await _context.Database.ExecuteSqlAsync( + $""" + UPDATE ServerSetting + SET Value = CAST(CAST(Value AS INTEGER) + 1 AS TEXT) + WHERE Key = {ServerSettingKey.StatsApiHits} + """); + } catch (HttpRequestException e) { @@ -402,6 +411,15 @@ public class StatsService : IStatsService private async Task OpenRandomFile(ServerInfoV3Dto dto) { + // Skip this if we've sent enough + var samplesTaken = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).StatsApiHits; + if (samplesTaken > 2) + { + dto.TimeToOpeCbzMs = null; + dto.TimeToOpenCbzPages = null; + return; + } + var random = new Random(); List extensions = [".cbz", ".zip"]; diff --git a/API/Startup.cs b/API/Startup.cs index a9ad01703..1e061e493 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -494,7 +494,8 @@ public class Startup await new MigrateTotalReads().RunAsync(dataContext, logger); await new MigrateToAuthKeys().RunAsync(dataContext, logger); await new MigrateMissingAppUserRatingDateColumns().RunAsync(dataContext, logger); - await new MigrateFormatToActivityData().RunAsync(dataContext, logger); + await new MigrateFormatToActivityDataV2().RunAsync(dataContext, logger); + await new MigrateIncorrectUtcMidnightRollovers().RunAsync(dataContext, logger); #endregion #endregion diff --git a/UI/Web/src/_card-item-common.scss b/UI/Web/src/_card-item-common.scss index 1c6f916f3..a793fa745 100644 --- a/UI/Web/src/_card-item-common.scss +++ b/UI/Web/src/_card-item-common.scss @@ -1,6 +1,10 @@ $image-height: 232.91px; $image-width: 160px; +.card-item-container { + width: $image-width; +} + .error-banner { width: $image-width; height: 18px; @@ -165,7 +169,7 @@ $image-width: 160px; :first-child { min-width: 22px; - } + } .card-title { font-size: 0.8rem; diff --git a/UI/Web/src/app/_models/chapter.ts b/UI/Web/src/app/_models/chapter.ts index 1ffa1c751..7c37bd5f4 100644 --- a/UI/Web/src/app/_models/chapter.ts +++ b/UI/Web/src/app/_models/chapter.ts @@ -12,9 +12,6 @@ import {IHasProgress} from "./common/i-has-progress"; export const LooseLeafOrDefaultNumber = -100000; export const SpecialVolumeNumber = 100000; -/** - * Chapter table object. This does not have metadata on it, use ChapterMetadata which is the same Chapter but with those fields. - */ export interface Chapter extends IHasCast, IHasReadingTime, IHasCover, IHasProgress { id: number; range: string; diff --git a/UI/Web/src/app/_models/manga-file.ts b/UI/Web/src/app/_models/manga-file.ts index b630054af..bbabfeed3 100644 --- a/UI/Web/src/app/_models/manga-file.ts +++ b/UI/Web/src/app/_models/manga-file.ts @@ -1,10 +1,11 @@ -import { MangaFormat } from './manga-format'; +import {MangaFormat} from './manga-format'; export interface MangaFile { - id: number; - filePath: string; - pages: number; - format: MangaFormat; - created: string; - bytes: number; + id: number; + filePath: string; + pages: number; + format: MangaFormat; + created: string; + bytes: number; + koreaderHash: string; } diff --git a/UI/Web/src/app/_models/metadata/series-filter.ts b/UI/Web/src/app/_models/metadata/series-filter.ts index 8563d0e06..d80b58fa2 100644 --- a/UI/Web/src/app/_models/metadata/series-filter.ts +++ b/UI/Web/src/app/_models/metadata/series-filter.ts @@ -15,6 +15,9 @@ export enum SortField { LastChapterAdded = 4, TimeToRead = 5, ReleaseYear = 6, + /** + * This sorts on the DATE of last progress + */ ReadProgress = 7, /** * Kavita+ only diff --git a/UI/Web/src/app/_pipes/reading-progress-icon-pipe.pipe.ts b/UI/Web/src/app/_pipes/reading-progress-icon-pipe.pipe.ts new file mode 100644 index 000000000..4e37654e9 --- /dev/null +++ b/UI/Web/src/app/_pipes/reading-progress-icon-pipe.pipe.ts @@ -0,0 +1,23 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {ReadingProgressStatus} from "../_models/series-detail/reading-progress"; + +@Pipe({ + name: 'readingProgressIconPipe', +}) +export class ReadingProgressIconPipePipe implements PipeTransform { + + transform(value: ReadingProgressStatus | undefined): string { + if (value === undefined) return 'fa fa-book'; + + switch (value) { + case ReadingProgressStatus.NoProgress: + return 'fa fa-book'; + case ReadingProgressStatus.Progress: + return 'fa fa-book-open'; + case ReadingProgressStatus.FullyRead: + return 'fa fa-book'; + + } + } + +} diff --git a/UI/Web/src/app/_pipes/reading-progress-status-pipe.pipe.ts b/UI/Web/src/app/_pipes/reading-progress-status-pipe.pipe.ts index 0a5a79f0d..1c696b9ca 100644 --- a/UI/Web/src/app/_pipes/reading-progress-status-pipe.pipe.ts +++ b/UI/Web/src/app/_pipes/reading-progress-status-pipe.pipe.ts @@ -1,4 +1,4 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {ReadingProgressStatus} from "../_models/series-detail/reading-progress"; import {translate} from "@jsverse/transloco"; @@ -12,12 +12,11 @@ export class ReadingProgressStatusPipePipe implements PipeTransform { switch (value) { case ReadingProgressStatus.NoProgress: - return translate('reading-progress-status-pipe.no-progress' + suffix) + return translate('reading-progress-status-pipe.no-progress' + suffix); case ReadingProgressStatus.Progress: - return translate('reading-progress-status-pipe.progress' + suffix) + return translate('reading-progress-status-pipe.progress' + suffix); case ReadingProgressStatus.FullyRead: - return translate('reading-progress-status-pipe.full-read' + suffix) - + return translate('reading-progress-status-pipe.full-read' + suffix); } } diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index e6f1e3d0d..6d3cf1fb5 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -1,4 +1,4 @@ -import {HttpClient, httpResource} from '@angular/common/http'; +import {HttpClient} from '@angular/common/http'; import {computed, DestroyRef, inject, Injectable} from '@angular/core'; import {Observable, of, ReplaySubject, shareReplay} from 'rxjs'; import {filter, map, switchMap, tap} from 'rxjs/operators'; @@ -91,7 +91,12 @@ export class AccountService { filter(evt => evt.event === EVENTS.AuthKeyUpdate), map(evt => evt.payload as {authKey: AuthKey}), tap(({authKey}) => { - const authKeys = this.currentUser!.authKeys.map(k => k.id === authKey.id ? authKey : k); + const existingKeys = this.currentUser!.authKeys; + const index = existingKeys.findIndex(k => k.id === authKey.id); + + const authKeys = index >= 0 + ? existingKeys.map(k => k.id === authKey.id ? authKey : k) + : [...existingKeys, authKey]; this.setCurrentUser({ ...this.currentUser!, diff --git a/UI/Web/src/app/_services/external-source.service.ts b/UI/Web/src/app/_services/external-source.service.ts index 3f974b430..f84951e76 100644 --- a/UI/Web/src/app/_services/external-source.service.ts +++ b/UI/Web/src/app/_services/external-source.service.ts @@ -31,7 +31,9 @@ export class ExternalSourceService { } sourceExists(name: string, host: string, apiKey: string) { - return this.httpClient.get(this.baseUrl + `stream/external-source-exists?host=${encodeURIComponent(host)}&name=${name}&apiKey=${apiKey}`, TextResonse) + const dto = {id: 0, name, host, apiKey}; + + return this.httpClient.post(this.baseUrl + `stream/external-source-exists`, dto, TextResonse) .pipe(map(s => s == 'true')); } } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index e1ac78361..4983f6372 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -23,7 +23,7 @@ import {translate} from "@jsverse/transloco"; import {ToastrService} from "ngx-toastr"; import {FilterField} from "../_models/metadata/v2/filter-field"; import {ModalService} from "./modal.service"; -import {map, Observable, of, switchMap, tap} from "rxjs"; +import {catchError, map, Observable, of, switchMap, tap} from "rxjs"; import {ListSelectModalComponent} from "../shared/_components/list-select-modal/list-select-modal.component"; import {take, takeUntil} from "rxjs/operators"; import {SeriesService} from "./series.service"; @@ -709,6 +709,7 @@ export class ReaderService { takeUntil(modal.dismissed), take(1), map(res => ({prompt: prompt, result: res as RereadPromptResult})), + catchError(() => of({prompt: prompt, result: RereadPromptResult.Cancel})) ); } diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index a598d827e..9e2015c73 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -34,9 +34,14 @@ export class SeriesService { paginatedResults: PaginatedResult = new PaginatedResult(); paginatedSeriesForTagsResults: PaginatedResult = new PaginatedResult(); - getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2, context: QueryContext = QueryContext.None) { + getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2, context: QueryContext = QueryContext.None, userId?: number) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + + if (userId) { + params = params.set('userId', userId); + } + const data = filter || {}; return this.httpClient.post>(this.baseUrl + 'series/all-v2?context=' + context, data, {observe: 'response', params}).pipe( diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index 7975a3dce..528e09045 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -31,7 +31,8 @@ import {Genre} from "../_models/metadata/genre"; import {Library} from "../_models/library/library"; import {Series} from "../_models/series"; import {Tag} from "../_models/tag"; -import {Person} from "../_models/metadata/person"; +import {Person, PersonRole} from "../_models/metadata/person"; +import {ReadingList} from "../_models/reading-list"; export enum DayOfWeek { @@ -73,6 +74,10 @@ export class StatisticsService { return httpResource[]>(() => this.baseUrl + 'stats/popular-series').asReadonly(); } + getPopularReadingList() { + return httpResource[]>(() => this.baseUrl + 'stats/popular-reading-list').asReadonly(); + } + getPopularGenresResource() { return httpResource[]>(() => this.baseUrl + 'stats/popular-genres').asReadonly(); } @@ -81,12 +86,8 @@ export class StatisticsService { return httpResource[]>(() => this.baseUrl + 'stats/popular-tags').asReadonly(); } - getPopularAuthorsResource() { - return httpResource[]>(() => this.baseUrl + 'stats/popular-authors').asReadonly(); - } - - getPopularArtistsResource() { - return httpResource[]>(() => this.baseUrl + 'stats/popular-artists').asReadonly(); + getPopularPersonResource(role: PersonRole) { + return httpResource[]>(() => this.baseUrl + `stats/popular-people?role=${role}`).asReadonly(); } getPopularDecadesResource() { diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html index 423cce147..19c90b60b 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html @@ -4,14 +4,14 @@ @if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) { } @else {
diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts index f9f537c55..370705db3 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts @@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, Component, EventEmitter, inject, input, OnDestr import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {AccountService} from 'src/app/_services/account.service'; import {ActionableEntity, ActionItem} from 'src/app/_services/action-factory.service'; -import {AsyncPipe, NgTemplateOutlet} from "@angular/common"; +import {AsyncPipe, NgClass, NgTemplateOutlet} from "@angular/common"; import {TranslocoDirective} from "@jsverse/transloco"; import {DynamicListPipe} from "./_pipes/dynamic-list.pipe"; import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; @@ -14,7 +14,7 @@ import {User} from "../../_models/user/user"; selector: 'app-card-actionables', imports: [ NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, - DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet + DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet, NgClass ], templateUrl: './card-actionables.component.html', styleUrls: ['./card-actionables.component.scss'], @@ -37,6 +37,10 @@ export class CardActionablesComponent implements OnDestroy { */ label = input(''); disabled = input(false); + /** + * Hide label when on mobile + */ + hideLabelOnMobile = input(false); entity = input(null); /** diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html index 870662465..c25449495 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html @@ -1,23 +1,35 @@
- @if (accountService.isAdmin() && filePaths && filePaths.length > 0) { + @let filePathsValue = filePaths(); + @let filesValue = files(); + + @if (accountService.isAdmin() && (filePathsValue.length > 0 || filesValue.length > 0)) {
-

{{t('file-path-title')}}

+

{{t(filesValue.length > 0 ? 'file-path-title' : 'folder-path-title')}}

- @for (fp of filePaths; track $index) { - {{fp}} + @if (filesValue.length > 0) { + @for (fp of filesValue; track $index) { + {{fp.filePath}} + @if (fp.koreaderHash) { + ({{fp.koreaderHash}}) + } + } + } @else { + @for (fp of filePathsValue; track $index) { + {{fp}} + } }
} - @if (!suppressEmptyGenres || genres.length > 0) { + @if (!suppressEmptyGenres || genres().length > 0) {

{{t('genres-title')}}

- + {{item.title}} @@ -26,11 +38,11 @@
} - @if (!suppressEmptyTags || tags.length > 0) { + @if (!suppressEmptyTags || tags().length > 0) {

{{t('tags-title')}}

- + {{item.title}} @@ -40,7 +52,7 @@ }
- +
- @if (genres.length > 0 || tags.length > 0 || webLinks.length > 0) { + @if (hasUpperMetadata()) { } diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts index c6f4ebc38..b91553e7b 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core'; +import {ChangeDetectionStrategy, Component, computed, inject, input, Input} from '@angular/core'; import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component"; import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component"; import {TranslocoDirective} from "@jsverse/transloco"; @@ -12,17 +12,10 @@ import {Tag} from "../../_models/tag"; import {ImageComponent} from "../../shared/image/image.component"; import {ImageService} from "../../_services/image.service"; import {BadgeExpanderComponent} from "../../shared/badge-expander/badge-expander.component"; -import {IHasReadingTime} from "../../_models/common/i-has-reading-time"; -import {ReadTimePipe} from "../../_pipes/read-time.pipe"; import {MangaFormat} from "../../_models/manga-format"; -import {SeriesFormatComponent} from "../../shared/series-format/series-format.component"; -import {MangaFormatPipe} from "../../_pipes/manga-format.pipe"; -import {LanguageNamePipe} from "../../_pipes/language-name.pipe"; -import {AsyncPipe} from "@angular/common"; import {SafeUrlPipe} from "../../_pipes/safe-url.pipe"; -import {AgeRating} from "../../_models/metadata/age-rating"; -import {AgeRatingImageComponent} from "../age-rating-image/age-rating-image.component"; import {AccountService} from "../../_services/account.service"; +import {MangaFile} from "../../_models/manga-file"; @Component({ selector: 'app-details-tab', @@ -49,13 +42,17 @@ export class DetailsTabComponent { protected readonly MangaFormat = MangaFormat; @Input({required: true}) metadata!: IHasCast; - @Input() genres: Array = []; - @Input() tags: Array = []; - @Input() webLinks: Array = []; + genres = input([]); + tags = input([]); + webLinks = input([]); @Input() suppressEmptyGenres: boolean = false; @Input() suppressEmptyTags: boolean = false; - @Input() filePaths: string[] | undefined; + filePaths = input([]); + files = input([]); + hasUpperMetadata = computed(() => { + return this.genres().length > 0 || this.tags().length > 0 || this.webLinks().length > 0; + }); openGeneric(queryParamName: FilterField, filter: string | number) { if (queryParamName === FilterField.None) return; diff --git a/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.html b/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.html index d6531a418..4a7fe4f6d 100644 --- a/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.html +++ b/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.html @@ -1,14 +1,15 @@ - @let image = currentImageUrl(); - @if (image && !noImage()) { - - } @else { - - } +@let image = currentImageUrl(); +@let sizeValue = size(); +@if (image && !noImage()) { + +} @else { + +} diff --git a/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.ts b/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.ts index b74cf636e..dda43e718 100644 --- a/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.ts +++ b/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.ts @@ -1,16 +1,14 @@ -import {ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, model} from '@angular/core'; +import {ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, signal} from '@angular/core'; import {ImageService} from "../../_services/image.service"; import {ImageComponent} from "../../shared/image/image.component"; import {EVENTS, MessageHubService} from "../../_services/message-hub.service"; import {CoverUpdateEvent} from "../../_models/events/cover-update-event"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {NgStyle} from "@angular/common"; @Component({ selector: 'app-profile-icon', imports: [ - ImageComponent, - NgStyle + ImageComponent ], templateUrl: './profile-icon.component.html', styleUrl: './profile-icon.component.scss', @@ -29,11 +27,10 @@ export class ProfileIconComponent { */ processEvents = input(true); - currentImageUrl = model(''); - noImage = model(false); + currentImageUrl = signal(''); + noImage = signal(false); constructor() { - this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => { if (!this.processEvents()) return; const imageUrl = this.currentImageUrl(); diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.html b/UI/Web/src/app/_single-module/review-card/review-card.component.html index 9b8ec5161..1e1f07de6 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.html +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.html @@ -22,7 +22,7 @@
@if (!reviewValue.isExternal) { - + {{reviewValue.username}} diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html index 1ead6b859..27960c7ab 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html @@ -28,7 +28,12 @@
- + (true); - isChecking = model(true); - isSaving = model(false); - hasLicense = model(false); - licenseInfo = model(null); - showEmail = model(false); + isViewMode = signal(true); + isChecking = signal(true); + isSaving = signal(false); + hasLicense = signal(false); + licenseInfo = signal(null); + showEmail = signal(false); /** * Either the normal manageLink or with a prefilled email to ease the user diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.html b/UI/Web/src/app/admin/manage-library/manage-library.component.html index 14a1b1378..9551ec9a3 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.html +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.html @@ -1,8 +1,8 @@ -
+
+ [disabled]="bulkMode" (actionHandler)="handleBulkAction($event, null!)" [hideLabelOnMobile]="true">
@if (bulkMode && bulkAction === Action.CopySettings && sourceCopyToLibrary) { -
- {{t('bulk-copy-to', {libraryName: sourceCopyToLibrary.name})}} -
-
- - - - {{t('include-type-tooltip')}} - -
-
- -
- - -
-
+
+ {{t('bulk-copy-to', {libraryName: sourceCopyToLibrary.name})}} +
+
+ + + + {{t('include-type-tooltip')}} + +
+
+
+ + +
+
} - - - - - - - - - - - - - @for(library of libraries; track library.name + library.type + library.folders.length + library.lastScanned; let idx = $index) { - - - - - - - - +
+
+
+ + +
+
+
+ + + @if (loading) { + + } @else { +
{{t('no-data')}}
} - @empty { - @if (loading) { -
- } @else { - - } - } - -
-
- - -
-
{{t('name-header')}}{{t('type-title')}}{{t('shared-folders-title')}}{{t('last-scanned-title')}}{{t('actions-header')}}
+ + + + +
- - + +
-
- {{library.name}} - + + +
+ + +
+
+ + + + {{t('name-header')}} + + {{library.name}} + + + + + {{t('type-title')}} + {{library.type | libraryType}} -
+ + + + + {{t('shared-folders-title')}} + {{library.folders.length}} - + + + + + {{t('last-scanned-title')}} + {{library.lastScanned | timeAgo | defaultDate}} - - - @if (useActionables$ | async) { - - } @else { - + + - + + {{t('actions-header')}} + + + + + + - - } -
{{t('no-data')}}
+ + + +
+
+ +
+ +
+ +
+
+ + +
+
+ {{library.name}} +
+
+ +
+
+
{{t('type-title')}}
+
{{library.type | libraryType}}
+
+ +
+
{{t('shared-folders-title')}}
+
{{library.folders.length}}
+
+ +
+
{{t('last-scanned-title')}}
+
{{library.lastScanned | timeAgo | defaultDate}}
+
+
+
+
+
+ + + + + @if (useActionables$ | async) { + + } @else { + + + + } + diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.scss b/UI/Web/src/app/admin/manage-library/manage-library.component.scss index 5d7f358ff..e55bae653 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.scss +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.scss @@ -2,7 +2,7 @@ .custom-position { right: 15px; - top: -42px; + top: -60px; } .member-name { diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.ts b/UI/Web/src/app/admin/manage-library/manage-library.component.ts index 9154eb2aa..8052cd335 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.ts +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.ts @@ -5,7 +5,8 @@ import { DestroyRef, HostListener, inject, - OnInit + OnInit, + TrackByFunction } from '@angular/core'; import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {ToastrService} from 'ngx-toastr'; @@ -33,11 +34,18 @@ import {Action, ActionFactoryService, ActionItem} from "../../_services/action-f import {ActionService} from "../../_services/action.service"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; import {BehaviorSubject, catchError, Observable} from "rxjs"; -import {SelectionModel} from "../../typeahead/_models/selection-model"; import { CopySettingsFromLibraryModalComponent } from "../_modals/copy-settings-from-library-modal/copy-settings-from-library-modal.component"; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms"; +import {SelectionModel} from "../../typeahead/_models/selection-model"; +import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; +import { + DataTableColumnCellDirective, + DataTableColumnDirective, + DataTableColumnHeaderDirective, + DatatableComponent +} from "@siemens/ngx-datatable"; @Component({ selector: 'app-manage-library', @@ -45,7 +53,7 @@ import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular styleUrls: ['./manage-library.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, imports: [RouterLink, NgbTooltip, LibraryTypePipe, TimeAgoPipe, SentenceCasePipe, TranslocoModule, DefaultDatePipe, - AsyncPipe, LoadingComponent, CardActionablesComponent, NgTemplateOutlet, ReactiveFormsModule, FormsModule] + AsyncPipe, LoadingComponent, CardActionablesComponent, NgTemplateOutlet, ReactiveFormsModule, FormsModule, ResponsiveTableComponent, DatatableComponent, DataTableColumnHeaderDirective, DataTableColumnDirective, DataTableColumnCellDirective] }) export class ManageLibraryComponent implements OnInit { @@ -82,6 +90,9 @@ export class ManageLibraryComponent implements OnInit { isShiftDown: boolean = false; lastSelectedIndex: number | null = null; + trackByLibrary: TrackByFunction = (_, lib) => + `${lib.name}_${lib.type}_${lib.folders.length}_${lib.lastScanned}`; + @HostListener('document:keydown.shift', ['$event']) handleKeypress(_: Event) { this.isShiftDown = true; diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.html b/UI/Web/src/app/admin/manage-users/manage-users.component.html index 9d1c988da..2f4297838 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.html +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.html @@ -1,123 +1,201 @@ -
-
- - - - - - - - - - - - - - - @for(member of members; track member.username + member.lastActiveUtc + member.roles.length; let idx = $index) { - - - - - - - - - } - @empty { - @if (loadingMembers) { - - } @else { - + +
+
{{t('sharing-header')}}
+
+
+ +
+
{{t('roles-header')}}
+
+
+ +
+ +
+ + + + + + + + + @if (member.libraries.length > 0) { + @if (hasAdminRole(member) || member.libraries.length === libraryCount) { + {{t('all-libraries')}} + } @else if (member.libraries.length > 5) { + {{t('too-many-libraries')}} + } @else { + @for(lib of member.libraries; track lib.name) { + {{lib.name}} } } - -
{{t('name-header')}}{{t('last-active-header')}}{{t('sharing-header')}}{{t('roles-header')}}{{t('actions-header')}}
-
- @switch (member.identityProvider) { - @case (IdentityProvider.OpenIdConnect) { - - } - @case (IdentityProvider.Kavita) { - - } + + + + + + @switch (member.identityProvider) { + @case (IdentityProvider.OpenIdConnect) { + } -
-
- {{member.username | titlecase}} + @case (IdentityProvider.Kavita) { + + } + } + + + + + {{t('name-header')}} + + + {{member.username | titlecase}} + @if (member.isPending) { {{t('pending-title')}} } - + + + + + {{t('last-active-header')}} + + @if ((messageHub.onlineUsers$ | async)?.includes(member.username)) { + {{t('online-now-tooltip')}} + } @else { + {{member.lastActiveUtc | utcToLocalDate | timeAgo | sentenceCase | defaultDate}} + } + + + + + + {{t('sharing-header')}} + + + + + + + {{t('roles-header')}} + + + + + + + {{t('actions-header')}} + + + + + + + + + @if (loadingMembers) { + + } @else { +
{{t('no-data')}}
+ } +
+ + +
+
+
+ @switch (member.identityProvider) { + @case (IdentityProvider.OpenIdConnect) { + + } + @case (IdentityProvider.Kavita) { + + } + } +
+ {{member.username | titlecase}} +
+ @if (member.isPending) { + {{t('pending-title')}} + } +
+ +
+
+
{{t('last-active-header')}}
+
@if ((messageHub.onlineUsers$ | async)?.includes(member.username)) { {{t('online-now-tooltip')}} } @else { {{member.lastActiveUtc | utcToLocalDate | timeAgo | sentenceCase | defaultDate}} } - -
- @if (member.libraries.length > 0) { - @if (hasAdminRole(member) || member.libraries.length === libraryCount) { - {{t('all-libraries')}} - } @else { - @if (member.libraries.length > 5) { - {{t('too-many-libraries')}} - } - @else { - @for(lib of member.libraries; track lib.name) { - {{lib.name}} - } - } - } - } @else { - {{null | defaultValue}} - } - - @if (getRoles(member); as roles) { -
- @if (roles.length === 0) { - {{null | defaultValue}} - } @else { - @if (hasAdminRole(member)) { - {{Role.Admin | roleLocalized}} - } @else { - @for (role of roles; track role) { - {{role | roleLocalized}} - } - } - }
- } @else { - {{null | defaultValue}} - } -
-
- @if (canEditMember(member)) { - - - - @if (member.isPending) { - - - } @else { - - } - }
-
{{t('no-data')}}
+ } @else { + {{null | defaultValue}} + } + + + + @if (getRoles(member); as roles) { + @if (roles.length === 0) { + {{null | defaultValue}} + } @else if (hasAdminRole(member)) { + {{Role.Admin | roleLocalized}} + } @else { + @for (role of roles; track role) { + {{role | roleLocalized}} + } + } + } @else { + {{null | defaultValue}} + } + + + + @if (canEditMember(member)) { +
+ + + @if (member.isPending) { + + + } @else { + + } +
+ } +
diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.scss b/UI/Web/src/app/admin/manage-users/manage-users.component.scss index 070521edf..dca7842d3 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.scss +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.scss @@ -35,23 +35,3 @@ background-color: var(--elevation-layer1); } -.table { - @media (max-width: theme.$grid-breakpoints-lg) { - overflow-x: auto; - width: 100% !important; - display: block; - } - .btn-container { - @media (max-width: theme.$grid-breakpoints-lg) { - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: center; - justify-content: center; - } - .btn { - width: 32px; - } - } -} - diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.ts b/UI/Web/src/app/admin/manage-users/manage-users.component.ts index 06852857c..5e98a56e3 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.ts +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit, TrackByFunction} from '@angular/core'; import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {take} from 'rxjs/operators'; import {MemberService} from 'src/app/_services/member.service'; @@ -12,7 +12,7 @@ import {InviteUserComponent} from '../invite-user/invite-user.component'; import {EditUserComponent} from '../edit-user/edit-user.component'; import {Router} from '@angular/router'; import {TagBadgeComponent} from '../../shared/tag-badge/tag-badge.component'; -import {AsyncPipe, NgClass, TitleCasePipe} from '@angular/common'; +import {AsyncPipe, NgClass, NgTemplateOutlet, TitleCasePipe} from '@angular/common'; import {TranslocoModule, TranslocoService} from "@jsverse/transloco"; import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; @@ -27,6 +27,13 @@ import {SettingsService} from "../settings.service"; import {ServerSettings} from "../_models/server-settings"; import {IdentityProvider} from "../../_models/user/user"; import {ImageComponent} from "../../shared/image/image.component"; +import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; +import { + DataTableColumnCellDirective, + DataTableColumnDirective, + DataTableColumnHeaderDirective, + DatatableComponent +} from "@siemens/ngx-datatable"; @Component({ selector: 'app-manage-users', @@ -35,7 +42,7 @@ import {ImageComponent} from "../../shared/image/image.component"; changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, TranslocoModule, DefaultDatePipe, NgClass, DefaultValuePipe, UtcToLocalTimePipe, LoadingComponent, TimeAgoPipe, SentenceCasePipe, UtcToLocalDatePipe, - RoleLocalizedPipe, ImageComponent] + RoleLocalizedPipe, ImageComponent, ResponsiveTableComponent, NgTemplateOutlet, DatatableComponent, DataTableColumnDirective, DataTableColumnCellDirective, DataTableColumnHeaderDirective] }) export class ManageUsersComponent implements OnInit { @@ -59,6 +66,9 @@ export class ManageUsersComponent implements OnInit { loadingMembers = false; libraryCount: number = 0; + trackByMember: TrackByFunction = (_, m) => + `${m.username}_${m.lastActiveUtc}_${m.roles.length}`; + constructor() { this.accountService.currentUser$.pipe(take(1)).subscribe((user) => { diff --git a/UI/Web/src/app/admin/server-activity/server-activity.component.ts b/UI/Web/src/app/admin/server-activity/server-activity.component.ts index 3b04a4288..8b20c2ce5 100644 --- a/UI/Web/src/app/admin/server-activity/server-activity.component.ts +++ b/UI/Web/src/app/admin/server-activity/server-activity.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, DestroyRef, inject, model, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit, signal} from '@angular/core'; import {TranslocoDirective} from "@jsverse/transloco"; import {ActivityCardComponent} from "../../_single-module/activity-card/activity-card.component"; import {ActivityService} from "../../_services/activity.service"; @@ -23,7 +23,7 @@ export class ServerActivityComponent implements OnInit { protected readonly messageHub = inject(MessageHubService); protected readonly destroyRef = inject(DestroyRef); - activeSessions = model([]); + activeSessions = signal([]); constructor() { this.messageHub.messages$.pipe( diff --git a/UI/Web/src/app/admin/server-devices/server-devices.component.ts b/UI/Web/src/app/admin/server-devices/server-devices.component.ts index e618e52da..582de9e1e 100644 --- a/UI/Web/src/app/admin/server-devices/server-devices.component.ts +++ b/UI/Web/src/app/admin/server-devices/server-devices.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, inject, model, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, Component, inject, OnInit, signal} from '@angular/core'; import {DeviceService} from "../../_services/device.service"; import {ClientDevice} from "../../_models/client-device"; import {ClientDeviceCardComponent} from "../../_single-module/client-device-card/client-device-card.component"; @@ -30,9 +30,9 @@ export class ServerDevicesComponent implements OnInit { private readonly clientDeviceClientTypePipe = new ClientDeviceClientTypePipe(); private readonly clientDeviceTypePipe = new ClientDeviceTypePipe(); - clientDevices = model([]); - clientDeviceTypeBreakdown = model[]>([]); - mobileVsDesktop = model[]>([]); + clientDevices = signal([]); + clientDeviceTypeBreakdown = signal[]>([]); + mobileVsDesktop = signal[]>([]); ngOnInit() { this.loadDevices(); diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html index 79a3f4960..d01039bf1 100644 --- a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html +++ b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html @@ -9,7 +9,7 @@ } @else { } - + {{ annotation().ownerUsername }}
diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts index 0d42fd56e..4040090d4 100644 --- a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts +++ b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts @@ -8,6 +8,7 @@ import { input, model, Output, + signal, Signal } from '@angular/core'; import {Annotation} from "../../../_models/annotations/annotation"; @@ -103,7 +104,7 @@ export class AnnotationCardComponent { @Output() selection = new EventEmitter(); titleColor: Signal; - hasClicked = model(false); + hasClicked = signal(false); constructor() { diff --git a/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.ts b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.ts index bf71ecf7d..b8d1c7f5f 100644 --- a/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.ts +++ b/UI/Web/src/app/book-reader/_components/_annotations/epub-highlight/epub-highlight.component.ts @@ -1,10 +1,9 @@ -import {Component, computed, DestroyRef, effect, ElementRef, inject, input, model, ViewChild} from '@angular/core'; +import {Component, computed, effect, ElementRef, inject, model, ViewChild} from '@angular/core'; import {Annotation} from "../../../_models/annotations/annotation"; import {EpubReaderMenuService} from "../../../../_services/epub-reader-menu.service"; import {AnnotationService} from "../../../../_services/annotation.service"; import {SlotColorPipe} from "../../../../_pipes/slot-color.pipe"; import {NgStyle} from "@angular/common"; -import {MessageHubService} from "../../../../_services/message-hub.service"; @Component({ selector: 'app-epub-highlight', @@ -17,8 +16,6 @@ import {MessageHubService} from "../../../../_services/message-hub.service"; export class EpubHighlightComponent { private readonly epubMenuService = inject(EpubReaderMenuService); private readonly annotationService = inject(AnnotationService); - private readonly messageHub = inject(MessageHubService); - private readonly destroyRef = inject(DestroyRef); showHighlight = model(true); diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts index d3fecf0cf..8dee18535 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-bookmarks-drawer/view-bookmark-drawer.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, effect, EventEmitter, inject, model} from '@angular/core'; +import {ChangeDetectionStrategy, Component, effect, EventEmitter, inject, input, model, signal} from '@angular/core'; import {TranslocoDirective} from "@jsverse/transloco"; import { NgbActiveOffcanvas, @@ -52,8 +52,8 @@ export class ViewBookmarkDrawerComponent { protected readonly imageService = inject(ImageService); - chapterId = model(); - bookmarks = model(); + chapterId = input.required(); + bookmarks = signal([]); /** * Current Page */ diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.html index 78c80541d..8394d5dab 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.html +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.html @@ -65,7 +65,7 @@
diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.ts index 34e11d02d..2c9cbed5a 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.ts +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.ts @@ -5,8 +5,8 @@ import { DestroyRef, effect, inject, - model, OnInit, + signal, Signal, ViewChild, ViewContainerRef @@ -89,9 +89,9 @@ export class ViewEditAnnotationDrawerComponent implements OnInit { @ViewChild('renderTarget', {read: ViewContainerRef}) renderTarget!: ViewContainerRef; - annotation = model(null); - mode = model(AnnotationMode.View); - user = model(null); + annotation = signal(null); + mode = signal(AnnotationMode.View); + user = signal(null); isEditMode: Signal isEditOrCreateMode: Signal titleColor: Signal; diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts index 6b557bc56..5d13d1090 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-toc-drawer/view-toc-drawer.component.ts @@ -5,7 +5,9 @@ import { effect, EventEmitter, inject, - model, signal + input, + model, + signal } from '@angular/core'; import {TranslocoDirective} from "@jsverse/transloco"; import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap"; @@ -30,7 +32,7 @@ export class ViewTocDrawerComponent { private readonly cdRef = inject(ChangeDetectorRef); private readonly bookService = inject(BookService); - chapterId = model(); + chapterId = input.required(); /** * Current Page */ @@ -39,7 +41,7 @@ export class ViewTocDrawerComponent { /** * The actual pages from the epub, used for showing on table of contents. This must be here as we need access to it for scroll anchors */ - chapters = model>([]); + chapters = signal>([]); loading = signal(true); diff --git a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts index e590f1451..404b5a356 100644 --- a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts @@ -7,9 +7,9 @@ import { EventEmitter, inject, Input, - model, OnInit, Output, + signal, } from '@angular/core'; import {fromEvent, merge, of} from "rxjs"; import {catchError, debounceTime, tap} from "rxjs/operators"; @@ -55,7 +55,7 @@ export class BookLineOverlayComponent implements OnInit { bookmarkForm: FormGroup = new FormGroup({ name: new FormControl('', [Validators.required]), }); - hasSelectedAnnotation = model(false); + hasSelectedAnnotation = signal(false); private readonly destroyRef = inject(DestroyRef); diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html index 726012def..e9446b3a0 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html @@ -13,7 +13,6 @@ [chapterId]="chapterId" [seriesId]="seriesId" [pageNumber]="pageNum()" - (isOpen)="updateLineOverlayOpen($event)" (refreshToC)="refreshPersonalToC()" /> } diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 09b57b9a3..09819dde8 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -9,12 +9,12 @@ import { ElementRef, EventEmitter, inject, - model, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, resource, + signal, Signal, ViewChild, ViewContainerRef @@ -176,7 +176,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * If this is true, no progress will be saved. */ - incognitoMode = model(false); + incognitoMode = signal(false); /** * If this is true, chapters will be fetched in the order of a reading list, @@ -192,11 +192,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * Current Page */ - pageNum = model(0); + pageNum = signal(0); /** * Max Pages */ - maxPages = model(1); + maxPages = signal(1); /** * This allows for exploration into different chapters */ @@ -214,23 +214,23 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * If the word/line overlay is open */ - isLineOverlayOpen = model(false); + isLineOverlayOpen = signal(false); /** * If the action bar (menu bars) is visible */ - actionBarVisible = model(true); + actionBarVisible = signal(true); /** * If we are loading from backend */ - isLoading = model(true); + isLoading = signal(true); /** * Title of the book. Rendered in action bar */ - bookTitle = model(''); + bookTitle = signal(''); /** * Authors of the book. Rendered in action bar */ - authorText = model(''); + authorText = signal(''); /** * The boolean that decides if the clickToPaginate overlay is visible or not. */ @@ -241,7 +241,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * This is the html we get from the server */ - page = model(undefined); + page = signal(undefined); /** * Next Chapter Id. This is not guaranteed to be a valid ChapterId. Prefetched on page load (non-blocking). */ @@ -284,12 +284,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Will hide if all content in book is absolute positioned */ horizontalScrollbarNeeded = false; - scrollbarNeeded = model(false); + scrollbarNeeded = signal(false); /** * Used solely for fullscreen to apply a hack */ - darkMode = model(true); + darkMode = signal(true); readingTimeLeftResource = resource({ params: () => ({ chapterId: this.chapterId, @@ -301,8 +301,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } }); - imageBookmarks = model([]); - annotationToLoad = model(-1); + imageBookmarks = signal([]); + annotationToLoad = signal(-1); /** * Anchors that map to the page number. When you click on one of these, we will load a given page up for the user. @@ -326,8 +326,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * Width of the document (in non-column layout), used for column layout virtual paging */ - windowWidth = model(0); - windowHeight = model(0); + windowWidth = signal(0); + windowHeight = signal(0); /** * used to track if a click is a drag or not, for opening menu @@ -345,7 +345,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * When the user is highlighting something, then we remove pagination */ - hidePagination = model(false); + hidePagination = signal(false); /** * Used to refresh the Personal PoC @@ -359,7 +359,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * Injects information to help debug issues */ - debugMode = model(!environment.production && true); + debugMode = signal(!environment.production && true); /** * Will be set to true if this.scroll(...) is called but the actual scroll is still delayed diff --git a/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts index 207059b13..31c77c3f0 100644 --- a/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts +++ b/UI/Web/src/app/book-reader/_components/personal-table-of-contents/personal-table-of-contents.component.ts @@ -5,9 +5,9 @@ import { EventEmitter, inject, Input, - model, OnInit, - Output + Output, + signal } from '@angular/core'; import {ReaderService} from "../../../_services/reader.service"; import {PersonalToC} from "../../../_models/readers/personal-toc"; @@ -44,7 +44,7 @@ export class PersonalTableOfContentsComponent implements OnInit { @Output() loadChapter: EventEmitter = new EventEmitter(); - ptocBookmarks = model([]); + ptocBookmarks = signal([]); formGroup = new FormGroup({ filter: new FormControl('', []) }); diff --git a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts index 5a3e50acb..822e07b93 100644 --- a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts +++ b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts @@ -1,12 +1,13 @@ import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, - computed, effect, + computed, + effect, EventEmitter, inject, - model, OnInit, - Output + model, + Output, + signal } from '@angular/core'; import {BookChapterItem} from '../../_models/book-chapter-item'; import {TranslocoDirective} from "@jsverse/transloco"; @@ -26,7 +27,7 @@ export class TableOfContentsComponent { chapterId = model.required(); pageNum = model.required(); - currentPageAnchor = model(); + currentPageAnchor = signal(''); chapters = model.required>(); loading = model.required(); diff --git a/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.ts b/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.ts index dd4da5cc0..cd5bfcf22 100644 --- a/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.ts +++ b/UI/Web/src/app/carousel/_components/carousel-reel/carousel-reel.component.ts @@ -61,7 +61,7 @@ export class CarouselReelComponent { @Output() sectionClick = new EventEmitter(); @Output() handleAction = new EventEmitter>(); - currentPage = model(1); + currentPage = signal(1); pageSize = input(20); nextPageLoader = input(null); diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.html b/UI/Web/src/app/chapter-detail/chapter-detail.component.html index 6be398fa5..0983e6fc3 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.html +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.html @@ -1,37 +1,41 @@ + @let chapterValue = chapter(); + @let seriesValue = series(); +
+ @let readingProgressStatusValue = readingProgressStatus(); - @if (chapter && series && libraryType !== null) { + @if (chapterValue && seriesValue && libraryType !== null) {
- +

- {{series.name}} + {{seriesValue.name}}

- +
-
-
@@ -56,7 +60,7 @@
@@ -76,19 +80,19 @@
- +
- +
- +
@@ -96,7 +100,7 @@
{{t('writers-title')}}
- + {{item.name}} @@ -104,15 +108,15 @@
- @if (chapter.releaseDate !== '0001-01-01T00:00:00' && (libraryType === LibraryType.ComicVine || libraryType === LibraryType.Comic)) { + @if (chapterValue.releaseDate !== '0001-01-01T00:00:00' && (libraryType === LibraryType.ComicVine || libraryType === LibraryType.Comic)) { {{t('release-date-title')}} } @else { {{t('cover-artists-title')}}
- + {{item.name}} @@ -128,7 +132,7 @@
{{t('genres-title')}}
- @@ -142,7 +146,7 @@
{{t('tags-title')}}
- @@ -161,15 +165,16 @@ diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts index c24604e4b..6e6fb5e03 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts @@ -2,11 +2,12 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, DestroyRef, ElementRef, inject, - model, OnInit, + signal, ViewChild } from '@angular/core'; import {AsyncPipe, DOCUMENT, Location, NgClass, NgStyle} from "@angular/common"; @@ -39,9 +40,8 @@ import {LibraryType} from "../_models/library/library"; import {LibraryService} from "../_services/library.service"; import {ThemeService} from "../_services/theme.service"; import {DownloadEvent, DownloadService} from "../shared/_services/download.service"; -import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {TranslocoDirective} from "@jsverse/transloco"; import {BulkSelectionService} from "../cards/bulk-selection.service"; -import {ToastrService} from "ngx-toastr"; import {ReaderService} from "../_services/reader.service"; import {AccountService} from "../_services/account.service"; import {ReadMoreComponent} from "../shared/read-more/read-more.component"; @@ -83,6 +83,7 @@ import {UtcToLocalTimePipe} from "../_pipes/utc-to-local-time.pipe"; import {UtcToLocalDatePipe} from "../_pipes/utc-to-locale-date.pipe"; import {ReadingProgressStatus} from "../_models/series-detail/reading-progress"; import {ReadingProgressStatusPipePipe} from "../_pipes/reading-progress-status-pipe.pipe"; +import {ReadingProgressIconPipePipe} from "../_pipes/reading-progress-icon-pipe.pipe"; enum TabID { Related = 'related-tab', @@ -126,7 +127,8 @@ enum TabID { AnnotationsTabComponent, UtcToLocalTimePipe, UtcToLocalDatePipe, - ReadingProgressStatusPipePipe + ReadingProgressStatusPipePipe, + ReadingProgressIconPipePipe ], templateUrl: './chapter-detail.component.html', styleUrl: './chapter-detail.component.scss', @@ -145,7 +147,6 @@ export class ChapterDetailComponent implements OnInit { private readonly themeService = inject(ThemeService); private readonly downloadService = inject(DownloadService); private readonly bulkSelectionService = inject(BulkSelectionService); - private readonly toastr = inject(ToastrService); private readonly readerService = inject(ReaderService); protected readonly accountService = inject(AccountService); private readonly modalService = inject(NgbModal); @@ -169,23 +170,24 @@ export class ChapterDetailComponent implements OnInit { @ViewChild('scrollingBlock') scrollingBlock: ElementRef | undefined; @ViewChild('companionBar') companionBar: ElementRef | undefined; - isLoading: boolean = true; + isLoading = signal(true); coverImage: string = ''; chapterId: number = 0; seriesId: number = 0; libraryId: number = 0; - chapter: Chapter | null = null; - series: Series | null = null; + chapter = signal(null); + series = signal(null); libraryType: LibraryType | null = null; - hasReadingProgress = false; userReviews: Array = []; plusReviews: Array = []; rating: number = 0; ratings: Array = []; hasBeenRated: boolean = false; - size: number = 0; - annotations = model([]); - readingProgressStatus = ReadingProgressStatus.NoProgress; + size = computed(() => { + return (this.chapter()?.files || []).reduce((sum, f) => sum + f.bytes, 0); + }) + annotations = signal([]); + readingProgressStatus = signal(ReadingProgressStatus.NoProgress); weblinks: Array = []; activeTabId = TabID.Details; @@ -195,7 +197,13 @@ export class ChapterDetailComponent implements OnInit { download$: Observable | null = null; downloadInProgress: boolean = false; readingLists: ReadingList[] = []; - showDetailsTab: boolean = true; + showDetailsTab = computed(() => { + const chp = this.chapter(); + const user = this.accountService.currentUserSignal(); + + return hasAnyCast(chp) || (chp?.genres || []).length > 0 || + (chp?.tags || []).length > 0 || (chp?.webLinks || []).length > 0 || this.accountService.hasAdminRole(user!); + }) mobileSeriesImgBackground: string | undefined; chapterActions: Array> = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)); @@ -269,27 +277,27 @@ export class ChapterDetailComponent implements OnInit { return; } - this.series = results.series; - this.chapter = results.chapter; - this.size = this.chapter.files.reduce((sum, f) => sum + f.bytes, 0); - this.weblinks = this.chapter.webLinks.length > 0 ? this.chapter.webLinks.split(',') : []; + this.series.set(results.series); + this.chapter.set(results.chapter); + this.weblinks = results.chapter.webLinks.length > 0 ? results.chapter.webLinks.split(',') : []; this.libraryType = results.libraryType; this.userReviews = results.chapterDetail.reviews.filter(r => !r.isExternal); this.plusReviews = results.chapterDetail.reviews.filter(r => r.isExternal); this.rating = results.chapterDetail.rating; this.hasBeenRated = results.chapterDetail.hasBeenRated; this.ratings = results.chapterDetail.ratings; - if (this.chapter.pagesRead > 0 && this.chapter.pagesRead < this.chapter.pages) { - this.readingProgressStatus = ReadingProgressStatus.Progress; - } else if (this.chapter.pagesRead >= this.chapter.pages) { - this.readingProgressStatus = ReadingProgressStatus.FullyRead; + + if (results.chapter.pagesRead > 0 && results.chapter.pagesRead < results.chapter.pages) { + this.readingProgressStatus.set(ReadingProgressStatus.Progress); + } else if (results.chapter.pagesRead >= results.chapter.pages) { + this.readingProgressStatus.set(ReadingProgressStatus.FullyRead); } - this.themeService.setColorScape(this.chapter.primaryColor, this.chapter.secondaryColor); + this.themeService.setColorScape(results.chapter.primaryColor, results.chapter.secondaryColor); // Set up the download in progress this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => { - return this.downloadService.mapToEntityType(events, this.chapter!); + return this.downloadService.mapToEntityType(events, this.chapter()!); })); this.readingListService.getReadingListsForChapter(this.chapterId).subscribe(lists => { @@ -305,14 +313,12 @@ export class ChapterDetailComponent implements OnInit { } }), takeUntilDestroyed(this.destroyRef)).subscribe(); - this.showDetailsTab = hasAnyCast(this.chapter) || (this.chapter.genres || []).length > 0 || - (this.chapter.tags || []).length > 0 || this.chapter.webLinks.length > 0; - if (!this.showDetailsTab && this.activeTabId === TabID.Details) { + if (!this.showDetailsTab() && this.activeTabId === TabID.Details) { this.activeTabId = TabID.Reviews; } - this.isLoading = false; + this.isLoading.set(false); this.cdRef.markForCheck(); }); @@ -326,24 +332,23 @@ export class ChapterDetailComponent implements OnInit { return; } - this.chapter = d; - this.cdRef.markForCheck(); + this.chapter.set(d); }) } read(incognitoMode: boolean = false) { if (this.bulkSelectionService.hasSelections()) return; - if (this.chapter === null) return; + if (this.chapter()! === null) return; - this.readerService.readChapter(this.libraryId, this.seriesId, this.chapter, incognitoMode); + this.readerService.readChapter(this.libraryId, this.seriesId, this.chapter()!, incognitoMode); } openEditModal() { const ref = this.modalService.open(EditChapterModalComponent, DefaultModalOptions); - ref.componentInstance.chapter = this.chapter; + ref.componentInstance.chapter = this.chapter(); ref.componentInstance.libraryType = this.libraryType; ref.componentInstance.libraryId = this.libraryId; - ref.componentInstance.seriesId = this.series!.id; + ref.componentInstance.seriesId = this.seriesId; ref.closed.subscribe(res => { this.loadData(); @@ -364,7 +369,7 @@ export class ChapterDetailComponent implements OnInit { downloadChapter() { if (this.downloadInProgress) return; - this.downloadService.download('chapter', this.chapter!, (d) => { + this.downloadService.download('chapter', this.chapter()!, (d) => { this.downloadInProgress = !!d; this.cdRef.markForCheck(); }); diff --git a/UI/Web/src/app/common/stats-no-data/stats-no-data.component.html b/UI/Web/src/app/common/stats-no-data/stats-no-data.component.html new file mode 100644 index 000000000..1d9533f68 --- /dev/null +++ b/UI/Web/src/app/common/stats-no-data/stats-no-data.component.html @@ -0,0 +1,4 @@ +
+ +

{{message()}}

+
diff --git a/UI/Web/src/app/common/stats-no-data/stats-no-data.component.scss b/UI/Web/src/app/common/stats-no-data/stats-no-data.component.scss new file mode 100644 index 000000000..48a91bddc --- /dev/null +++ b/UI/Web/src/app/common/stats-no-data/stats-no-data.component.scss @@ -0,0 +1,5 @@ +.container { + background: var(--elevation-layer1); + border-radius: 0.375rem; + color: var(--text-muted-color); +} diff --git a/UI/Web/src/app/common/stats-no-data/stats-no-data.component.ts b/UI/Web/src/app/common/stats-no-data/stats-no-data.component.ts new file mode 100644 index 000000000..26d9f708f --- /dev/null +++ b/UI/Web/src/app/common/stats-no-data/stats-no-data.component.ts @@ -0,0 +1,21 @@ +import {ChangeDetectionStrategy, Component, input} from '@angular/core'; + +@Component({ + selector: 'app-stats-no-data', + imports: [], + templateUrl: './stats-no-data.component.html', + styleUrl: './stats-no-data.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StatsNoDataComponent { + + /** + * No Data message + */ + message = input.required(); + /** + * Icon - Must include fa-solid/etc + */ + icon = input('fa-solid fa-users-slash'); + +} diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts index 2e6c47283..039f7f817 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts @@ -1,5 +1,26 @@ import {AsyncPipe, DOCUMENT} from '@angular/common'; -import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, DestroyRef, effect, ElementRef, EventEmitter, inject, Injector, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, signal, Signal, SimpleChanges, ViewChild } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + computed, + DestroyRef, + effect, + ElementRef, + EventEmitter, + inject, + Injector, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + Renderer2, + Signal, + SimpleChanges, + ViewChild +} from '@angular/core'; import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject, tap} from 'rxjs'; import {debounceTime} from 'rxjs/operators'; import {ScrollService} from 'src/app/_services/scroll.service'; @@ -225,6 +246,12 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, initScrollHandler() { const element = this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body; + // Reset any modal-induced overflow lock (this can happen when Starting Over and ngBootstrap modal hasn't completed teardown) + if (element === this.document.body) { + this.document.body.style.overflow = 'auto'; + this.document.body.classList.remove('modal-open'); // ngBootstrap adds this + } + fromEvent(element, 'scroll') .pipe( debounceTime(DEFAULT_SCROLL_DEBOUNCE), diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index 602437ee6..8f9855b9d 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -9,7 +9,6 @@ import { EventEmitter, HostListener, inject, - model, OnDestroy, OnInit, signal, @@ -184,7 +183,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { seriesId!: number; volumeId!: number; chapterId!: number; - chapterInfo = model(); + chapterInfo = signal(null); /** * Reading List id. Defaults to -1. */ @@ -197,7 +196,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * If this is true, we are reading a bookmark. ChapterId will be 0. There is no continuous reading. Progress is not saved. Bookmark control is removed. */ - bookmarkMode = model(false); + bookmarkMode = signal(false); /** * If this is true, chapters will be fetched in the order of a reading list, rather than natural series order. diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index c2b94321e..be2ceeb1f 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -12,7 +12,8 @@ import { Input, OnInit, Output, - Signal, TemplateRef + Signal, + TemplateRef } from '@angular/core'; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {NgbCollapse} from '@ng-bootstrap/ng-bootstrap'; diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html index b0bab7111..fe8130806 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html @@ -1,5 +1,7 @@ @if (navService.navbarVisible$ | async) { + @let user = currentUser(); +
diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.ts b/UI/Web/src/app/volume-detail/volume-detail.component.ts index 9ec088f6c..20597d15e 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -6,8 +6,8 @@ import { DestroyRef, ElementRef, inject, - model, - OnInit, signal, + OnInit, + signal, ViewChild } from '@angular/core'; import {AsyncPipe, DOCUMENT, Location, NgClass, NgStyle} from "@angular/common"; @@ -89,9 +89,9 @@ import {AnnotationsTabComponent} from "../_single-module/annotations-tab/annotat import {UtcToLocalDatePipe} from "../_pipes/utc-to-locale-date.pipe"; import {ReadingProgressStatus} from "../_models/series-detail/reading-progress"; import {ReadingProgressStatusPipePipe} from "../_pipes/reading-progress-status-pipe.pipe"; +import {ReadingProgressIconPipePipe} from "../_pipes/reading-progress-icon-pipe.pipe"; enum TabID { - Chapters = 'chapters-tab', Related = 'related-tab', Reviews = 'reviews-tab', // Only applicable for books @@ -164,7 +164,8 @@ interface VolumeCast extends IHasCast { ExternalRatingComponent, AnnotationsTabComponent, UtcToLocalDatePipe, - ReadingProgressStatusPipePipe + ReadingProgressStatusPipePipe, + ReadingProgressIconPipePipe ], templateUrl: './volume-detail.component.html', styleUrl: './volume-detail.component.scss', @@ -230,6 +231,10 @@ export class VolumeDetailComponent implements OnInit { return chapters.reduce((min, curr) => Math.min(min, curr.totalReads), Infinity); }); + files = computed(() => { + const chapters = this.volume?.chapters || []; + return chapters.flatMap(c => c.files); + }); readingProgressStatus: ReadingProgressStatus = ReadingProgressStatus.NoProgress; mobileSeriesImgBackground: string | undefined; diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 3d3a74fab..ebeb16684 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -326,9 +326,7 @@ }, "theme-manager": { - "title": "Theme Manager", "description": "Kavita comes in many colors, find a color scheme that meets your needs or build one yourself and share it. Themes may be applied for your account or applied to all accounts.", - "site-themes": "Site Themes", "set-default": "Set Default", "default-theme": "Default", "download": "{{changelog.download}}", @@ -338,7 +336,6 @@ "applied": "Applied", "active-theme": "Active", "updated-toastr": "Site default has been updated to {{name}}", - "scan-queued": "A site theme scan has been queued", "delete": "{{common.delete}}", "drag-n-drop": "{{cover-image-chooser.drag-n-drop}}", "upload": "{{cover-image-chooser.upload}}", @@ -537,6 +534,13 @@ "books-count": "{{count}} books" }, + "time-frame-label": { + "overall": "Overall", + "week": "This week", + "month": "This month", + "year": "This year" + }, + "role-selector": { "title": "{{common.roles}}", "deselect-all": "{{common.deselect-all}}", @@ -671,7 +675,6 @@ "artist": "Artist", "character": "Character", "colorist": "Colorist", - "cover-artist": "{{artist}}", "editor": "Editor", "inker": "Inker", "letterer": "Letterer", @@ -1028,7 +1031,6 @@ "edit-title": "Edit Annotation", "create-title": "Create Annotation", "create": "Create", - "close": "{{common.close}}", "contains-spoilers-label": "{{annotation-card.contains-spoilers-label}}", "edit": "Edit", "delete": "Delete" @@ -1147,16 +1149,9 @@ "series-detail": { "page-settings-title": "Page Settings", "close": "{{common.close}}", - "layout-mode-label": "{{manage-reading-profiles.layout-mode-book-label}}", - "layout-mode-option-card": "Card", - "layout-mode-option-list": "List", "continue-from": "Continue {{title}}", "read": "{{common.read}}", - "continue": "Continue", - "read-incognito": "Read Incognito", - "continue-incognito": "Continue Incognito", "read-options-alt": "Read options", - "incognito": "Incognito", "remove-from-want-to-read": "{{actionable.remove-from-want-to-read}}", "add-to-want-to-read": "{{actionable.add-to-want-to-read}}", "edit-series-alt": "Edit Information", @@ -1177,17 +1172,6 @@ "writers-title": "{{metadata-fields.writers-title}}", "cover-artists-title": "{{metadata-fields.cover-artists-title}}", - "characters-title": "{{metadata-fields.characters-title}}", - "colorists-title": "{{metadata-fields.colorists-title}}", - "editors-title": "{{metadata-fields.editors-title}}", - "inkers-title": "{{metadata-fields.inkers-title}}", - "letterers-title": "{{metadata-fields.letterers-title}}", - "translators-title": "{{metadata-fields.translators-title}}", - "pencillers-title": "{{metadata-fields.pencillers-title}}", - "publishers-title": "{{metadata-fields.publishers-title}}", - "imprints-title": "{{metadata-fields.imprints-title}}", - "teams-title": "{{metadata-fields.teams-title}}", - "locations-title": "{{metadata-fields.locations-title}}", "genres-title": "{{metadata-fields.genres-title}}", "tags-title": "{{metadata-fields.tags-title}}", "ongoing": "{{publication-status-pipe.ongoing}}", @@ -1359,7 +1343,6 @@ "browse-person-title": "All Works of {{name}}", "browse-person-by-role-title": "All Works of {{name}} as a {{role}}", "all-roles": "{{common.roles}}", - "anilist-url": "{{edit-person-modal.anilist-tooltip}}", "no-info": "No information about this Person" }, @@ -1535,7 +1518,8 @@ "format-title": "{{metadata-filter.format-label}}", "length-title": "{{edit-chapter-modal.words-label}}", "age-rating-title": "{{metadata-fields.age-rating-title}}", - "file-path-title": "Folder path" + "folder-path-title": "Folder path", + "file-path-title": "Files" }, "related-tab": { @@ -1900,34 +1884,26 @@ }, "manage-users": { - "title": "Active Users", "invite": "Invite", - "you-alt": "(You)", "pending-title": "Pending", "delete-user-tooltip": "Delete User", "delete-user-alt": "Delete User {{user}}", "edit-user-tooltip": "Edit", "edit-user-alt": "Edit User {{user}}", - "username-pattern": "Username can only contain the following characters and whitespace: {{characters}}", "resend-invite-tooltip": "Resend Invite", "resend-invite-alt": "Resend Invite {{user}}", "setup-user-tooltip": "Setup User", "setup-user-alt": "Setup User {{user}}", "change-password-tooltip": "Change Password", "change-password-alt": "Change Password {{user}}", - "resend": "Resend", - "setup": "Setup", "last-active-header": "Last Active", "roles-header": "{{common.roles}}", "name-header": "Name", - "none": "None", - "never": "Never", "online-now-tooltip": "Online Now", "all-libraries": "All Libraries", "too-many-libraries": "A lot", "sharing-header": "Sharing", "no-data": "There are no other users.", - "loading": "{{common.loading}}", "actions-header": "Actions", "pending-tooltip": "This user has not validated their email", "identity-provider-oidc-tooltip": "OIDC", @@ -2416,7 +2392,6 @@ "original": "Original", "auto-close-menu-label": "{{manage-reading-profiles.auto-close-menu-label}}", "swipe-enabled-label": "Swipe Enabled", - "enable-comic-book-label": "Emulate comic book", "brightness-label": "Brightness", "bookmark-page-tooltip": "Bookmark Page", "unbookmark-page-tooltip": "Unbookmark Page", @@ -2730,15 +2705,8 @@ "files-over-time": { "title": "Files added over time", - "no-data": "{{common.no-data}}" - }, - - "manga-format-stats": { - "title": "Format", - "visualisation-label": "Visualisation", - "data-table-label": "Data Table", - "format-header": "Format", - "count-header": "Count" + "no-data": "{{common.no-data}}", + "most-files": "On {{date}}, {{count}} files were added" }, "publication-status-stats": { @@ -2786,15 +2754,17 @@ "total-read-time-label": "Total Read Time", "total-read-time-tooltip": "Total Read Time: {{count}}", "series": "series", + "users": "users", "reads": "reads", + "readers": "readers", "popular-decades-title": "Popular Decades", "most-active-users-title": "Most Active Users", "popular-libraries-title": "Popular Libraries", "popular-series-title": "Popular Series", + "popular-reading-lists-title": "Most Read Reading Lists", "popular-genres-title": "Popular Genres", "popular-tags-title": "Popular Tags", - "popular-artists-title": "Popular Artists", - "popular-authors-title": "Popular Authors", + "popular-person-title": "Popular {{role}}", "series-count": "{{num}} Series", @@ -2831,7 +2801,6 @@ "stats-tab": "{{tabs.stats-tab}}", "reviews-tab": "{{tabs.reviews-tab}}", "no-reviews": "No Reviews yet", - "user-possessive": "{{name}}'s", "total-reads-badge": "{{reads}} Reads", "k+-badge": "K+ Subscriber" }, @@ -2839,7 +2808,6 @@ "profile-review-list": { "no-data": "No Reviews or Reviews aren't being shared", "filter-label": "Filter by Series", - "rating-alt": "Filter by Rating", "clear-rating-alt": "Clear Rating Filter" }, @@ -3282,15 +3250,12 @@ "toasts": { - "regen-cover": "A job has been enqueued to regenerate the cover image", "no-pages": "There are no pages. Kavita was not able to read this archive.", - "download-in-progress": "Download is already in progress. Please wait.", "scan-queued": "Scan queued for {{name}}", "server-settings-updated": "Server settings updated", "reset-ip-address": "IP Addresses Reset", "reset-base-url": "Base Url Reset", "unauthorized-1": "You are not authorized to view this page.", - "unauthorized-2": "Unauthorized", "no-updates": "No updates available", "confirm-delete-user": "Are you sure you want to delete this user?", "user-deleted": "{{user}} has been deleted", @@ -3302,7 +3267,6 @@ "reading-lists-deleted": "Reading lists deleted", "reading-list-updated": "Reading list updated", "confirm-delete-reading-list": "Are you sure you want to delete the reading list? This cannot be undone.", - "confirm-delete-reading-lists": "Are you sure you want to delete the reading lists? This cannot be undone.", "item-removed": "Item removed", "nothing-to-remove": "Nothing to remove", "series-added-to-reading-list": "Series added to reading list", @@ -3312,12 +3276,8 @@ "select-files-warning": "You need to select files to move forward", "reading-list-imported": "Reading List imported", "incognito-off": "Incognito mode is off. Progress will now start being tracked.", - "email-service-reset": "Email Service Reset", - "email-service-reachable": "Kavita Email Connection Successful", - "email-service-unresponsive": "Email Service Url did not respond.", "refresh-covers-queued": "Refresh covers queued for {{name}}", "generate-colorscape-queued": "Generate colorscape queued for {{name}}", - "library-file-analysis-queued": "Library file analysis queued for {{name}}", "entity-read": "{{name}} is now read", "entity-unread": "{{name}} is now unread", "mark-read": "Marked as Read", @@ -3338,7 +3298,6 @@ "k+-reset-key-success": "Your license has been un-registered. Use Edit button to re-register your instance and re-activate Kavita+", "library-deleted": "Library {{name}} has been removed", "copied-to-clipboard": "Copied to clipboard", - "book-settings-info": "You can modify book settings, save those settings for all books, and view table of contents from the drawer.", "no-next-chapter": "Could not find next {{entity}}", "no-prev-chapter": "Could not find previous {{entity}}", "load-next-chapter": "Next {{entity}} loaded", @@ -3358,7 +3317,6 @@ "device-created": "Device created", "delete-device": "Are you sure you want to delete this device?", "confirm-regen-covers": "Refresh covers will force all cover images to be recalculated. This is a heavy operation. Are you sure you don't want to perform a Scan instead?", - "alert-long-running": "This is a long running process. Please give it the time to complete before invoking again.", "confirm-delete-multiple-series": "Are you sure you want to delete {{count}} series? It will not modify files on disk.", "confirm-delete-multiple-chapters": "Are you sure you want to delete {{count}} chapter/volumes? It will not modify files on disk.", "confirm-delete-multiple-volumes": "Are you sure you want to delete {{count}} volumes? It will not modify files on disk.", @@ -3382,8 +3340,6 @@ "collection-not-owned": "You do not own this collection", "collections-promoted": "Collections promoted", "collections-unpromoted": "Collections un-promoted", - "reading-lists-promoted": "Reading Lists promoted", - "reading-lists-unpromoted": "Reading Lists un-promoted", "reading-list-promoted": "Reading List promoted", "reading-list-unpromoted": "Reading List un-promoted", "confirm-delete-collections": "Are you sure you want to delete multiple collections?", @@ -3411,8 +3367,7 @@ "confirm-delete-font": "Removing this font will delete it from the disk. You can grab it from temp directory before removal.", "confirm-force-delete-font": "This font is currently in use. Do you want to force delete it? This will force users back to the Default font.", "font-in-use": "Cannot delete as the font is in use by one or more users.", - "k+-resend-welcome-email-success": "An email was sent to your Kavita+ email", - "k+-resend-welcome-email-error": "There was an error sending your Kavita+ license. This can be due to no longer having an active account. Reach out to support." + "k+-resend-welcome-email-success": "An email was sent to your Kavita+ email" }, "read-time-pipe": { @@ -3686,7 +3641,6 @@ "tasks-tab": "Tasks", "recommendations-tab": "Recommendations", "info-tab": "Info", - "progress-tab": "Progress", "tags-tab": "Tags", "weblink-tab": "Web Links", "people-tab": "People", diff --git a/UI/Web/src/theme/components/_stat-card.scss b/UI/Web/src/theme/components/_stat-card.scss index 1ef983cce..3b348adca 100644 --- a/UI/Web/src/theme/components/_stat-card.scss +++ b/UI/Web/src/theme/components/_stat-card.scss @@ -19,6 +19,7 @@ // A note at the bottom, like "Missing genres for X series" .stats-note { margin-top: 1.5rem; + font-size: 0.75rem; } } diff --git a/UI/Web/src/theme/components/_table.scss b/UI/Web/src/theme/components/_table.scss index 201f9f43d..6bc1d056a 100644 --- a/UI/Web/src/theme/components/_table.scss +++ b/UI/Web/src/theme/components/_table.scss @@ -71,3 +71,4 @@ th[sortable].desc:after { +