diff --git a/.gitignore b/.gitignore index 4bc9c1605..d8fb6b15c 100644 --- a/.gitignore +++ b/.gitignore @@ -537,6 +537,7 @@ UI/Web/.vscode/settings.json /API.Tests/Services/Test Data/ArchiveService/CoverImages/output/* UI/Web/.angular/ BenchmarkDotNet.Artifacts +.claude/ API.Tests/Services/Test Data/ImageService/**/*_output* diff --git a/API.Tests/Services/OidcServiceTests.cs b/API.Tests/Services/OidcServiceTests.cs index 267d8daf3..780972c9d 100644 --- a/API.Tests/Services/OidcServiceTests.cs +++ b/API.Tests/Services/OidcServiceTests.cs @@ -581,8 +581,11 @@ public class OidcServiceTests(ITestOutputHelper outputHelper): AbstractDbTest(ou await userManager.CreateAsync(user); await userManager.CreateAsync(defaultAdmin); - var accountService = new AccountService(userManager, Substitute.For>(), unitOfWork, mapper, Substitute.For()); - var oidcService = new OidcService(Substitute.For>(), userManager, unitOfWork, accountService, Substitute.For()); + var accountService = new AccountService(userManager, Substitute.For>(), + unitOfWork, mapper, Substitute.For()); + var oidcService = new OidcService(Substitute.For>(), userManager, unitOfWork, + accountService, Substitute.For()); + return (oidcService, user, accountService, userManager); } } diff --git a/API.Tests/Services/OpdsServiceTests.cs b/API.Tests/Services/OpdsServiceTests.cs index 8d908caeb..dd8289d4f 100644 --- a/API.Tests/Services/OpdsServiceTests.cs +++ b/API.Tests/Services/OpdsServiceTests.cs @@ -6,15 +6,17 @@ using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; +using API.DTOs.OPDS; using API.DTOs.OPDS.Requests; using API.DTOs.Progress; +using API.Constants; using API.Entities; using API.Entities.Enums; +using API.Helpers; using API.Helpers.Builders; using API.Services; using API.Services.Plus; using API.SignalR; -using API.Tests.Helpers; using AutoMapper; using Hangfire; using Hangfire.InMemory; @@ -32,7 +34,7 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe #region Setup - private Tuple SetupService(IUnitOfWork unitOfWork, IMapper mapper) + private static Tuple SetupService(IUnitOfWork unitOfWork, IMapper mapper) { JobStorage.Current = new InMemoryStorage(); @@ -57,7 +59,7 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe private async Task SetupSeriesAndUser(DataContext context, IUnitOfWork unitOfWork, int numberOfSeries = 1) { - var library = new LibraryBuilder("Test Lib", LibraryType.Manga).Build(); + var library = new LibraryBuilder("Test Lib").Build(); unitOfWork.LibraryRepository.Add(library); await unitOfWork.CommitAsync(); @@ -66,6 +68,7 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe context.AppUser.Add(new AppUserBuilder("majora2007", "majora2007") .WithLibrary(library) .WithLocale("en") + .WithRole(PolicyConstants.AdminRole) .Build()); await context.SaveChangesAsync(); @@ -97,28 +100,118 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe await unitOfWork.CommitAsync(); - - - var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); + var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress | AppUserIncludes.WantToRead | AppUserIncludes.Collections); Assert.NotNull(user); - // // Build a reading list - // - // var readingList = new ReadingListBuilder("Test RL").WithAppUserId(user.Id).WithItem(new ReadingListItem - // { - // SeriesId = 1, - // VolumeId = 1, - // ChapterId = 0, - // Order = 0, - // Series = null, - // Volume = null, - // Chapter = null - // }) - + // Setup SideNav streams for library + user.SideNavStreams = new List + { + new AppUserSideNavStream + { + Name = library.Name, + IsProvided = true, + Order = 0, + StreamType = SideNavStreamType.Library, + Visible = true, + LibraryId = library.Id, + AppUserId = user.Id + } + }; + await unitOfWork.CommitAsync(); return user; } + private static async Task CreateCollection(IUnitOfWork unitOfWork, string title, int userId, params int[] seriesIds) + { + var collectionBuilder = new AppUserCollectionBuilder(title, promoted: true); + + foreach (var seriesId in seriesIds) + { + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series != null) + { + collectionBuilder.WithItem(series); + } + } + + var collection = collectionBuilder.Build(); + + // Get the user and add collection + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Collections); + if (user != null) + { + user.Collections.Add(collection); + await unitOfWork.CommitAsync(); + } + + return collection; + } + + private static async Task CreateReadingList(DataContext context, IUnitOfWork unitOfWork, string title, int userId, List<(int seriesId, int volumeId, int chapterId)> items) + { + var readingList = new ReadingListBuilder(title) + .WithAppUserId(userId) + .Build(); + + var order = 0; + foreach (var (seriesId, volumeId, chapterId) in items) + { + var readingListItem = new ReadingListItem + { + SeriesId = seriesId, + VolumeId = volumeId, + ChapterId = chapterId, + Order = order++ + }; + readingList.Items.Add(readingListItem); + } + + context.ReadingList.Add(readingList); + await unitOfWork.CommitAsync(); + + return readingList; + } + + private static async Task CreateSmartFilter(DataContext context, int userId, string name, string filter) + { + var smartFilter = new AppUserSmartFilter + { + Name = name, + Filter = filter, + AppUserId = userId + }; + + context.AppUserSmartFilter.Add(smartFilter); + await context.SaveChangesAsync(); + + return smartFilter; + } + + private static void ValidatePaginationLinks(Feed feed, int pageNumber, bool expectNext, bool expectPrev) + { + var nextLink = feed.Links.FirstOrDefault(l => l.Rel == FeedLinkRelation.Next); + var prevLink = feed.Links.FirstOrDefault(l => l.Rel == FeedLinkRelation.Prev); + + if (expectNext) + { + Assert.NotNull(nextLink); + } + else + { + Assert.Null(nextLink); + } + + if (expectPrev) + { + Assert.NotNull(prevLink); + } + else + { + Assert.Null(prevLink); + } + } + #endregion #region Continue Points @@ -128,19 +221,12 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe { var (unitOfWork, context, mapper) = await CreateDatabase(); var (opdsService, readerService) = SetupService(unitOfWork, mapper); - - var user = await SetupSeriesAndUser(context, unitOfWork); var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); - Assert.NotNull(firstChapter); - - // Mark Chapter 1 as read await readerService.MarkChaptersAsRead(user, 1, [firstChapter]); - Assert.True(unitOfWork.HasChanges()); await unitOfWork.CommitAsync(); - // Generate Series Feed and validate first element is a Continue From Chapter 2 var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest { ApiKey = user.ApiKey, @@ -151,26 +237,62 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe PageNumber = 0 }); - Assert.NotEmpty(feed.Entries); Assert.Equal(3, feed.Entries.Count); Assert.StartsWith("Continue Reading from", feed.Entries.First().Title); } - [Fact] public async Task ContinuePoint_DoesntExist_WhenNoProgress() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork); + + var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = 1, + PageNumber = 0 + }); + + Assert.Equal(2, feed.Entries.Count); + } + #endregion + + #region Reading Progress Icons + + [Theory] + [InlineData(0, "NoReadingProgressIcon", 0)] // No progress + [InlineData(2, "QuarterReadingProgressIcon", 0)] // 2/10 pages = quarter + [InlineData(5, "HalfReadingProgressIcon", 0)] // 5/10 pages = half + [InlineData(7, "AboveHalfReadingProgressIcon", 0)] // 7/10 pages = above half + [InlineData(10, "FullReadingProgressIcon", 1)] // 10/10 pages = full (shows in continue reading) + public async Task ReadingProgressIconEncoding(int pageNum, string expectedIconField, int entryIndex) { var (unitOfWork, context, mapper) = await CreateDatabase(); var (opdsService, readerService) = SetupService(unitOfWork, mapper); - - var user = await SetupSeriesAndUser(context, unitOfWork); var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); Assert.NotNull(firstChapter); + if (pageNum > 0) + { + await readerService.SaveReadingProgress(new ProgressDto + { + VolumeId = firstChapter.VolumeId, + ChapterId = firstChapter.Id, + PageNum = pageNum, + SeriesId = 1, + LibraryId = 1, + BookScrollId = null, + LastModifiedUtc = default + }, user.Id); + } - // Generate Series Feed and validate first element is a Continue From Chapter 2 var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest { ApiKey = user.ApiKey, @@ -182,196 +304,319 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe }); Assert.NotEmpty(feed.Entries); - Assert.Equal(2, feed.Entries.Count); + var expectedIcon = typeof(OpdsService).GetField(expectedIconField)?.GetValue(null) as string; + Assert.NotNull(expectedIcon); + Assert.Contains(expectedIcon, feed.Entries[entryIndex].Title); } + #endregion #region Misc [Fact] - public async Task NoProgressEncoding() + public async Task Search_EmptyQuery_ThrowsException() { var (unitOfWork, context, mapper) = await CreateDatabase(); - var (opdsService, readerService) = SetupService(unitOfWork, mapper); - - + var (opdsService, _) = SetupService(unitOfWork, mapper); var user = await SetupSeriesAndUser(context, unitOfWork); - var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); - Assert.NotNull(firstChapter); - - - // Generate Series Feed and validate first element is a Continue From Chapter 2 - var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + await Assert.ThrowsAsync(async () => { - ApiKey = user.ApiKey, - Prefix = OpdsService.DefaultApiPrefix, - BaseUrl = string.Empty, - UserId = user.Id, - EntityId = 1, - PageNumber = 0 + await opdsService.Search(new OpdsSearchRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + Query = string.Empty + }); }); - - Assert.NotEmpty(feed.Entries); - Assert.Contains(OpdsService.NoReadingProgressIcon, feed.Entries.First().Title); } [Fact] - public async Task QuarterProgressEncoding() + public async Task GetCatalogue_ContainsDashboardStreams() { var (unitOfWork, context, mapper) = await CreateDatabase(); - var (opdsService, readerService) = SetupService(unitOfWork, mapper); - - + var (opdsService, _) = SetupService(unitOfWork, mapper); var user = await SetupSeriesAndUser(context, unitOfWork); - var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); - Assert.NotNull(firstChapter); - - // Mark Chapter 1 as read - await readerService.SaveReadingProgress(new ProgressDto + // Setup dashboard streams + user.DashboardStreams = new List { - VolumeId = firstChapter.VolumeId, - ChapterId = firstChapter.Id, - PageNum = 2, // 10 total pages - SeriesId = 1, - LibraryId = 1, - BookScrollId = null, - LastModifiedUtc = default - }, user.Id); + new AppUserDashboardStream + { + Name = "On Deck", + IsProvided = true, + Order = 0, + StreamType = DashboardStreamType.OnDeck, + Visible = true, + AppUserId = user.Id + }, + new AppUserDashboardStream + { + Name = "Recently Added", + IsProvided = true, + Order = 1, + StreamType = DashboardStreamType.NewlyAdded, + Visible = true, + AppUserId = user.Id + } + }; + await unitOfWork.CommitAsync(); - // Generate Series Feed and validate first element is a Continue From Chapter 2 - var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + var feed = await opdsService.GetCatalogue(new OpdsCatalogueRequest { ApiKey = user.ApiKey, Prefix = OpdsService.DefaultApiPrefix, BaseUrl = string.Empty, - UserId = user.Id, - EntityId = 1, - PageNumber = 0 + UserId = user.Id }); Assert.NotEmpty(feed.Entries); - Assert.Contains(OpdsService.QuarterReadingProgressIcon, feed.Entries.First().Title); + Assert.Contains(feed.Entries, e => e.Id == "onDeck"); + Assert.Contains(feed.Entries, e => e.Id == "recentlyAdded"); + Assert.Contains(feed.Entries, e => e.Id == "readingList"); + Assert.Contains(feed.Entries, e => e.Id == "wantToRead"); + Assert.Contains(feed.Entries, e => e.Id == "allLibraries"); + Assert.Contains(feed.Entries, e => e.Id == "allCollections"); } + #endregion + + #region Paginated Catalogue Tests [Fact] - public async Task HalfProgressEncoding() + public async Task GetSmartFilters_WithPagination() { var (unitOfWork, context, mapper) = await CreateDatabase(); - var (opdsService, readerService) = SetupService(unitOfWork, mapper); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5); - - var user = await SetupSeriesAndUser(context, unitOfWork); - - var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); - Assert.NotNull(firstChapter); - - // Mark Chapter 1 as read - await readerService.SaveReadingProgress(new ProgressDto + // Create smart filters (more than page size) + for (var i = 0; i < OpdsService.PageSize + 5; i++) { - VolumeId = firstChapter.VolumeId, - ChapterId = firstChapter.Id, - PageNum = 5, // 10 total pages - SeriesId = 1, - LibraryId = 1, - BookScrollId = null, - LastModifiedUtc = default - }, user.Id); + await CreateSmartFilter(context, user.Id, $"Filter {i}", "combination=0"); + } - // Generate Series Feed and validate first element is a Continue From Chapter 2 - var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + // Test page 1 + var feed = await opdsService.GetSmartFilters(new OpdsPaginatedCatalogueRequest { ApiKey = user.ApiKey, Prefix = OpdsService.DefaultApiPrefix, BaseUrl = string.Empty, UserId = user.Id, - EntityId = 1, - PageNumber = 0 + PageNumber = OpdsService.FirstPageNumber }); - Assert.NotEmpty(feed.Entries); - Assert.Contains(OpdsService.HalfReadingProgressIcon, feed.Entries.First().Title); + Assert.Equal(OpdsService.PageSize, feed.Entries.Count); + Assert.Equal(OpdsService.PageSize + 5, feed.Total); + ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false); } [Fact] - public async Task AboveHalfProgressEncoding() + public async Task GetLibraries_ReturnsAllLibraries() { var (unitOfWork, context, mapper) = await CreateDatabase(); - var (opdsService, readerService) = SetupService(unitOfWork, mapper); - - + var (opdsService, _) = SetupService(unitOfWork, mapper); var user = await SetupSeriesAndUser(context, unitOfWork); - var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); - Assert.NotNull(firstChapter); - - // Mark Chapter 1 as read - await readerService.SaveReadingProgress(new ProgressDto - { - VolumeId = firstChapter.VolumeId, - ChapterId = firstChapter.Id, - PageNum = 7, // 10 total pages - SeriesId = 1, - LibraryId = 1, - BookScrollId = null, - LastModifiedUtc = default - }, user.Id); - - // Generate Series Feed and validate first element is a Continue From Chapter 2 - var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + var feed = await opdsService.GetLibraries(new OpdsPaginatedCatalogueRequest { ApiKey = user.ApiKey, Prefix = OpdsService.DefaultApiPrefix, BaseUrl = string.Empty, UserId = user.Id, - EntityId = 1, - PageNumber = 0 + PageNumber = OpdsService.FirstPageNumber }); - Assert.NotEmpty(feed.Entries); - Assert.Contains(OpdsService.AboveHalfReadingProgressIcon, feed.Entries.First().Title); + Assert.Single(feed.Entries); + Assert.Contains("Test Lib", feed.Entries.First().Title); } [Fact] - public async Task FullProgressEncoding() + public async Task GetWantToRead_WithPagination() { var (unitOfWork, context, mapper) = await CreateDatabase(); - var (opdsService, readerService) = SetupService(unitOfWork, mapper); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5); - - var user = await SetupSeriesAndUser(context, unitOfWork); - - var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); - Assert.NotNull(firstChapter); - - // Mark Chapter 1 as read - await readerService.SaveReadingProgress(new ProgressDto + // Mark series as want to read + for (var i = 1; i <= OpdsService.PageSize + 5; i++) { - VolumeId = firstChapter.VolumeId, - ChapterId = firstChapter.Id, - PageNum = 10, // 10 total pages - SeriesId = 1, - LibraryId = 1, - BookScrollId = null, - LastModifiedUtc = default - }, user.Id); + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(i); + if (series != null) + { + user.WantToRead.Add(new AppUserWantToRead + { + SeriesId = series.Id, + AppUserId = user.Id + }); + } + } + await unitOfWork.CommitAsync(); - // Generate Series Feed and validate first element is a Continue From Chapter 2 - var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + // Test page 1 + var feed = await opdsService.GetWantToRead(new OpdsPaginatedCatalogueRequest { ApiKey = user.ApiKey, Prefix = OpdsService.DefaultApiPrefix, BaseUrl = string.Empty, UserId = user.Id, - EntityId = 1, - PageNumber = 0 + PageNumber = OpdsService.FirstPageNumber + }); + + Assert.Equal(OpdsService.PageSize, feed.Entries.Count); + ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false); + } + + [Fact] + public async Task GetCollections_WithPagination() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5); + + // Create collections (more than page size) + var firstSeries = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + user.Collections ??= new List(); + + for (var i = 0; i < OpdsService.PageSize + 5; i++) + { + user.Collections.Add(new AppUserCollectionBuilder($"Collection {i}").WithItem(firstSeries).Build()); + } + + await unitOfWork.CommitAsync(); + + // Test page 1 + var feed = await opdsService.GetCollections(new OpdsPaginatedCatalogueRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + PageNumber = OpdsService.FirstPageNumber + }); + + // Collections should exist + Assert.NotEmpty(feed.Entries); + + // If we have more than page size, verify pagination + if (feed.Total > OpdsService.PageSize) + { + Assert.Equal(OpdsService.PageSize, feed.Entries.Count); + ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false); + } + } + + [Fact] + public async Task GetReadingLists_WithPagination() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5); + + // Create reading lists (more than page size) + for (var i = 0; i < OpdsService.PageSize + 5; i++) + { + await CreateReadingList(context, unitOfWork, $"Reading List {i}", user.Id, + [(1, 1, 1)]); + } + + // Test page 1 + var feed = await opdsService.GetReadingLists(new OpdsPaginatedCatalogueRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + PageNumber = OpdsService.FirstPageNumber + }); + + Assert.Equal(OpdsService.PageSize, feed.Entries.Count); + ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false); + } + + [Fact] + public async Task GetRecentlyAdded_WithPagination() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5); + + // Test page 1 + var feed = await opdsService.GetRecentlyAdded(new OpdsPaginatedCatalogueRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + PageNumber = OpdsService.FirstPageNumber + }); + + Assert.Equal(OpdsService.PageSize, feed.Entries.Count); + ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false); + } + + [Fact] + public async Task GetRecentlyUpdated_WithPagination() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, readerService) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5); + + // Mark some chapters as read to create updated series + for (var i = 1; i <= OpdsService.PageSize + 5; i++) + { + var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(i * 2 - 1); + if (chapter != null) + { + await readerService.MarkChaptersAsRead(user, i, [chapter]); + } + } + await unitOfWork.CommitAsync(); + + // Test page 1 + var feed = await opdsService.GetRecentlyUpdated(new OpdsPaginatedCatalogueRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + PageNumber = OpdsService.FirstPageNumber + }); + + Assert.NotEmpty(feed.Entries); + } + + [Fact] + public async Task GetOnDeck_WithPagination() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, readerService) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5); + + // Mark first chapter as read for each series to create on-deck items + for (var i = 1; i <= OpdsService.PageSize + 5; i++) + { + var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(i * 2 - 1); + if (chapter != null) + { + await readerService.MarkChaptersAsRead(user, i, [chapter]); + } + } + await unitOfWork.CommitAsync(); + + // Test page 1 + var feed = await opdsService.GetOnDeck(new OpdsPaginatedCatalogueRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + PageNumber = OpdsService.FirstPageNumber }); Assert.NotEmpty(feed.Entries); - Assert.Equal(3, feed.Entries.Count); - Assert.Contains(OpdsService.FullReadingProgressIcon, feed.Entries[1].Title); // The continue from will show the 2nd chapter } #endregion @@ -382,10 +627,12 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe public async Task PaginationWorks() { var (unitOfWork, context, mapper) = await CreateDatabase(); - var (opdsService, readerService) = SetupService(unitOfWork, mapper); - var user = await SetupSeriesAndUser(context, unitOfWork, 100); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize * 2); - var libs = await unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(1); + var libs = (await unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(1)).ToList(); + + // Test page 1 var feed = await opdsService.GetSeriesFromLibrary(new OpdsItemsFromEntityIdRequest() { ApiKey = user.ApiKey, @@ -397,6 +644,10 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe }); Assert.Equal(OpdsService.PageSize, feed.Entries.Count); + Assert.Equal(OpdsService.PageSize * 2, feed.Total); + ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false); + + // Test page 2 var feed2 = await opdsService.GetSeriesFromLibrary(new OpdsItemsFromEntityIdRequest() { ApiKey = user.ApiKey, @@ -404,20 +655,348 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe BaseUrl = string.Empty, UserId = user.Id, EntityId = libs.First().Id, + PageNumber = OpdsService.FirstPageNumber + 1 + }); + Assert.Equal(OpdsService.PageSize, feed2.Entries.Count); + + // Ensure there is no overlap between page 1 and page 2 + var page1Ids = feed.Entries.Select(e => e.Id).ToList(); + var page2Ids = feed2.Entries.Select(e => e.Id).ToList(); + Assert.Empty(page1Ids.Intersect(page2Ids)); + + // Validate page 2 pagination - should have prev link but no next link (last page) + ValidatePaginationLinks(feed2, OpdsService.FirstPageNumber + 1, expectNext: false, expectPrev: true); + } + + [Fact] + public async Task GetMoreInGenre_WithPagination() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5); + + // Add genre to all series + var genre = new GenreBuilder("Action").Build(); + context.Genre.Add(genre); + await context.SaveChangesAsync(); + + for (var i = 1; i <= OpdsService.PageSize + 5; i++) + { + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(i); + if (series?.Metadata != null) + { + series.Metadata.Genres.Add(genre); + } + } + await unitOfWork.CommitAsync(); + + // Test page 1 + var feed = await opdsService.GetMoreInGenre(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = genre.Id, PageNumber = OpdsService.FirstPageNumber }); + Assert.Equal(OpdsService.PageSize, feed.Entries.Count); - - // Ensure there is no overlap - Assert.NotSame(feed.Entries.Select(e => e.Id), feed2.Entries.Select(e => e.Id)); - - - - + ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false); } + #endregion #region Detail Feeds + + [Fact] + public async Task GetSeriesFromSmartFilter_WithPagination() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5); + + // Create a smart filter that matches series containing "Test" (all test series) + var smartFilter = await CreateSmartFilter(context, user.Id, "Test Filter", "combination=0"); + + // Test page 1 + var feed = await opdsService.GetSeriesFromSmartFilter(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = smartFilter.Id, + PageNumber = OpdsService.FirstPageNumber + }); + + Assert.Equal(OpdsService.PageSize, feed.Entries.Count); + ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false); + } + + [Fact] + public async Task GetSeriesFromCollection_WithPagination() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5); + + // Create a collection with PageSize + 1 series + var seriesIds = Enumerable.Range(1, OpdsService.PageSize + 1).ToArray(); + var collection = await CreateCollection(unitOfWork, "Test Collection", user.Id, seriesIds); + + // Test page 1 + var feed = await opdsService.GetSeriesFromCollection(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = collection.Id, + PageNumber = OpdsService.FirstPageNumber + }); + + Assert.NotEmpty(feed.Entries); + + // If we have pagination, verify it + if (feed.Total > OpdsService.PageSize) + { + Assert.Equal(OpdsService.PageSize, feed.Entries.Count); + ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false); + } + + // Validate all entries are from the collection + foreach (var entry in feed.Entries) + { + var seriesId = int.Parse(entry.Id); + Assert.True(seriesId <= OpdsService.PageSize + 5); + } + } + + [Fact] + public async Task GetSeriesFromLibrary_WithPagination() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5); + + var libraries = await unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id); + var library = libraries.First(); + + // Test page 1 + var feed = await opdsService.GetSeriesFromLibrary(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = library.Id, + PageNumber = OpdsService.FirstPageNumber + }); + + Assert.Equal(OpdsService.PageSize, feed.Entries.Count); + ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false); + } + + [Fact] + public async Task GetReadingListItems_WithPagination() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5); + + // Create reading list with items from all series (chapter 1 only) + var items = new List<(int, int, int)>(); + for (var i = 1; i <= OpdsService.PageSize + 5; i++) + { + items.Add((i, 1, i * 2 - 1)); // seriesId, volumeId, chapterId + } + var readingList = await CreateReadingList(context, unitOfWork, "Test Reading List", user.Id, items); + + // Test page 1 + var feed = await opdsService.GetReadingListItems(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = readingList.Id, + PageNumber = OpdsService.FirstPageNumber + }); + + + Assert.True(feed.Entries.Count == UserParams.Default.PageSize); + Assert.True(feed.Total >= OpdsService.PageSize); + + // Validate pagination - reading lists have complex pagination due to continue reading items + // First page should never have prev link + var prevLink = feed.Links.FirstOrDefault(l => l.Rel == FeedLinkRelation.Prev); + Assert.Null(prevLink); + } + + /// + /// Reading lists have unique pagination implementation thus need explicit testing + /// + [Fact] + public async Task GetReadingListItems_ContinueFromItem_WhenFirst2PagesFullyRead() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, readerService) = SetupService(unitOfWork, mapper); + + // Create enough series for 2+ pages (UserParams.Default.PageSize * 2 + 5) + var totalItems = UserParams.Default.PageSize * 2 + 5; + var user = await SetupSeriesAndUser(context, unitOfWork, totalItems); + + // Create reading list with items from all series (chapter 1 only) + var items = new List<(int, int, int)>(); + for (var i = 1; i <= totalItems; i++) + { + items.Add((i, 1, i * 2 - 1)); // seriesId, volumeId, chapterId + } + var readingList = await CreateReadingList(context, unitOfWork, "Test Reading List", user.Id, items); + + // Mark all chapters in first 2 pages as fully read + var itemsInFirst2Pages = UserParams.Default.PageSize * 2; + for (var i = 1; i <= itemsInFirst2Pages; i++) + { + var chapterId = i * 2 - 1; + var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId); + if (chapter != null) + { + await readerService.MarkChaptersAsRead(user, i, [chapter]); + } + } + await unitOfWork.CommitAsync(); + + // Test page 1 - should include continue reading item at the top + var feed = await opdsService.GetReadingListItems(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = readingList.Id, + PageNumber = OpdsService.FirstPageNumber + }); + + Assert.NotEmpty(feed.Entries); + + // Verify continue reading item is inserted at the top + var firstEntry = feed.Entries.First(); + Assert.Contains("Continue Reading from", firstEntry.Title); + + // The continue reading should point to the first unread item (page 3, first item) + var expectedNextUnreadItemIndex = itemsInFirst2Pages + 1; // First item of page 3 + Assert.Contains($"Test {expectedNextUnreadItemIndex}", firstEntry.Title); + } + + [Fact] + public async Task GetSeriesDetail_ReturnsChapters() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork); + + var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = 1, + PageNumber = OpdsService.FirstPageNumber + }); + + Assert.NotEmpty(feed.Entries); + Assert.Equal(2, feed.Entries.Count); // 2 chapters + } + + [Fact] + public async Task GetItemsFromVolume_ReturnsChapters() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork); + + var feed = await opdsService.GetItemsFromVolume(new OpdsItemsFromCompoundEntityIdsRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + SeriesId = 1, + VolumeId = 1, + PageNumber = OpdsService.FirstPageNumber + }); + + Assert.NotEmpty(feed.Entries); + // May include continue reading item if there's progress + Assert.True(feed.Entries.Count >= 2); + } + + [Fact] + public async Task GetItemsFromChapter_ReturnsFiles() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork); + + var feed = await opdsService.GetItemsFromChapter(new OpdsItemsFromCompoundEntityIdsRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + SeriesId = 1, + VolumeId = 1, + ChapterId = 1, + PageNumber = OpdsService.FirstPageNumber + }); + + Assert.NotEmpty(feed.Entries); + Assert.Single(feed.Entries); // Each chapter has 1 file + } + + #endregion + + #region XML Serialization + + [Fact] + public async Task SerializeXml_ProducesValidXml() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork); + + var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = 1, + PageNumber = OpdsService.FirstPageNumber + }); + + var xml = opdsService.SerializeXml(feed); + + Assert.NotEmpty(xml); + Assert.Contains("", xml); + Assert.Contains("utf-8", xml); + Assert.DoesNotContain("utf-16", xml); + } + + [Fact] + public async Task SerializeXml_HandlesNullFeed() + { + var (unitOfWork, _, mapper) = await CreateDatabase(); + var (opdsService, _) = SetupService(unitOfWork, mapper); + + var xml = opdsService.SerializeXml(null); + + Assert.Empty(xml); + } + #endregion } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 6281c2888..4916cb116 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -274,14 +274,14 @@ public class ReaderController : BaseApiController if (info.IsSpecial) { info.Subtitle = Path.GetFileNameWithoutExtension(info.FileName); - } else if (!info.IsSpecial && info.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)) + } else if (!info.IsSpecial && info.VolumeNumber.Equals(Parser.LooseLeafVolume)) { info.Subtitle = ReaderService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber; } else { info.Subtitle = await _localizationService.Translate(User.GetUserId(), "volume-num", info.VolumeNumber); - if (!info.ChapterNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultChapter)) + if (!info.ChapterNumber.Equals(Parser.DefaultChapter)) { info.Subtitle += " " + ReaderService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber; diff --git a/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs b/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs index 2cd9b5896..aaf4eded8 100644 --- a/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs +++ b/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs @@ -32,4 +32,8 @@ public sealed record LicenseInfoDto /// A license is stored within Kavita /// public bool HasLicense { get; set; } + /// + /// InstallId which can be given to support + /// + public string InstallId { get; set; } } diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 721dec4d7..c11c94725 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -58,6 +58,8 @@ public interface IReadingListRepository Task> GetReadingListsByIds(IList ids, ReadingListIncludes includes = ReadingListIncludes.Items); Task> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items); Task GetReadingListInfoAsync(int readingListId); + Task AnyUserReadingProgressAsync(int readingListId, int userId); + Task GetContinueReadingPoint(int readingListId, int userId); } public class ReadingListRepository : IReadingListRepository @@ -357,13 +359,128 @@ public class ReadingListRepository : IReadingListRepository .SingleOrDefaultAsync(); } + public async Task AnyUserReadingProgressAsync(int readingListId, int userId) + { + // Since the list is already created, we can assume RBS doesn't need to apply + var chapterIdsQuery = _context.ReadingListItem + .Where(s => s.ReadingListId == readingListId) + .Select(s => s.ChapterId) + .AsQueryable(); + + return await _context.AppUserProgresses + .Where(p => chapterIdsQuery.Contains(p.ChapterId) && p.AppUserId == userId) + .AsNoTracking() + .AnyAsync(); + } + + public async Task GetContinueReadingPoint(int readingListId, int userId) + { + var userLibraries = _context.Library.GetUserLibraries(userId); + + var query = _context.ReadingListItem + .Where(rli => rli.ReadingListId == readingListId) + .Join(_context.Chapter, rli => rli.ChapterId, chapter => chapter.Id, (rli, chapter) => new + { + ReadingListItem = rli, + Chapter = chapter, + }) + .Join(_context.Volume, x => x.ReadingListItem.VolumeId, volume => volume.Id, (x, volume) => new + { + x.ReadingListItem, + x.Chapter, + Volume = volume + }) + .Join(_context.Series, x => x.ReadingListItem.SeriesId, series => series.Id, (x, series) => new + { + x.ReadingListItem, + x.Chapter, + x.Volume, + Series = series + }) + .Where(x => userLibraries.Contains(x.Series.LibraryId)) + .GroupJoin(_context.AppUserProgresses.Where(p => p.AppUserId == userId), + x => x.ReadingListItem.ChapterId, + progress => progress.ChapterId, + (x, progressGroup) => new + { + x.ReadingListItem, + x.Chapter, + x.Volume, + x.Series, + ProgressGroup = progressGroup + }) + .SelectMany( + x => x.ProgressGroup.DefaultIfEmpty(), + (x, progress) => new + { + x.ReadingListItem, + x.Chapter, + x.Volume, + x.Series, + Progress = progress, + PagesRead = progress != null ? progress.PagesRead : 0, + HasProgress = progress != null, + IsPartiallyRead = progress != null && progress.PagesRead > 0 && progress.PagesRead < x.Chapter.Pages, + IsUnread = progress == null || progress.PagesRead == 0 + }) + .OrderBy(x => x.ReadingListItem.Order); + + // First try to find a partially read item then the first unread item + var item = await query + .OrderBy(x => x.IsPartiallyRead ? 0 : x.IsUnread ? 1 : 2) + .ThenBy(x => x.ReadingListItem.Order) + .FirstOrDefaultAsync(); + + + if (item == null) return null; + + // Map to DTO + var library = await _context.Library + .Where(l => l.Id == item.Series.LibraryId) + .Select(l => new { l.Name, l.Type }) + .FirstAsync(); + + var dto = new ReadingListItemDto + { + Id = item.ReadingListItem.Id, + ChapterId = item.ReadingListItem.ChapterId, + Order = item.ReadingListItem.Order, + SeriesId = item.ReadingListItem.SeriesId, + SeriesName = item.Series.Name, + SeriesFormat = item.Series.Format, + PagesTotal = item.Chapter.Pages, + PagesRead = item.PagesRead, + ChapterNumber = item.Chapter.Range, + VolumeNumber = item.Volume.Name, + LibraryId = item.Series.LibraryId, + VolumeId = item.Volume.Id, + ReadingListId = item.ReadingListItem.ReadingListId, + ReleaseDate = item.Chapter.ReleaseDate, + LibraryType = library.Type, + ChapterTitleName = item.Chapter.TitleName, + LibraryName = library.Name, + FileSize = item.Chapter.Files.Sum(f => f.Bytes), // TODO: See if we can put FileSize on the chapter in future + Summary = item.Chapter.Summary, + IsSpecial = item.Chapter.IsSpecial, + LastReadingProgressUtc = item.Progress?.LastModifiedUtc + }; + + dto.Title = ReadingListService.FormatTitle(dto); + + return dto; + } + + public async Task> GetReadingListItemDtosByIdAsync(int readingListId, int userId, UserParams? userParams = null) { var userLibraries = _context.Library.GetUserLibraries(userId); var query = _context.ReadingListItem - .Where(s => s.ReadingListId == readingListId) - .Join(_context.Chapter, s => s.ChapterId, chapter => chapter.Id, (data, chapter) => new + .Where(s => s.ReadingListId == readingListId) + .Join(_context.Chapter, + s => s.ChapterId, + chapter => chapter.Id, + (data, chapter) => new { TotalPages = chapter.Pages, ChapterNumber = chapter.Range, @@ -373,9 +490,11 @@ public class ReadingListRepository : IReadingListRepository FileSize = chapter.Files.Sum(f => f.Bytes), chapter.Summary, chapter.IsSpecial - }) - .Join(_context.Volume, s => s.ReadingListItem.VolumeId, volume => volume.Id, (data, volume) => new + .Join(_context.Volume, + s => s.ReadingListItem.VolumeId, + volume => volume.Id, + (data, volume) => new { data.ReadingListItem, data.TotalPages, @@ -388,59 +507,68 @@ public class ReadingListRepository : IReadingListRepository VolumeId = volume.Id, VolumeNumber = volume.Name, }) - .Join(_context.Series, s => s.ReadingListItem.SeriesId, series => series.Id, - (data, s) => new - { - SeriesName = s.Name, - SeriesFormat = s.Format, - s.LibraryId, - data.ReadingListItem, - data.TotalPages, - data.ChapterNumber, - data.VolumeNumber, - data.VolumeId, - data.ReleaseDate, - data.ChapterTitleName, - data.FileSize, - data.Summary, - data.IsSpecial, - LibraryName = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Name).Single(), - LibraryType = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Type).Single() - }) - .Select(data => new ReadingListItemDto() + .Join(_context.Series, + s => s.ReadingListItem.SeriesId, + series => series.Id, + (data, s) => new { - Id = data.ReadingListItem.Id, - ChapterId = data.ReadingListItem.ChapterId, - Order = data.ReadingListItem.Order, - SeriesId = data.ReadingListItem.SeriesId, - SeriesName = data.SeriesName, - SeriesFormat = data.SeriesFormat, - PagesTotal = data.TotalPages, - ChapterNumber = data.ChapterNumber, - VolumeNumber = data.VolumeNumber, - LibraryId = data.LibraryId, - VolumeId = data.VolumeId, - ReadingListId = data.ReadingListItem.ReadingListId, - ReleaseDate = data.ReleaseDate, - LibraryType = data.LibraryType, - ChapterTitleName = data.ChapterTitleName, - LibraryName = data.LibraryName, - FileSize = data.FileSize, - Summary = data.Summary, - IsSpecial = data.IsSpecial + SeriesName = s.Name, + SeriesFormat = s.Format, + s.LibraryId, + data.ReadingListItem, + data.TotalPages, + data.ChapterNumber, + data.VolumeNumber, + data.VolumeId, + data.ReleaseDate, + data.ChapterTitleName, + data.FileSize, + data.Summary, + data.IsSpecial, + LibraryName = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Name).Single(), + LibraryType = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Type).Single() }) - .Where(o => userLibraries.Contains(o.LibraryId)) - .OrderBy(rli => rli.Order) - .AsSplitQuery(); + .GroupJoin(_context.AppUserProgresses.Where(p => p.AppUserId == userId), + data => data.ReadingListItem.ChapterId, + progress => progress.ChapterId, + (data, progressGroup) => new { Data = data, ProgressGroup = progressGroup }) + .SelectMany( + x => x.ProgressGroup.DefaultIfEmpty(), + (x, progress) => new ReadingListItemDto() + { + Id = x.Data.ReadingListItem.Id, + ChapterId = x.Data.ReadingListItem.ChapterId, + Order = x.Data.ReadingListItem.Order, + SeriesId = x.Data.ReadingListItem.SeriesId, + SeriesName = x.Data.SeriesName, + SeriesFormat = x.Data.SeriesFormat, + PagesTotal = x.Data.TotalPages, + ChapterNumber = x.Data.ChapterNumber, + VolumeNumber = x.Data.VolumeNumber, + LibraryId = x.Data.LibraryId, + VolumeId = x.Data.VolumeId, + ReadingListId = x.Data.ReadingListItem.ReadingListId, + ReleaseDate = x.Data.ReleaseDate, + LibraryType = x.Data.LibraryType, + ChapterTitleName = x.Data.ChapterTitleName, + LibraryName = x.Data.LibraryName, + FileSize = x.Data.FileSize, + Summary = x.Data.Summary, + IsSpecial = x.Data.IsSpecial, + PagesRead = progress != null ? progress.PagesRead : 0, + LastReadingProgressUtc = progress != null ? progress.LastModifiedUtc : null + }) + .Where(o => userLibraries.Contains(o.LibraryId)) + .OrderBy(rli => rli.Order) + .AsSplitQuery(); if (userParams != null) { query = query - .Skip((userParams.PageNumber - 1) * userParams.PageSize) // NOTE: PageNumber starts at 1 with PagedList, so copy logic here + .Skip((userParams.PageNumber - 1) * userParams.PageSize) .Take(userParams.PageSize); } - var items = await query.ToListAsync(); foreach (var item in items) @@ -448,22 +576,6 @@ public class ReadingListRepository : IReadingListRepository item.Title = ReadingListService.FormatTitle(item); } - // Attach progress information - var fetchedChapterIds = items.Select(i => i.ChapterId); - var progresses = await _context.AppUserProgresses - .Where(p => fetchedChapterIds.Contains(p.ChapterId)) - .AsNoTracking() - .ToListAsync(); - - foreach (var progress in progresses) - { - var progressItem = items.SingleOrDefault(i => i.ChapterId == progress.ChapterId && i.ReadingListId == readingListId); - if (progressItem == null) continue; - - progressItem.PagesRead = progress.PagesRead; - progressItem.LastReadingProgressUtc = progress.LastModifiedUtc; - } - return items; } diff --git a/API/Services/OpdsService.cs b/API/Services/OpdsService.cs index acaf4e1a0..4346117db 100644 --- a/API/Services/OpdsService.cs +++ b/API/Services/OpdsService.cs @@ -596,13 +596,18 @@ public class OpdsService : IOpdsService // Check if there is reading progress or not, if so, inject a "continue-reading" item - var firstReadReadingListItem = items.FirstOrDefault(i => i.PagesRead > 0 && i.PagesRead != i.PagesTotal) ?? - items.FirstOrDefault(i => i.PagesRead == 0 && i.PagesRead != i.PagesTotal); - if (firstReadReadingListItem != null && request.PageNumber == FirstPageNumber) + var anyProgress = await _unitOfWork.ReadingListRepository.AnyUserReadingProgressAsync(readingListId, userId); + if (anyProgress) { - await AddContinueReadingPoint(firstReadReadingListItem, feed, request); + var firstReadReadingListItem = await _unitOfWork.ReadingListRepository.GetContinueReadingPoint(readingListId, userId); + if (firstReadReadingListItem != null && request.PageNumber == FirstPageNumber) + { + await AddContinueReadingPoint(firstReadReadingListItem, feed, request); + } } + + foreach (var item in items) { var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId, userId); @@ -1003,7 +1008,7 @@ public class OpdsService : IOpdsService var pageNumber = Math.Max(list.CurrentPage, 1); - if (pageNumber > 1) + if (pageNumber > FirstPageNumber) { feed.Links.Add(CreateLink(FeedLinkRelation.Prev, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber - 1))); } diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index a58a9cb6c..b6baa5336 100644 --- a/API/Services/Plus/LicenseService.cs +++ b/API/Services/Plus/LicenseService.cs @@ -265,8 +265,6 @@ public class LicenseService( if (cacheValue.HasValue) return cacheValue.Value; } - // TODO: If info.IsCancelled && notActive, let's remove the license so we aren't constantly checking - try { var encryptedLicense = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); @@ -286,17 +284,25 @@ public class LicenseService( .All(r => new Version(r.UpdateVersion) <= BuildInfo.Version); response.HasLicense = hasLicense; + response.InstallId = HashUtil.ServerToken(); // Cache if the license is valid here as well var licenseProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); await licenseProvider.SetAsync(CacheKey, response.IsActive, _licenseCacheTimeout); + // TODO: If info.IsCancelled && notActive, let's remove the license so we aren't constantly checking + if (response is {IsCancelled: true, IsActive: false}) + { + //logger.LogWarning("Kavita+ License is no longer active, removing Server registration"); + } + // Cache the license info if IsActive and ExpirationDate > DateTime.UtcNow + 2 if (response.IsActive && response.ExpirationDate > DateTime.UtcNow.AddDays(2)) { await licenseInfoProvider.SetAsync(LicenseInfoCacheKey, response, _licenseCacheTimeout); } + return response; } catch (FlurlHttpException e) diff --git a/UI/Web/src/app/_models/kavitaplus/license-info.ts b/UI/Web/src/app/_models/kavitaplus/license-info.ts index 4a724b3ff..e239f77be 100644 --- a/UI/Web/src/app/_models/kavitaplus/license-info.ts +++ b/UI/Web/src/app/_models/kavitaplus/license-info.ts @@ -6,4 +6,5 @@ export interface LicenseInfo { registeredEmail: string; totalMonthsSubbed: number; hasLicense: boolean; + installId: string; } diff --git a/UI/Web/src/app/_services/font.service.ts b/UI/Web/src/app/_services/font.service.ts index e2b253e61..16bc58fd2 100644 --- a/UI/Web/src/app/_services/font.service.ts +++ b/UI/Web/src/app/_services/font.service.ts @@ -37,7 +37,6 @@ export class FontService { } getFontFace(font: EpubFont): FontFace { - // TODO: We need to refactor this so that we loadFonts with an array, fonts have an id to remove them, and we don't keep populating the document if (font.provider === FontProvider.System) { return new FontFace(font.name, `url('/assets/fonts/${font.name}/${font.fileName}')`); } diff --git a/UI/Web/src/app/admin/license/license.component.html b/UI/Web/src/app/admin/license/license.component.html index ce53155de..1c303b4b4 100644 --- a/UI/Web/src/app/admin/license/license.component.html +++ b/UI/Web/src/app/admin/license/license.component.html @@ -179,6 +179,14 @@ +
+ + + {{licInfo.installId}} + + +
+
diff --git a/UI/Web/src/app/announcements/_components/changelog/changelog.component.html b/UI/Web/src/app/announcements/_components/changelog/changelog.component.html index c5110a8de..93777dbce 100644 --- a/UI/Web/src/app/announcements/_components/changelog/changelog.component.html +++ b/UI/Web/src/app/announcements/_components/changelog/changelog.component.html @@ -7,7 +7,7 @@

-

diff --git a/UI/Web/src/app/announcements/_components/changelog/changelog.component.scss b/UI/Web/src/app/announcements/_components/changelog/changelog.component.scss index b9434f558..55aeb21b4 100644 --- a/UI/Web/src/app/announcements/_components/changelog/changelog.component.scss +++ b/UI/Web/src/app/announcements/_components/changelog/changelog.component.scss @@ -6,3 +6,7 @@ .changelog-header { color: var(--body-text-color); } + +.accordion-button { + color: var(--body-text-color); +} 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 2b74ccb4c..f6b59c990 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 @@ -37,7 +37,8 @@ @if(showPageLink()) { - {{t('page-num', {page: annotation().pageNumber})}} + + {{t('page-num', {page: annotation().pageNumber + 1})}} } 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 7ebe216a0..9b7c5a45b 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 @@ -89,6 +89,10 @@ export class AnnotationCardComponent { * If enabled, listens to annotation updates */ listedToUpdates = input(false); + /** + * If the card is rendered inside the book reader. Used for styling the confirm button + */ + inBookReader = input(false); selected = input(false); @Output() delete = new EventEmitter(); @@ -157,7 +161,10 @@ export class AnnotationCardComponent { } async deleteAnnotation() { - if (!await this.confirmService.confirm(translate('toasts.confirm-delete-annotation'))) return; + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-annotation'), { + ...this.confirmService.defaultConfirm, + bookReader: this.inBookReader(), + })) return; const annotation = this.annotation(); if (!annotation) return; diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.html index 3a05cfbd5..2c5d51e96 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.html +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.html @@ -36,6 +36,7 @@ (delete)="handleDelete(annotation)" (navigate)="handleNavigateTo($event)" [forceSize]="false" + [inBookReader]="true" /> } @empty { 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 35351d641..0829f6204 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 @@ -2259,11 +2259,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { async viewAnnotations() { await this.epubMenuService.openViewAnnotationsDrawer((annotation: Annotation) => { + const currentPageNum = this.pageNum(); if (this.pageNum() != annotation.pageNumber) { this.setPageNum(annotation.pageNumber); } if (annotation.xPath != null) { + this.adhocPageHistory.push({page: currentPageNum, scrollPart: this.readerService.scopeBookReaderXpath(this.lastSeenScrollPartPath)}); this.loadPage(annotation.xPath); } }); diff --git a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html index 827fc5503..a38b39dd4 100644 --- a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html +++ b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.html @@ -6,6 +6,7 @@

@@ -68,6 +69,7 @@

@@ -157,6 +159,7 @@

diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index f75c489e8..57a5f0a1b 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -310,7 +310,6 @@ export class EditSeriesModalComponent implements OnInit { this.volumeCollapsed[v.name] = true; }); this.seriesVolumes.forEach(vol => { - //.sort(this.utilityService.sortChapters) (no longer needed, all data is sorted on the backend) vol.volumeFiles = vol.chapters?.map((c: Chapter) => c.files.map((f: any) => { // TODO: Identify how to fix this hack f.chapter = c.range; diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index c01fbca98..50ee4a739 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -241,7 +241,7 @@ export class CardItemComponent implements OnInit { } } else if (this.utilityService.isSeries(this.entity)) { this.tooltipTitle = this.title || (this.utilityService.asSeries(this.entity).name); - } else if (this.entity.hasOwnProperty('expectedDate')) { + } else if (this.entity.hasOwnProperty('expectedDate')) { // Upcoming Chapter Entity this.suppressArchiveWarning = true; this.imageUrl = ''; const nextDate = (this.entity as NextExpectedChapter); @@ -250,7 +250,6 @@ export class CardItemComponent implements OnInit { // this.overlayInformation = ` // //
${tokens[0]}
${tokens[1]}
`; - // // todo: figure out where this caller is this.centerOverlay = true; if (nextDate.expectedDate) { diff --git a/UI/Web/src/app/cards/person-card/person-card.component.scss b/UI/Web/src/app/cards/person-card/person-card.component.scss index 26724b2c0..67eeb6dbb 100644 --- a/UI/Web/src/app/cards/person-card/person-card.component.scss +++ b/UI/Web/src/app/cards/person-card/person-card.component.scss @@ -19,7 +19,7 @@ $image-height: 160px; position: relative; display: flex; background-color: hsl(0deg 0% 0% / 12%); - /* TODO: Robbie fix this hack */ + .missing-img { align-self: center; display: flex; 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 26a24001b..cc6546a36 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.html +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.html @@ -86,7 +86,7 @@
- +
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 ea9926a81..1260db0f9 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts @@ -61,7 +61,7 @@ import { } from "../series-detail/_components/metadata-detail-row/metadata-detail-row.component"; import {DownloadButtonComponent} from "../series-detail/_components/download-button/download-button.component"; import {hasAnyCast} from "../_models/common/i-has-cast"; -import {Breakpoint, UtilityService} from "../shared/_services/utility.service"; +import {Breakpoint, UserBreakpoint, UtilityService} from "../shared/_services/utility.service"; import {EVENTS, MessageHubService} from "../_services/message-hub.service"; import {CoverUpdateEvent} from "../_models/events/cover-update-event"; import {ChapterRemovedEvent} from "../_models/events/chapter-removed-event"; @@ -156,7 +156,7 @@ export class ChapterDetailComponent implements OnInit { protected readonly AgeRating = AgeRating; protected readonly TabID = TabID; protected readonly FilterField = FilterField; - protected readonly Breakpoint = Breakpoint; + protected readonly UserBreakpoint = UserBreakpoint; protected readonly LibraryType = LibraryType; protected readonly encodeURIComponent = encodeURIComponent; diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html index a4dcf2588..0cfa50c66 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html @@ -39,7 +39,7 @@
@if (summary.length > 0) {
- +
@if (collectionTag.source !== ScrobbleProvider.Kavita) { diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts index 95f0fe08f..be55cc7f6 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts @@ -8,7 +8,7 @@ import {debounceTime, take} from 'rxjs/operators'; import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; import {EditCollectionTagsComponent} from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component'; import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service'; -import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; +import {Breakpoint, UserBreakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; import {UserCollection} from 'src/app/_models/collection-tag'; import {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added-to-collection-event'; import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; @@ -90,7 +90,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { private readonly metadataService = inject(MetadataService); protected readonly ScrobbleProvider = ScrobbleProvider; - protected readonly Breakpoint = Breakpoint; + protected readonly UserBreakpoint = UserBreakpoint; @ViewChild('scrollingBlock') scrollingBlock: ElementRef | undefined; @ViewChild('companionBar') companionBar: ElementRef | undefined; diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html index ecf89e486..cd4192b23 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html @@ -26,7 +26,7 @@
{{chapterTitleLabel()}} @if (totalSeriesPages > 0) { - {{t('series-progress', {percentage: (Math.min(1, ((totalSeriesPagesRead + pageNum) / totalSeriesPages)) | percent)}) }} + {{t('series-progress', {percentage: (Math.min(1, ((totalSeriesPagesRead + pageNum) / totalSeriesPages)) | percent)}) }} }
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 e343afd49..fd48d6f18 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 @@ -501,7 +501,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { return this.bookmarks[this.pageNum]; } - return chapterInfo?.chapterTitle ?? ''; + return chapterInfo?.chapterTitle || chapterInfo?.subtitle || ''; }); @@ -835,7 +835,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { //send the current width override value to the label this.widthOverrideLabel$ = this.readerSettings$?.pipe( - map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'), + map(values => parseInt(values.widthSlider)), + map(widthOverride => (isNaN(widthOverride)) ? '' : widthOverride + '%'), takeUntilDestroyed(this.destroyRef) ); } diff --git a/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.html b/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.html index bcc6c9165..28d2d1908 100644 --- a/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.html +++ b/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.html @@ -24,8 +24,9 @@ @if (fileToProcess.validateSummary; as summary) {
-
@@ -48,8 +49,9 @@ @if (fileToProcess.dryRunSummary; as summary) {
-
@@ -70,8 +72,9 @@ @if (fileToProcess.finalizeSummary; as summary) {
-
diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html index 37a403fc5..59cd87297 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html @@ -100,7 +100,7 @@
- +
diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts index 0e503aec2..65236278e 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts @@ -1,10 +1,10 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, inject, OnInit, ViewChild } from '@angular/core'; import {ActivatedRoute, Router, RouterLink} from '@angular/router'; -import {AsyncPipe, DatePipe, DecimalPipe, DOCUMENT, Location, NgClass, NgStyle} from '@angular/common'; +import {DatePipe, DecimalPipe, DOCUMENT, Location, NgClass, NgStyle} from '@angular/common'; import {ToastrService} from 'ngx-toastr'; import {take} from 'rxjs/operators'; import {ConfirmService} from 'src/app/shared/confirm.service'; -import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; +import {Breakpoint, UserBreakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; import {LibraryType} from 'src/app/_models/library/library'; import {MangaFormat} from 'src/app/_models/manga-format'; import {ReadingList, ReadingListInfo, ReadingListItem} from 'src/app/_models/reading-list'; @@ -65,7 +65,7 @@ enum TabID { imports: [CardActionablesComponent, ImageComponent, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, ReadMoreComponent, BadgeExpanderComponent, LoadingComponent, DraggableOrderedListComponent, - ReadingListItemComponent, NgClass, AsyncPipe, DecimalPipe, DatePipe, TranslocoDirective, ReactiveFormsModule, + ReadingListItemComponent, NgClass, DecimalPipe, DatePipe, TranslocoDirective, ReactiveFormsModule, NgbNav, NgbNavContent, NgbNavLink, NgbTooltip, RouterLink, VirtualScrollerModule, NgStyle, NgbNavOutlet, NgbNavItem, PromotedIconComponent, DefaultValuePipe, DetailsTabComponent] }) @@ -75,6 +75,7 @@ export class ReadingListDetailComponent implements OnInit { protected readonly MangaFormat = MangaFormat; protected readonly Breakpoint = Breakpoint; + protected readonly UserBreakpoint = UserBreakpoint; protected readonly TabID = TabID; protected readonly encodeURIComponent = encodeURIComponent; diff --git a/UI/Web/src/app/registration/user-login/user-login.component.html b/UI/Web/src/app/registration/user-login/user-login.component.html index 49f03844f..40995b391 100644 --- a/UI/Web/src/app/registration/user-login/user-login.component.html +++ b/UI/Web/src/app/registration/user-login/user-login.component.html @@ -32,7 +32,7 @@ @if (showOidcButton()) { diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index ebbc2819c..69c7e1214 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -109,7 +109,7 @@
- +
diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 93cfab260..6b8a1f24a 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -38,7 +38,7 @@ import { EditSeriesModalComponent } from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component'; import {DownloadEvent, DownloadService} from 'src/app/shared/_services/download.service'; -import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; +import {Breakpoint, UserBreakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter'; import {Device} from 'src/app/_models/device/device'; import {ScanSeriesEvent} from 'src/app/_models/events/scan-series-event'; @@ -158,7 +158,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { protected readonly SettingsTabId = SettingsTabId; protected readonly FilterField = FilterField; protected readonly AgeRating = AgeRating; - protected readonly Breakpoint = Breakpoint; + protected readonly UserBreakpoint = UserBreakpoint; protected readonly encodeURIComponent = encodeURIComponent; private readonly destroyRef = inject(DestroyRef); @@ -866,9 +866,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.volumes = detail.volumes; this.storyChapters = detail.storylineChapters; - this.totalSize.set(detail.volumes.reduce((sum, v) => sum + v.chapters.reduce((volumeSum, c) => { - return volumeSum + c.files.reduce((chapterSum, f) => chapterSum + f.bytes , 0) - }, 0), 0)); + const uniqueChapters = Array.from( + new Map([...detail.chapters, ...detail.volumes.flatMap(v => v.chapters)] + .map(c => [c.id, c])).values() + ); + + this.totalSize.set(uniqueChapters + .flatMap(c => c.files) + .reduce((sum, f) => sum + f.bytes, 0)); this.storylineItems = []; const v = this.volumes.map(v => { diff --git a/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html index 59dfc1fdb..1c5735671 100644 --- a/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html +++ b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html @@ -1,6 +1,6 @@
-
+

{{title()}}

{{tooltip()}}
diff --git a/UI/Web/src/app/shared/badge-expander/badge-expander.component.html b/UI/Web/src/app/shared/badge-expander/badge-expander.component.html index d3485641c..dc3a7456a 100644 --- a/UI/Web/src/app/shared/badge-expander/badge-expander.component.html +++ b/UI/Web/src/app/shared/badge-expander/badge-expander.component.html @@ -1,17 +1,17 @@
diff --git a/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts b/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts index 5e1d3cd13..d014262d4 100644 --- a/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts +++ b/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts @@ -1,11 +1,9 @@ import { ChangeDetectionStrategy, - ChangeDetectorRef, - Component, + Component, computed, ContentChild, EventEmitter, - inject, - Input, OnChanges, - OnInit, Output, SimpleChanges, + input, + OnInit, Output, signal, TemplateRef } from '@angular/core'; import {NgTemplateOutlet} from "@angular/common"; @@ -19,59 +17,49 @@ import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; styleUrls: ['./badge-expander.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class BadgeExpanderComponent implements OnInit, OnChanges { +export class BadgeExpanderComponent implements OnInit { - private readonly cdRef = inject(ChangeDetectorRef); - - @Input() items: Array = []; - @Input() itemsTillExpander: number = 4; - @Input() allowToggle: boolean = true; - @Input() includeComma: boolean = true; + items = input.required(); + itemsTillExpander = input(4); + allowToggle = input(true); + includeComma = input(true); /** - * If should be expanded by default. Defaults to false. + * If the list should be expanded by default. Defaults to false. */ - @Input() defaultExpanded: boolean = false; + defaultExpanded = input(false); + /** * Invoked when the "and more" is clicked */ @Output() toggle = new EventEmitter(); @ContentChild('badgeExpanderItem') itemTemplate!: TemplateRef; + isCollapsed = signal(undefined); + visibleItems = computed(() => { + const allItems = this.items(); + const isCollapsed = this.isCollapsed(); + const cutOff = this.itemsTillExpander(); - visibleItems: Array = []; - isCollapsed: boolean = false; + if (!isCollapsed) return allItems; - get itemsLeft() { - if (this.defaultExpanded) return 0; + return allItems.slice(0, cutOff); + }); + itemsLeft = computed(() => { + const allItems = this.items(); + const visibleItems = this.visibleItems(); - return Math.max(this.items.length - this.itemsTillExpander, 0); - } + return allItems.length - visibleItems.length; + }); ngOnInit(): void { - - if (this.defaultExpanded) { - this.isCollapsed = false; - this.visibleItems = this.items; - this.cdRef.markForCheck(); - return; - } - - this.visibleItems = this.items.slice(0, this.itemsTillExpander); - this.cdRef.markForCheck(); - } - - ngOnChanges(changes: SimpleChanges) { - this.visibleItems = this.items.slice(0, this.itemsTillExpander); - this.cdRef.markForCheck(); + this.isCollapsed.set(!this.defaultExpanded()); } toggleVisible() { this.toggle.emit(); - if (!this.allowToggle) return; + if (!this.allowToggle()) return; - this.isCollapsed = !this.isCollapsed; - this.visibleItems = this.items; - this.cdRef.markForCheck(); + this.isCollapsed.update(x => !x); } } diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.scss b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.scss index 8a07145ec..4bc133186 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.scss +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.scss @@ -9,7 +9,7 @@ } &::-webkit-scrollbar { - background-color: transparent; /*make scrollbar space invisible */ + background-color: transparent; width: inherit; display: none; visibility: hidden; @@ -17,7 +17,7 @@ } &::-webkit-scrollbar-thumb { - background: transparent; /*makes it invisible when not hovering*/ + background: transparent; } &:hover { diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.html b/UI/Web/src/app/volume-detail/volume-detail.component.html index e1232313a..b3c01f625 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.html +++ b/UI/Web/src/app/volume-detail/volume-detail.component.html @@ -90,7 +90,7 @@
- +
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 69f54ec71..77009362c 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -53,7 +53,7 @@ import {IHasCast} from "../_models/common/i-has-cast"; import {EntityTitleComponent} from "../cards/entity-title/entity-title.component"; import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service"; -import {Breakpoint, UtilityService} from "../shared/_services/utility.service"; +import {Breakpoint, UserBreakpoint, UtilityService} from "../shared/_services/utility.service"; import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component"; import {EditVolumeModalComponent} from "../_single-module/edit-volume-modal/edit-volume-modal.component"; import {Genre} from "../_models/metadata/genre"; @@ -194,7 +194,7 @@ export class VolumeDetailComponent implements OnInit { protected readonly AgeRating = AgeRating; protected readonly TabID = TabID; protected readonly FilterField = FilterField; - protected readonly Breakpoint = Breakpoint; + protected readonly UserBreakpoint = UserBreakpoint; protected readonly encodeURIComponent = encodeURIComponent; @ViewChild('scrollingBlock') scrollingBlock: ElementRef | undefined; diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 317c48587..296d5b86c 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -48,7 +48,7 @@ "provider-name-tooltip": "Name shown on the login screen", "defaults-title": "Defaults", - "defaults-requirement": "The following settings are used when a user is registered via OIDC while SyncUserSettings is turned off", + "defaults-requirement": "The following settings are used when a user is registered via OIDC while Sync User Settings is turned off", "default-include-unknowns-label": "Include unknowns", "default-include-unknowns-tooltip": "Include unknown age ratings", "default-age-restriction-label": "Age rating", @@ -815,6 +815,7 @@ "faq-title": "FAQ", "total-subbed-months-label": "Total Months Subscribed", "email-label": "Registered Email", + "supportId-label": "Support Id", "license-mismatch": "License may be registered to another Kavita instance. Re-register to fix.", "k+-license-overwrite": "License is registered to another Kavita instance. This can happen on re-installation and rarely due to system updates. Select overwrite to force this instance to register with Kavita+.", @@ -2180,7 +2181,7 @@ "next": "{{import-cbl-modal.next}}", "save": "{{common.save}}", - "import-description": "Upload a file you, or someone else has exported to replace or merge with your current settings.", + "import-description": "Upload a file you, or someone else, has exported to replace or merge with your current settings.", "select-files-warning": "You must upload a json file to continue", "invalid-file": "Failed to parse your file, check your input", "file-no-valid-content": "Your import did not contain any meaningful data to continue with", diff --git a/UI/Web/src/theme/components/_accordion.scss b/UI/Web/src/theme/components/_accordion.scss index 3ade0f46a..04accdd79 100644 --- a/UI/Web/src/theme/components/_accordion.scss +++ b/UI/Web/src/theme/components/_accordion.scss @@ -1,36 +1,53 @@ .accordion-header { font-weight: bold; - color: var(--accordion-header-text-color); + color: var(--accordion-header-text-color); } -.accordion-item { +.accordion-item { background-color: var(--accordion-body-bg-color); color: var(--accordion-body-text-color); border-color: var(--accordion-body-border-color); div[role="tabpanel"] { - background-color: var(--accordion-header-bg-color); + background-color: var(--accordion-header-bg-color); .accordion-body { - background-color: var(--accordion-active-body-bg-color); + background-color: var(--accordion-active-body-bg-color); } } } .accordion-button { - &:not(.collapsed) { - color: var(--accordion-header-text-color); - background-color: var(--accordion-body-bg-color); - box-shadow: var(--accordion-body-box-shadow); + display: flex; + justify-content: space-between; + + &::after { + content: none; + } + + &:not(.collapsed) { + color: var(--accordion-header-text-color); + background-color: var(--accordion-body-bg-color); + box-shadow: var(--accordion-body-box-shadow); + + i { + transform: rotate(180deg); + transition: transform 0.3s ease; + } + } + + &.collapsed { + color: var(--accordion-header-collapsed-text-color); + background-color: var(--accordion-header-collapsed-bg-color); + + i { + transition: transform 0.3s ease; } - &.collapsed { - color: var(--accordion-header-collapsed-text-color); - background-color: var(--accordion-header-collapsed-bg-color); - } + } - &:focus { - border-color: var(--accordion-button-focus-border-color); - box-shadow: var(--accordion-button-focus-box-shadow); - } + &:focus { + border-color: var(--accordion-button-focus-border-color); + box-shadow: var(--accordion-button-focus-box-shadow); + } }