diff --git a/API.Tests/Services/ImageServiceTests.cs b/API.Tests/Services/ImageServiceTests.cs index a1073a55b..f2c87e1ad 100644 --- a/API.Tests/Services/ImageServiceTests.cs +++ b/API.Tests/Services/ImageServiceTests.cs @@ -161,10 +161,10 @@ public class ImageServiceTests private static void GenerateColorImage(string hexColor, string outputPath) { - var color = ImageService.HexToRgb(hexColor); - using var colorImage = Image.Black(200, 100); - using var output = colorImage + new[] { color.R / 255.0, color.G / 255.0, color.B / 255.0 }; - output.WriteToFile(outputPath); + var (r, g, b) = ImageService.HexToRgb(hexColor); + using var blackImage = Image.Black(200, 100); + using var colorImage = blackImage.NewFromImage(r, g, b); + colorImage.WriteToFile(outputPath); } private void GenerateHtmlFileForColorScape() diff --git a/API/API.csproj b/API/API.csproj index f9a889d74..4eed66f22 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -97,9 +97,9 @@ + - diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index 10a5f393a..cab33692a 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -6,8 +6,10 @@ using System.Threading.Tasks; using API.Constants; using API.Data; using API.Data.Repositories; +using API.DTOs; using API.DTOs.Filtering; using API.DTOs.Metadata; +using API.DTOs.Metadata.Browse; using API.DTOs.Person; using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; @@ -46,6 +48,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context)); } + /// + /// Returns a list of Genres with counts for counts when Genre is on Series/Chapter + /// + /// + [HttpPost("genres-with-counts")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)] + public async Task>> GetBrowseGenres(UserParams? userParams = null) + { + userParams ??= UserParams.Default; + + var list = await unitOfWork.GenreRepository.GetBrowseableGenre(User.GetUserId(), userParams); + Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + + return Ok(list); + } + /// /// Fetches people from the instance by role /// @@ -95,6 +113,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId())); } + /// + /// Returns a list of Tags with counts for counts when Tag is on Series/Chapter + /// + /// + [HttpPost("tags-with-counts")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)] + public async Task>> GetBrowseTags(UserParams? userParams = null) + { + userParams ??= UserParams.Default; + + var list = await unitOfWork.TagRepository.GetBrowseableTag(User.GetUserId(), userParams); + Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + + return Ok(list); + } + /// /// Fetches all age ratings from the instance /// diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index a2ab3bf88..bf3cc1814 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -4,6 +4,9 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Filtering.v2; +using API.DTOs.Metadata.Browse; +using API.DTOs.Metadata.Browse.Requests; using API.DTOs.Person; using API.Entities.Enums; using API.Extensions; @@ -77,11 +80,13 @@ public class PersonController : BaseApiController /// /// [HttpPost("all")] - public async Task>> GetAuthorsForBrowse([FromQuery] UserParams? userParams) + public async Task>> GetPeopleForBrowse(BrowsePersonFilterDto filter, [FromQuery] UserParams? userParams) { userParams ??= UserParams.Default; - var list = await _unitOfWork.PersonRepository.GetAllWritersAndSeriesCount(User.GetUserId(), userParams); + + var list = await _unitOfWork.PersonRepository.GetBrowsePersonDtos(User.GetUserId(), filter, userParams); Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + return Ok(list); } @@ -112,6 +117,7 @@ public class PersonController : BaseApiController person.Name = dto.Name?.Trim(); + person.NormalizedName = person.Name.ToNormalized(); person.Description = dto.Description ?? string.Empty; person.CoverImageLocked = dto.CoverImageLocked; diff --git a/API/DTOs/Filtering/PersonSortField.cs b/API/DTOs/Filtering/PersonSortField.cs new file mode 100644 index 000000000..5268a1bf9 --- /dev/null +++ b/API/DTOs/Filtering/PersonSortField.cs @@ -0,0 +1,8 @@ +namespace API.DTOs.Filtering; + +public enum PersonSortField +{ + Name = 1, + SeriesCount = 2, + ChapterCount = 3 +} diff --git a/API/DTOs/Filtering/SortOptions.cs b/API/DTOs/Filtering/SortOptions.cs index a08e2968e..18f2b17ea 100644 --- a/API/DTOs/Filtering/SortOptions.cs +++ b/API/DTOs/Filtering/SortOptions.cs @@ -8,3 +8,12 @@ public sealed record SortOptions public SortField SortField { get; set; } public bool IsAscending { get; set; } = true; } + +/// +/// All Sorting Options for a query related to Person Entity +/// +public sealed record PersonSortOptions +{ + public PersonSortField SortField { get; set; } + public bool IsAscending { get; set; } = true; +} diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs index 5323f2b48..246a92a90 100644 --- a/API/DTOs/Filtering/v2/FilterField.cs +++ b/API/DTOs/Filtering/v2/FilterField.cs @@ -56,5 +56,12 @@ public enum FilterField /// Last time User Read /// ReadLast = 32, - +} + +public enum PersonFilterField +{ + Role = 1, + Name = 2, + SeriesCount = 3, + ChapterCount = 4, } diff --git a/API/DTOs/Filtering/v2/FilterStatementDto.cs b/API/DTOs/Filtering/v2/FilterStatementDto.cs index ebe6d16af..8c99bd24c 100644 --- a/API/DTOs/Filtering/v2/FilterStatementDto.cs +++ b/API/DTOs/Filtering/v2/FilterStatementDto.cs @@ -1,4 +1,6 @@ -namespace API.DTOs.Filtering.v2; +using API.DTOs.Metadata.Browse.Requests; + +namespace API.DTOs.Filtering.v2; public sealed record FilterStatementDto { @@ -6,3 +8,10 @@ public sealed record FilterStatementDto public FilterField Field { get; set; } public string Value { get; set; } } + +public sealed record PersonFilterStatementDto +{ + public FilterComparison Comparison { get; set; } + public PersonFilterField Field { get; set; } + public string Value { get; set; } +} diff --git a/API/DTOs/Filtering/v2/FilterV2Dto.cs b/API/DTOs/Filtering/v2/FilterV2Dto.cs index 11dc42a6b..a247a17a6 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; } = new List(); + public ICollection Statements { get; set; } = []; public FilterCombination Combination { get; set; } = FilterCombination.And; public SortOptions? SortOptions { get; set; } diff --git a/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs index 8eb38c98a..c394cf8d4 100644 --- a/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs +++ b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs @@ -15,5 +15,9 @@ public enum MatchStateOption public sealed record ManageMatchFilterDto { public MatchStateOption MatchStateOption { get; set; } = MatchStateOption.All; + /// + /// Library Type in int form. -1 indicates to ignore the field. + /// + public int LibraryType { get; set; } = -1; public string SearchTerm { get; set; } = string.Empty; } diff --git a/API/DTOs/Metadata/Browse/BrowseGenreDto.cs b/API/DTOs/Metadata/Browse/BrowseGenreDto.cs new file mode 100644 index 000000000..8044c7914 --- /dev/null +++ b/API/DTOs/Metadata/Browse/BrowseGenreDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.Metadata.Browse; + +public sealed record BrowseGenreDto : GenreTagDto +{ + /// + /// Number of Series this Entity is on + /// + public int SeriesCount { get; set; } + /// + /// Number of Chapters this Entity is on + /// + public int ChapterCount { get; set; } +} diff --git a/API/DTOs/Person/BrowsePersonDto.cs b/API/DTOs/Metadata/Browse/BrowsePersonDto.cs similarity index 71% rename from API/DTOs/Person/BrowsePersonDto.cs rename to API/DTOs/Metadata/Browse/BrowsePersonDto.cs index c7d318e79..20f84b783 100644 --- a/API/DTOs/Person/BrowsePersonDto.cs +++ b/API/DTOs/Metadata/Browse/BrowsePersonDto.cs @@ -1,6 +1,6 @@ using API.DTOs.Person; -namespace API.DTOs; +namespace API.DTOs.Metadata.Browse; /// /// Used to browse writers and click in to see their series @@ -12,7 +12,7 @@ public class BrowsePersonDto : PersonDto /// public int SeriesCount { get; set; } /// - /// Number or Issues this Person is the Writer for + /// Number of Issues this Person is the Writer for /// - public int IssueCount { get; set; } + public int ChapterCount { get; set; } } diff --git a/API/DTOs/Metadata/Browse/BrowseTagDto.cs b/API/DTOs/Metadata/Browse/BrowseTagDto.cs new file mode 100644 index 000000000..9a71876e3 --- /dev/null +++ b/API/DTOs/Metadata/Browse/BrowseTagDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.Metadata.Browse; + +public sealed record BrowseTagDto : TagDto +{ + /// + /// Number of Series this Entity is on + /// + public int SeriesCount { get; set; } + /// + /// Number of Chapters this Entity is on + /// + public int ChapterCount { get; set; } +} diff --git a/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs b/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs new file mode 100644 index 000000000..d41cf37f3 --- /dev/null +++ b/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; + +namespace API.DTOs.Metadata.Browse.Requests; +#nullable enable + +public sealed record BrowsePersonFilterDto +{ + /// + /// Not used - For parity with Series Filter + /// + public int Id { get; set; } + /// + /// Not used - For parity with Series Filter + /// + public string? Name { get; set; } + public ICollection Statements { get; set; } = []; + public FilterCombination Combination { get; set; } = FilterCombination.And; + public PersonSortOptions? SortOptions { get; set; } + + /// + /// Limit the number of rows returned. Defaults to not applying a limit (aka 0) + /// + public int LimitTo { get; set; } = 0; +} diff --git a/API/DTOs/Metadata/GenreTagDto.cs b/API/DTOs/Metadata/GenreTagDto.cs index 4846048d2..13a339d38 100644 --- a/API/DTOs/Metadata/GenreTagDto.cs +++ b/API/DTOs/Metadata/GenreTagDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Metadata; -public sealed record GenreTagDto +public record GenreTagDto { public int Id { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Metadata/TagDto.cs b/API/DTOs/Metadata/TagDto.cs index f8deb6913..f5c925e1f 100644 --- a/API/DTOs/Metadata/TagDto.cs +++ b/API/DTOs/Metadata/TagDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Metadata; -public sealed record TagDto +public record TagDto { public int Id { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index cbc16275d..47a526411 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -49,6 +49,11 @@ public sealed record ReadingListDto : IHasCoverImage /// public required AgeRating AgeRating { get; set; } = AgeRating.Unknown; + /// + /// Username of the User that owns (in the case of a promoted list) + /// + public string OwnerUserName { get; set; } + public void ResetColorScape() { PrimaryColor = string.Empty; diff --git a/API/DTOs/UserReadingProfileDto.cs b/API/DTOs/UserReadingProfileDto.cs index 23f67ce4d..24dbf1c34 100644 --- a/API/DTOs/UserReadingProfileDto.cs +++ b/API/DTOs/UserReadingProfileDto.cs @@ -64,6 +64,9 @@ public sealed record UserReadingProfileDto /// public int? WidthOverride { get; set; } + /// + public BreakPoint DisableWidthOverride { get; set; } = BreakPoint.Never; + #endregion #region EpubReader diff --git a/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs new file mode 100644 index 000000000..0e9f00b4e --- /dev/null +++ b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs @@ -0,0 +1,3701 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint")] + partial class AppUserReadingProfileDisableWidthOverrideBreakPoint + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.cs b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.cs new file mode 100644 index 000000000..11a554bdf --- /dev/null +++ b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AppUserReadingProfileDisableWidthOverrideBreakPoint : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DisableWidthOverride", + table: "AppUserReadingProfiles", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DisableWidthOverride", + table: "AppUserReadingProfiles"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 25db64e2a..e777bbf7c 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -665,6 +665,9 @@ namespace API.Data.Migrations .HasColumnType("TEXT") .HasDefaultValue("Dark"); + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + b.Property("EmulateBook") .HasColumnType("INTEGER"); @@ -704,7 +707,7 @@ namespace API.Data.Migrations b.Property("ScalingOption") .HasColumnType("INTEGER"); - b.PrimitiveCollection("SeriesIds") + b.Property("SeriesIds") .HasColumnType("TEXT"); b.Property("ShowScreenHints") diff --git a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs index 45882b5c4..377344a3c 100644 --- a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs +++ b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs @@ -108,14 +108,17 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor public async Task NeedsDataRefresh(int seriesId) { + // TODO: Add unit test var row = await _context.ExternalSeriesMetadata .Where(s => s.SeriesId == seriesId) .FirstOrDefaultAsync(); + return row == null || row.ValidUntilUtc <= DateTime.UtcNow; } public async Task GetSeriesDetailPlusDto(int seriesId) { + // TODO: Add unit test var seriesDetailDto = await _context.ExternalSeriesMetadata .Where(m => m.SeriesId == seriesId) .Include(m => m.ExternalRatings) @@ -144,7 +147,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - IEnumerable reviews = new List(); + IEnumerable reviews = []; if (seriesDetailDto.ExternalReviews != null && seriesDetailDto.ExternalReviews.Any()) { reviews = seriesDetailDto.ExternalReviews @@ -231,6 +234,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .Include(s => s.ExternalSeriesMetadata) .Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type)) .Where(s => s.Library.AllowMetadataMatching) + .WhereIf(filter.LibraryType >= 0, s => s.Library.Type == (LibraryType) filter.LibraryType) .FilterMatchState(filter.MatchStateOption) .OrderBy(s => s.NormalizedName) .ProjectTo(_mapper.ConfigurationProvider) diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index d9bc20c99..3e645cb2e 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -3,9 +3,11 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs.Metadata; +using API.DTOs.Metadata.Browse; using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Helpers; using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; @@ -27,6 +29,7 @@ public interface IGenreRepository Task GetRandomGenre(); Task GetGenreById(int id); Task> GetAllGenresNotInListAsync(ICollection genreNames); + Task> GetBrowseableGenre(int userId, UserParams userParams); } public class GenreRepository : IGenreRepository @@ -165,4 +168,28 @@ public class GenreRepository : IGenreRepository // Return the original non-normalized genres for the missing ones return missingGenres.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList(); } + + public async Task> GetBrowseableGenre(int userId, UserParams userParams) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + var query = _context.Genre + .RestrictAgainstAgeRestriction(ageRating) + .Select(g => new BrowseGenreDto + { + Id = g.Id, + Title = g.Title, + SeriesCount = g.SeriesMetadatas + .Select(sm => sm.Id) + .Distinct() + .Count(), + ChapterCount = g.Chapters + .Select(ch => ch.Id) + .Distinct() + .Count() + }) + .OrderBy(g => g.Title); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } } diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index dce3f86ef..6954ccf03 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -2,13 +2,19 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Data.Misc; using API.DTOs; +using API.DTOs.Filtering.v2; +using API.DTOs.Metadata.Browse; +using API.DTOs.Metadata.Browse.Requests; using API.DTOs.Person; using API.Entities.Enums; using API.Entities.Person; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Extensions.QueryExtensions.Filtering; using API.Helpers; +using API.Helpers.Converters; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -45,7 +51,7 @@ public interface IPersonRepository Task GetCoverImageAsync(int personId); Task GetCoverImageByNameAsync(string name); Task> GetRolesForPersonByName(int personId, int userId); - Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams); + Task> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams); Task GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None); Task GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases); /// @@ -194,36 +200,82 @@ public class PersonRepository : IPersonRepository return chapterRoles.Union(seriesRoles).Distinct(); } - public async Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams) + public async Task> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams) { - List roles = [PersonRole.Writer, PersonRole.CoverArtist]; var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); - var query = _context.Person - .Where(p => p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))) - .RestrictAgainstAgeRestriction(ageRating) - .Select(p => new BrowsePersonDto - { - Id = p.Id, - Name = p.Name, - Description = p.Description, - CoverImage = p.CoverImage, - SeriesCount = p.SeriesMetadataPeople - .Where(smp => roles.Contains(smp.Role)) - .Select(smp => smp.SeriesMetadata.SeriesId) - .Distinct() - .Count(), - IssueCount = p.ChapterPeople - .Where(cp => roles.Contains(cp.Role)) - .Select(cp => cp.Chapter.Id) - .Distinct() - .Count() - }) - .OrderBy(p => p.Name); + var query = CreateFilteredPersonQueryable(userId, filter, ageRating); return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } + private IQueryable CreateFilteredPersonQueryable(int userId, BrowsePersonFilterDto filter, AgeRestriction ageRating) + { + var query = _context.Person.AsNoTracking(); + + // Apply filtering based on statements + query = BuildPersonFilterQuery(userId, filter, query); + + // Apply age restriction + query = query.RestrictAgainstAgeRestriction(ageRating); + + // Apply sorting and limiting + var sortedQuery = query.SortBy(filter.SortOptions); + + var limitedQuery = ApplyPersonLimit(sortedQuery, filter.LimitTo); + + // Project to DTO + var projectedQuery = limitedQuery.Select(p => new BrowsePersonDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + CoverImage = p.CoverImage, + SeriesCount = p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count(), + ChapterCount = p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() + }); + + return projectedQuery; + } + + private static IQueryable BuildPersonFilterQuery(int userId, BrowsePersonFilterDto filterDto, IQueryable query) + { + if (filterDto.Statements == null || filterDto.Statements.Count == 0) return query; + + var queries = filterDto.Statements + .Select(statement => BuildPersonFilterGroup(userId, statement, query)) + .ToList(); + + return filterDto.Combination == FilterCombination.And + ? queries.Aggregate((q1, q2) => q1.Intersect(q2)) + : queries.Aggregate((q1, q2) => q1.Union(q2)); + } + + private static IQueryable BuildPersonFilterGroup(int userId, PersonFilterStatementDto statement, IQueryable query) + { + var value = PersonFilterFieldValueConverter.ConvertValue(statement.Field, statement.Value); + + return statement.Field switch + { + PersonFilterField.Name => query.HasPersonName(true, statement.Comparison, (string)value), + PersonFilterField.Role => query.HasPersonRole(true, statement.Comparison, (IList)value), + PersonFilterField.SeriesCount => query.HasPersonSeriesCount(true, statement.Comparison, (int)value), + PersonFilterField.ChapterCount => query.HasPersonChapterCount(true, statement.Comparison, (int)value), + _ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}") + }; + } + + private static IQueryable ApplyPersonLimit(IQueryable query, int limit) + { + return limit <= 0 ? query : query.Take(limit); + } + public async Task GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None) { return await _context.Person.Where(p => p.Id == personId) diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index e04c944e3..e2eab0976 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -735,6 +735,7 @@ public class SeriesRepository : ISeriesRepository { return await _context.Series .Where(s => s.Id == seriesId) + .Include(s => s.ExternalSeriesMetadata) .Select(series => new PlusSeriesRequestDto() { MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), @@ -744,6 +745,7 @@ public class SeriesRepository : ISeriesRepository ScrobblingService.AniListWeblinkWebsite), MalId = ScrobblingService.ExtractId(series.Metadata.WebLinks, ScrobblingService.MalWeblinkWebsite), + CbrId = series.ExternalSeriesMetadata.CbrId, GoogleBooksId = ScrobblingService.ExtractId(series.Metadata.WebLinks, ScrobblingService.GoogleBooksWeblinkWebsite), MangaDexId = ScrobblingService.ExtractId(series.Metadata.WebLinks, @@ -1088,8 +1090,6 @@ public class SeriesRepository : ISeriesRepository return query.Where(s => false); } - - // First setup any FilterField.Libraries in the statements, as these don't have any traditional query statements applied here query = ApplyLibraryFilter(filter, query); @@ -1290,7 +1290,7 @@ public class SeriesRepository : ISeriesRepository FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId), FilterField.ReadLast => query.HasReadLast(true, statement.Comparison, (int) value, userId), FilterField.AverageRating => query.HasAverageRating(true, statement.Comparison, (float) value), - _ => throw new ArgumentOutOfRangeException() + _ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}") }; } diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index c4f189957..ea39d2b0d 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -2,9 +2,11 @@ using System.Linq; using System.Threading.Tasks; using API.DTOs.Metadata; +using API.DTOs.Metadata.Browse; using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Helpers; using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; @@ -23,6 +25,7 @@ public interface ITagRepository Task RemoveAllTagNoLongerAssociated(); Task> GetAllTagDtosForLibrariesAsync(int userId, IList? libraryIds = null); Task> GetAllTagsNotInListAsync(ICollection tags); + Task> GetBrowseableTag(int userId, UserParams userParams); } public class TagRepository : ITagRepository @@ -104,6 +107,30 @@ public class TagRepository : ITagRepository return missingTags.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList(); } + public async Task> GetBrowseableTag(int userId, UserParams userParams) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + var query = _context.Tag + .RestrictAgainstAgeRestriction(ageRating) + .Select(g => new BrowseTagDto + { + Id = g.Id, + Title = g.Title, + SeriesCount = g.SeriesMetadatas + .Select(sm => sm.Id) + .Distinct() + .Count(), + ChapterCount = g.Chapters + .Select(ch => ch.Id) + .Distinct() + .Count() + }) + .OrderBy(g => g.Title); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + public async Task> GetAllTagsAsync() { return await _context.Tag.ToListAsync(); diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 74bfbb296..c08f80afa 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -120,7 +120,7 @@ public static class Seed new AppUserSideNavStream() { Name = "browse-authors", - StreamType = SideNavStreamType.BrowseAuthors, + StreamType = SideNavStreamType.BrowsePeople, Order = 6, IsProvided = true, Visible = true diff --git a/API/Entities/AppUserReadingProfile.cs b/API/Entities/AppUserReadingProfile.cs index ad2548661..9b238b4f5 100644 --- a/API/Entities/AppUserReadingProfile.cs +++ b/API/Entities/AppUserReadingProfile.cs @@ -1,9 +1,22 @@ using System.Collections.Generic; +using System.ComponentModel; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; namespace API.Entities; +public enum BreakPoint +{ + [Description("Never")] + Never = 0, + [Description("Mobile")] + Mobile = 1, + [Description("Tablet")] + Tablet = 2, + [Description("Desktop")] + Desktop = 3, +} + public class AppUserReadingProfile { public int Id { get; set; } @@ -72,6 +85,10 @@ public class AppUserReadingProfile /// Manga Reader Option: Optional fixed width override /// public int? WidthOverride { get; set; } = null; + /// + /// Manga Reader Option: Disable the width override if the screen is past the breakpoint + /// + public BreakPoint DisableWidthOverride { get; set; } = BreakPoint.Never; #endregion diff --git a/API/Entities/SideNavStreamType.cs b/API/Entities/SideNavStreamType.cs index 545c630d8..62f429889 100644 --- a/API/Entities/SideNavStreamType.cs +++ b/API/Entities/SideNavStreamType.cs @@ -10,5 +10,5 @@ public enum SideNavStreamType ExternalSource = 6, AllSeries = 7, WantToRead = 8, - BrowseAuthors = 9 + BrowsePeople = 9 } diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index 4e84e2fa5..8beec88ca 100644 --- a/API/Extensions/EnumerableExtensions.cs +++ b/API/Extensions/EnumerableExtensions.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.RegularExpressions; using API.Data.Misc; using API.Entities.Enums; +using API.Entities.Metadata; namespace API.Extensions; #nullable enable @@ -42,4 +43,16 @@ public static class EnumerableExtensions return q; } + + public static IEnumerable RestrictAgainstAgeRestriction(this IEnumerable items, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return items; + var q = items.Where(s => s.AgeRating <= restriction.AgeRating); + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.AgeRating != AgeRating.Unknown); + } + + return q; + } } diff --git a/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs b/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs new file mode 100644 index 000000000..c36164d9d --- /dev/null +++ b/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; +using API.Entities.Person; +using Kavita.Common; +using Microsoft.EntityFrameworkCore; + +namespace API.Extensions.QueryExtensions.Filtering; + +public static class PersonFilter +{ + public static IQueryable HasPersonName(this IQueryable queryable, bool condition, + FilterComparison comparison, string queryString) + { + if (string.IsNullOrEmpty(queryString) || !condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => p.Name.Equals(queryString)), + FilterComparison.BeginsWith => queryable.Where(p => EF.Functions.Like(p.Name, $"{queryString}%")), + FilterComparison.EndsWith => queryable.Where(p => EF.Functions.Like(p.Name, $"%{queryString}")), + FilterComparison.Matches => queryable.Where(p => EF.Functions.Like(p.Name, $"%{queryString}%")), + FilterComparison.NotEqual => queryable.Where(p => p.Name != queryString), + FilterComparison.NotContains or FilterComparison.GreaterThan or FilterComparison.GreaterThanEqual + or FilterComparison.LessThan or FilterComparison.LessThanEqual or FilterComparison.Contains + or FilterComparison.IsBefore or FilterComparison.IsAfter or FilterComparison.IsInLast + or FilterComparison.IsNotInLast or FilterComparison.MustContains + or FilterComparison.IsEmpty => + throw new KavitaException($"{comparison} not applicable for Person.Name"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, + "Filter Comparison is not supported") + }; + } + public static IQueryable HasPersonRole(this IQueryable queryable, bool condition, + FilterComparison comparison, IList roles) + { + if (roles == null || roles.Count == 0 || !condition) return queryable; + + return comparison switch + { + FilterComparison.Contains or FilterComparison.MustContains => queryable.Where(p => + p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || + p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))), + FilterComparison.NotContains => queryable.Where(p => + !p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) && + !p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))), + FilterComparison.Equal or FilterComparison.NotEqual or FilterComparison.BeginsWith + or FilterComparison.EndsWith or FilterComparison.Matches or FilterComparison.GreaterThan + or FilterComparison.GreaterThanEqual or FilterComparison.LessThan or FilterComparison.LessThanEqual + or FilterComparison.IsBefore or FilterComparison.IsAfter or FilterComparison.IsInLast + or FilterComparison.IsNotInLast + or FilterComparison.IsEmpty => + throw new KavitaException($"{comparison} not applicable for Person.Role"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, + "Filter Comparison is not supported") + }; + } + + public static IQueryable HasPersonSeriesCount(this IQueryable queryable, bool condition, + FilterComparison comparison, int count) + { + if (!condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() == count), + FilterComparison.GreaterThan => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() > count), + FilterComparison.GreaterThanEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() >= count), + FilterComparison.LessThan => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() < count), + FilterComparison.LessThanEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() <= count), + FilterComparison.NotEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() != count), + FilterComparison.BeginsWith or FilterComparison.EndsWith or FilterComparison.Matches + or FilterComparison.Contains or FilterComparison.NotContains or FilterComparison.IsBefore + or FilterComparison.IsAfter or FilterComparison.IsInLast or FilterComparison.IsNotInLast + or FilterComparison.MustContains + or FilterComparison.IsEmpty => throw new KavitaException( + $"{comparison} not applicable for Person.SeriesCount"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported") + }; + } + + public static IQueryable HasPersonChapterCount(this IQueryable queryable, bool condition, + FilterComparison comparison, int count) + { + if (!condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() == count), + FilterComparison.GreaterThan => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() > count), + FilterComparison.GreaterThanEqual => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() >= count), + FilterComparison.LessThan => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() < count), + FilterComparison.LessThanEqual => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() <= count), + FilterComparison.NotEqual => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() != count), + FilterComparison.BeginsWith or FilterComparison.EndsWith or FilterComparison.Matches + or FilterComparison.Contains or FilterComparison.NotContains or FilterComparison.IsBefore + or FilterComparison.IsAfter or FilterComparison.IsInLast or FilterComparison.IsNotInLast + or FilterComparison.MustContains + or FilterComparison.IsEmpty => throw new KavitaException( + $"{comparison} not applicable for Person.ChapterCount"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported") + }; + } +} diff --git a/API/Extensions/QueryExtensions/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs index a2db1dde7..ef2af721f 100644 --- a/API/Extensions/QueryExtensions/QueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -5,10 +5,13 @@ using System.Linq.Expressions; using System.Threading.Tasks; using API.Data.Misc; using API.Data.Repositories; +using API.DTOs; using API.DTOs.Filtering; using API.DTOs.KavitaPlus.Manage; +using API.DTOs.Metadata.Browse; using API.Entities; using API.Entities.Enums; +using API.Entities.Person; using API.Entities.Scrobble; using Microsoft.EntityFrameworkCore; @@ -273,6 +276,27 @@ public static class QueryableExtensions }; } + public static IQueryable SortBy(this IQueryable query, PersonSortOptions? sort) + { + if (sort == null) + { + return query.OrderBy(p => p.Name); + } + + return sort.SortField switch + { + PersonSortField.Name when sort.IsAscending => query.OrderBy(p => p.Name), + PersonSortField.Name => query.OrderByDescending(p => p.Name), + PersonSortField.SeriesCount when sort.IsAscending => query.OrderBy(p => p.SeriesMetadataPeople.Count), + PersonSortField.SeriesCount => query.OrderByDescending(p => p.SeriesMetadataPeople.Count), + PersonSortField.ChapterCount when sort.IsAscending => query.OrderBy(p => p.ChapterPeople.Count), + PersonSortField.ChapterCount => query.OrderByDescending(p => p.ChapterPeople.Count), + _ => query.OrderBy(p => p.Name) + }; + + + } + /// /// Performs either OrderBy or OrderByDescending on the given query based on the value of SortOptions.IsAscending. /// diff --git a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs index fc3314f58..aef595596 100644 --- a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -3,6 +3,7 @@ using System.Linq; using API.Data.Misc; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Entities.Person; namespace API.Extensions.QueryExtensions; @@ -26,6 +27,7 @@ public static class RestrictByAgeExtensions return q; } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; @@ -39,20 +41,6 @@ public static class RestrictByAgeExtensions return q; } - [Obsolete] - public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) - { - if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; - - if (restriction.IncludeUnknowns) - { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); - } - - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); - } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { @@ -74,12 +62,15 @@ public static class RestrictByAgeExtensions if (restriction.IncludeUnknowns) { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating)); } - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating && sm.AgeRating != AgeRating.Unknown) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating && cp.AgeRating != AgeRating.Unknown) + ); } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) @@ -88,12 +79,15 @@ public static class RestrictByAgeExtensions if (restriction.IncludeUnknowns) { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating)); } - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating && sm.AgeRating != AgeRating.Unknown) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating && cp.AgeRating != AgeRating.Unknown) + ); } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index d25444a51..bb7511c64 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -286,7 +286,8 @@ public class AutoMapperProfiles : Profile CreateMap(); CreateMap() - .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)); + .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)) + .ForMember(dest => dest.OwnerUserName, opt => opt.MapFrom(src => src.AppUser.UserName)); CreateMap(); CreateMap(); CreateMap(); diff --git a/API/Helpers/Converters/PersonFilterFieldValueConverter.cs b/API/Helpers/Converters/PersonFilterFieldValueConverter.cs new file mode 100644 index 000000000..822ce105a --- /dev/null +++ b/API/Helpers/Converters/PersonFilterFieldValueConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; + +namespace API.Helpers.Converters; + +public static class PersonFilterFieldValueConverter +{ + public static object ConvertValue(PersonFilterField field, string value) + { + return field switch + { + PersonFilterField.Name => value, + PersonFilterField.Role => ParsePersonRoles(value), + PersonFilterField.SeriesCount => int.Parse(value), + PersonFilterField.ChapterCount => int.Parse(value), + _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Field is not supported") + }; + } + + private static IList ParsePersonRoles(string value) + { + if (string.IsNullOrEmpty(value)) return []; + + return value.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(v => Enum.Parse(v.Trim())) + .ToList(); + } +} diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 0255b785d..544efa4ce 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -10,11 +10,9 @@ using API.Entities.Interfaces; using API.Extensions; using Microsoft.Extensions.Logging; using NetVips; -using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing.Processors.Quantization; -using Color = System.Drawing.Color; using Image = NetVips.Image; namespace API.Services; @@ -750,7 +748,7 @@ public class ImageService : IImageService } - public static Color HexToRgb(string? hex) + public static (int R, int G, int B) HexToRgb(string? hex) { if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null"); @@ -774,7 +772,7 @@ public class ImageService : IImageService var g = Convert.ToInt32(hex.Substring(2, 2), 16); var b = Convert.ToInt32(hex.Substring(4, 2), 16); - return Color.FromArgb(r, g, b); + return (r, g, b); } diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index a1e3750dd..435727bda 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -200,6 +200,9 @@ public class ExternalMetadataService : IExternalMetadataService /// /// Returns the match results for a Series from UI Flow /// + /// + /// Will extract alternative names like Localized name, year will send as ReleaseYear but fallback to Comic Vine syntax if applicable + /// /// /// public async Task> MatchSeries(MatchSeriesDto dto) @@ -212,19 +215,26 @@ public class ExternalMetadataService : IExternalMetadataService var potentialAnilistId = ScrobblingService.ExtractId(dto.Query, ScrobblingService.AniListWeblinkWebsite); var potentialMalId = ScrobblingService.ExtractId(dto.Query, ScrobblingService.MalWeblinkWebsite); - List altNames = [series.LocalizedName, series.OriginalName]; - if (potentialAnilistId == null && potentialMalId == null && !string.IsNullOrEmpty(dto.Query)) + var format = series.Library.Type.ConvertToPlusMediaFormat(series.Format); + var otherNames = ExtractAlternativeNames(series); + + var year = series.Metadata.ReleaseYear; + if (year == 0 && format == PlusMediaFormat.Comic && !string.IsNullOrWhiteSpace(series.Name)) { - altNames.Add(dto.Query); + var potentialYear = Parser.ParseYear(series.Name); + if (!string.IsNullOrEmpty(potentialYear)) + { + year = int.Parse(potentialYear); + } } var matchRequest = new MatchSeriesRequestDto() { - Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), + Format = format, Query = dto.Query, SeriesName = series.Name, - AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(), - Year = series.Metadata.ReleaseYear, + AlternativeNames = otherNames, + Year = year, AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series), MalId = potentialMalId ?? ScrobblingService.GetMalId(series) }; @@ -254,6 +264,12 @@ public class ExternalMetadataService : IExternalMetadataService return ArraySegment.Empty; } + private static List ExtractAlternativeNames(Series series) + { + List altNames = [series.LocalizedName, series.OriginalName]; + return altNames.Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList(); + } + /// /// Retrieves Metadata about a Recommended External Series diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index 774103518..91f5a8fdd 100644 --- a/API/Services/Plus/LicenseService.cs +++ b/API/Services/Plus/LicenseService.cs @@ -130,22 +130,23 @@ public class LicenseService( if (cacheValue.HasValue) return cacheValue.Value; } + var result = false; try { var serverSetting = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - var result = await IsLicenseValid(serverSetting.Value); - await provider.FlushAsync(); - await provider.SetAsync(CacheKey, result, _licenseCacheTimeout); - return result; + result = await IsLicenseValid(serverSetting.Value); } catch (Exception ex) { logger.LogError(ex, "There was an issue connecting to Kavita+"); + } + finally + { await provider.FlushAsync(); - await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); + await provider.SetAsync(CacheKey, result, _licenseCacheTimeout); } - return false; + return result; } /// diff --git a/API/Services/ReadingProfileService.cs b/API/Services/ReadingProfileService.cs index fbfabad70..4c3dab006 100644 --- a/API/Services/ReadingProfileService.cs +++ b/API/Services/ReadingProfileService.cs @@ -432,6 +432,7 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService existingProfile.SwipeToPaginate = dto.SwipeToPaginate; existingProfile.AllowAutomaticWebtoonReaderDetection = dto.AllowAutomaticWebtoonReaderDetection; existingProfile.WidthOverride = dto.WidthOverride; + existingProfile.DisableWidthOverride = dto.DisableWidthOverride; // Book Reader existingProfile.BookReaderMargin = dto.BookReaderMargin; diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index 99d02401b..015613965 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -206,17 +206,12 @@ public class CoverDbService : ICoverDbService throw new KavitaException($"Could not grab publisher image for {publisherName}"); } - _logger.LogTrace("Fetching publisher image from {Url}", publisherLink.Sanitize()); - // Download the publisher file using Flurl - var publisherStream = await publisherLink - .AllowHttpStatus("2xx,304") - .GetStreamAsync(); - // Create the destination file path - using var image = Image.NewFromStream(publisherStream); var filename = ImageService.GetPublisherFormat(publisherName, encodeFormat); - image.WriteToFile(Path.Combine(_directoryService.PublisherDirectory, filename)); + _logger.LogTrace("Fetching publisher image from {Url}", publisherLink.Sanitize()); + await DownloadImageFromUrl(publisherName, encodeFormat, publisherLink, _directoryService.PublisherDirectory); + _logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName.Sanitize()); return filename; @@ -302,7 +297,27 @@ public class CoverDbService : ICoverDbService .GetStreamAsync(); using var image = Image.NewFromStream(imageStream); - image.WriteToFile(targetFile); + try + { + image.WriteToFile(targetFile); + } + catch (Exception ex) + { + switch (encodeFormat) + { + case EncodeFormat.PNG: + image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + case EncodeFormat.WEBP: + image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + case EncodeFormat.AVIF: + image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + } + } return filename; } @@ -385,14 +400,13 @@ public class CoverDbService : ICoverDbService private async Task FallbackToKavitaReaderPublisher(string publisherName) { const string publisherFileName = "publishers.txt"; - var externalLink = string.Empty; var allOverrides = await GetCachedData(publisherFileName) ?? await $"{NewHost}publishers/{publisherFileName}".GetStringAsync(); // Cache immediately await CacheDataAsync(publisherFileName, allOverrides); - if (string.IsNullOrEmpty(allOverrides)) return externalLink; + if (string.IsNullOrEmpty(allOverrides)) return string.Empty; var externalFile = allOverrides .Split("\n") @@ -415,7 +429,7 @@ public class CoverDbService : ICoverDbService throw new KavitaException($"Could not grab publisher image for {publisherName}"); } - return $"{NewHost}publishers/{externalLink}"; + return $"{NewHost}publishers/{externalFile}"; } private async Task CacheDataAsync(string fileName, string? content) @@ -572,8 +586,7 @@ public class CoverDbService : ICoverDbService var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase); if (choseNewImage) { - - // Don't delete series cover, unless it's an override, otherwise the first chapter cover will be null + // Don't delete the Series cover unless it is an override, otherwise the first chapter will be null if (existingPath.Contains(ImageService.GetSeriesFormat(series.Id))) { _directoryService.DeleteFiles([existingPath]); @@ -624,6 +637,7 @@ public class CoverDbService : ICoverDbService } } + // TODO: Refactor this to IHasCoverImage instead of a hard entity type public async Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false) { if (!string.IsNullOrEmpty(url)) diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 12987b18b..c8eb010b3 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -1159,6 +1159,12 @@ public static partial class Parser return !string.IsNullOrEmpty(name) && SeriesAndYearRegex.IsMatch(name); } + /// + /// Parse a Year from a Comic Series: Series Name (YEAR) + /// + /// Harley Quinn (2024) returns 2024 + /// + /// public static string ParseYear(string? name) { if (string.IsNullOrEmpty(name)) return string.Empty; diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 3450cdce3..f2d64cde6 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -17,7 +17,7 @@ public static class Configuration private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development - ? "https://plus.kavitareader.com" : "https://plus.kavitareader.com"; + ? "http://localhost:5020" : "https://plus.kavitareader.com"; public static readonly string StatsApiUrl = "https://stats.kavitareader.com"; public static int Port diff --git a/README.md b/README.md index bff8f0f5c..ffff8d831 100644 --- a/README.md +++ b/README.md @@ -107,13 +107,10 @@ Support this project by becoming a sponsor. Your logo will show up here with a l ## Mega Sponsors -## JetBrains -Thank you to [ JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools. - -* [ Rider](http://www.jetbrains.com/rider/) +## Powered By +[![JetBrains logo.](https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg)](https://jb.gg/OpenSource) ### License - * [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) * Copyright 2020-2024 diff --git a/UI/Web/src/_tag-card-common.scss b/UI/Web/src/_tag-card-common.scss new file mode 100644 index 000000000..07f37c2a0 --- /dev/null +++ b/UI/Web/src/_tag-card-common.scss @@ -0,0 +1,30 @@ +.tag-card { + background-color: var(--bs-card-color, #2c2c2c); + padding: 1rem; + border-radius: 12px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + transition: transform 0.2s ease, background 0.3s ease; + cursor: pointer; +} + +.tag-card:hover { + background-color: #3a3a3a; + //transform: translateY(-3px); // Cool effect but has a weird background issue. ROBBIE: Fix this +} + +.tag-name { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.5rem; + max-height: 8rem; + height: 8rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.tag-meta { + font-size: 0.85rem; + display: flex; + justify-content: space-between; + color: var(--text-muted-color, #bbb); +} diff --git a/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts b/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts index a8dc1ce06..05a4041c8 100644 --- a/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts +++ b/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts @@ -1,6 +1,8 @@ import {MatchStateOption} from "./match-state-option"; +import {LibraryType} from "../library/library"; export interface ManageMatchFilter { matchStateOption: MatchStateOption; + libraryType: LibraryType | -1; searchTerm: string; } diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index 06ba86cf2..bad83f54b 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -13,6 +13,8 @@ export enum LibraryType { } export const allLibraryTypes = [LibraryType.Manga, LibraryType.ComicVine, LibraryType.Comic, LibraryType.Book, LibraryType.LightNovel, LibraryType.Images]; +export const allKavitaPlusMetadataApplicableTypes = [LibraryType.Manga, LibraryType.LightNovel, LibraryType.ComicVine, LibraryType.Comic]; +export const allKavitaPlusScrobbleEligibleTypes = [LibraryType.Manga, LibraryType.LightNovel]; export interface Library { id: number; diff --git a/UI/Web/src/app/_models/metadata/browse/browse-genre.ts b/UI/Web/src/app/_models/metadata/browse/browse-genre.ts new file mode 100644 index 000000000..e7bb0d915 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/browse/browse-genre.ts @@ -0,0 +1,6 @@ +import {Genre} from "../genre"; + +export interface BrowseGenre extends Genre { + seriesCount: number; + chapterCount: number; +} diff --git a/UI/Web/src/app/_models/person/browse-person.ts b/UI/Web/src/app/_models/metadata/browse/browse-person.ts similarity index 52% rename from UI/Web/src/app/_models/person/browse-person.ts rename to UI/Web/src/app/_models/metadata/browse/browse-person.ts index aeddac7cd..886f9455b 100644 --- a/UI/Web/src/app/_models/person/browse-person.ts +++ b/UI/Web/src/app/_models/metadata/browse/browse-person.ts @@ -1,6 +1,6 @@ -import {Person} from "../metadata/person"; +import {Person} from "../person"; export interface BrowsePerson extends Person { seriesCount: number; - issueCount: number; + chapterCount: number; } diff --git a/UI/Web/src/app/_models/metadata/browse/browse-tag.ts b/UI/Web/src/app/_models/metadata/browse/browse-tag.ts new file mode 100644 index 000000000..4d87370ee --- /dev/null +++ b/UI/Web/src/app/_models/metadata/browse/browse-tag.ts @@ -0,0 +1,6 @@ +import {Tag} from "../../tag"; + +export interface BrowseTag extends Tag { + seriesCount: number; + chapterCount: number; +} diff --git a/UI/Web/src/app/_models/metadata/language.ts b/UI/Web/src/app/_models/metadata/language.ts index 8b68c7233..28ab2b598 100644 --- a/UI/Web/src/app/_models/metadata/language.ts +++ b/UI/Web/src/app/_models/metadata/language.ts @@ -4,7 +4,10 @@ export interface Language { } export interface KavitaLocale { - fileName: string; // isoCode aka what maps to the file on disk and what transloco loads + /** + * isoCode aka what maps to the file on disk and what transloco loads + */ + fileName: string; renderName: string; translationCompletion: number; isRtL: boolean; diff --git a/UI/Web/src/app/_models/metadata/person.ts b/UI/Web/src/app/_models/metadata/person.ts index 6b098de19..efc8df914 100644 --- a/UI/Web/src/app/_models/metadata/person.ts +++ b/UI/Web/src/app/_models/metadata/person.ts @@ -2,7 +2,6 @@ import {IHasCover} from "../common/i-has-cover"; export enum PersonRole { Other = 1, - Artist = 2, Writer = 3, Penciller = 4, Inker = 5, @@ -32,3 +31,22 @@ export interface Person extends IHasCover { primaryColor: string; secondaryColor: string; } + +/** + * Excludes Other as it's not in use + */ +export const allPeopleRoles = [ + PersonRole.Writer, + PersonRole.Penciller, + PersonRole.Inker, + PersonRole.Colorist, + PersonRole.Letterer, + PersonRole.CoverArtist, + PersonRole.Editor, + PersonRole.Publisher, + PersonRole.Character, + PersonRole.Translator, + PersonRole.Imprint, + PersonRole.Team, + PersonRole.Location +] diff --git a/UI/Web/src/app/_models/metadata/series-filter.ts b/UI/Web/src/app/_models/metadata/series-filter.ts index 7d043aa3c..7875732b7 100644 --- a/UI/Web/src/app/_models/metadata/series-filter.ts +++ b/UI/Web/src/app/_models/metadata/series-filter.ts @@ -1,5 +1,5 @@ import {MangaFormat} from "../manga-format"; -import {SeriesFilterV2} from "./v2/series-filter-v2"; +import {FilterV2} from "./v2/filter-v2"; export interface FilterItem { title: string; @@ -7,10 +7,6 @@ export interface FilterItem { selected: boolean; } -export interface SortOptions { - sortField: SortField; - isAscending: boolean; -} export enum SortField { SortName = 1, @@ -27,7 +23,7 @@ export enum SortField { Random = 9 } -export const allSortFields = Object.keys(SortField) +export const allSeriesSortFields = Object.keys(SortField) .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) .map(key => parseInt(key, 10)) as SortField[]; @@ -54,8 +50,8 @@ export const mangaFormatFilters = [ } ]; -export interface FilterEvent { - filterV2: SeriesFilterV2; +export interface FilterEvent { + filterV2: FilterV2; isFirst: boolean; } diff --git a/UI/Web/src/app/_models/metadata/v2/browse-person-filter.ts b/UI/Web/src/app/_models/metadata/v2/browse-person-filter.ts new file mode 100644 index 000000000..bb5edc9ce --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/browse-person-filter.ts @@ -0,0 +1,8 @@ +import {PersonRole} from "../person"; +import {PersonSortOptions} from "./sort-options"; + +export interface BrowsePersonFilter { + roles: Array; + query?: string; + sortOptions?: PersonSortOptions; +} diff --git a/UI/Web/src/app/_models/metadata/v2/filter-field.ts b/UI/Web/src/app/_models/metadata/v2/filter-field.ts index 08005d5c8..eeb8c7853 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-field.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-field.ts @@ -48,7 +48,7 @@ const enumArray = Object.keys(FilterField) enumArray.sort((a, b) => a.value.localeCompare(b.value)); -export const allFields = enumArray +export const allSeriesFilterFields = enumArray .map(key => parseInt(key.key, 10))as FilterField[]; export const allPeople = [ @@ -66,7 +66,6 @@ export const allPeople = [ export const personRoleForFilterField = (role: PersonRole) => { switch (role) { - case PersonRole.Artist: return FilterField.CoverArtist; case PersonRole.Character: return FilterField.Characters; case PersonRole.Colorist: return FilterField.Colorist; case PersonRole.CoverArtist: return FilterField.CoverArtist; diff --git a/UI/Web/src/app/_models/metadata/v2/filter-statement.ts b/UI/Web/src/app/_models/metadata/v2/filter-statement.ts index d031927a2..b14fe564d 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-statement.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-statement.ts @@ -1,8 +1,7 @@ -import { FilterComparison } from "./filter-comparison"; -import { FilterField } from "./filter-field"; +import {FilterComparison} from "./filter-comparison"; -export interface FilterStatement { +export interface FilterStatement { comparison: FilterComparison; - field: FilterField; + field: T; value: string; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/_models/metadata/v2/filter-v2.ts b/UI/Web/src/app/_models/metadata/v2/filter-v2.ts new file mode 100644 index 000000000..77c064450 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/filter-v2.ts @@ -0,0 +1,11 @@ +import {FilterStatement} from "./filter-statement"; +import {FilterCombination} from "./filter-combination"; +import {SortOptions} from "./sort-options"; + +export interface FilterV2 { + name?: string; + statements: Array>; + combination: FilterCombination; + sortOptions?: SortOptions; + limitTo: number; +} diff --git a/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts b/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts new file mode 100644 index 000000000..6bfb5a0c1 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts @@ -0,0 +1,12 @@ +export enum PersonFilterField { + Role = 1, + Name = 2, + SeriesCount = 3, + ChapterCount = 4, +} + + +export const allPersonFilterFields = Object.keys(PersonFilterField) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as PersonFilterField[]; + diff --git a/UI/Web/src/app/_models/metadata/v2/person-sort-field.ts b/UI/Web/src/app/_models/metadata/v2/person-sort-field.ts new file mode 100644 index 000000000..6bcb66925 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/person-sort-field.ts @@ -0,0 +1,9 @@ +export enum PersonSortField { + Name = 1, + SeriesCount = 2, + ChapterCount = 3 +} + +export const allPersonSortFields = Object.keys(PersonSortField) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as PersonSortField[]; diff --git a/UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts b/UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts deleted file mode 100644 index c13244644..000000000 --- a/UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { SortOptions } from "../series-filter"; -import {FilterStatement} from "./filter-statement"; -import {FilterCombination} from "./filter-combination"; - -export interface SeriesFilterV2 { - name?: string; - statements: Array; - combination: FilterCombination; - sortOptions?: SortOptions; - limitTo: number; -} diff --git a/UI/Web/src/app/_models/metadata/v2/sort-options.ts b/UI/Web/src/app/_models/metadata/v2/sort-options.ts new file mode 100644 index 000000000..ed68d6b9d --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/sort-options.ts @@ -0,0 +1,17 @@ +import {PersonSortField} from "./person-sort-field"; + +/** + * Series-based Sort options + */ +export interface SortOptions { + sortField: TSort; + isAscending: boolean; +} + +/** + * Person-based Sort Options + */ +export interface PersonSortOptions { + sortField: PersonSortField; + isAscending: boolean; +} diff --git a/UI/Web/src/app/_models/preferences/reading-profiles.ts b/UI/Web/src/app/_models/preferences/reading-profiles.ts index d81b8cc88..dad02946f 100644 --- a/UI/Web/src/app/_models/preferences/reading-profiles.ts +++ b/UI/Web/src/app/_models/preferences/reading-profiles.ts @@ -12,6 +12,7 @@ import {PdfLayoutMode} from "./pdf-layout-mode"; import {PdfSpreadMode} from "./pdf-spread-mode"; import {Series} from "../series"; import {Library} from "../library/library"; +import {UserBreakpoint} from "../../shared/_services/utility.service"; export enum ReadingProfileKind { Default = 0, @@ -39,6 +40,7 @@ export interface ReadingProfile { swipeToPaginate: boolean; allowAutomaticWebtoonReaderDetection: boolean; widthOverride?: number; + disableWidthOverride: UserBreakpoint; // Book Reader bookReaderMargin: number; @@ -75,3 +77,4 @@ export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multi export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}]; export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}]; export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}]; +export const breakPoints = [UserBreakpoint.Never, UserBreakpoint.Mobile, UserBreakpoint.Tablet, UserBreakpoint.Desktop] diff --git a/UI/Web/src/app/_models/wiki.ts b/UI/Web/src/app/_models/wiki.ts index 21b669f0c..a01267cf3 100644 --- a/UI/Web/src/app/_models/wiki.ts +++ b/UI/Web/src/app/_models/wiki.ts @@ -20,5 +20,6 @@ export enum WikiLink { UpdateNative = 'https://wiki.kavitareader.com/guides/updating/updating-native', UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker', OpdsClients = 'https://wiki.kavitareader.com/guides/features/opds/#opds-capable-clients', - Guides = 'https://wiki.kavitareader.com/guides' + Guides = 'https://wiki.kavitareader.com/guides', + ReadingProfiles = "https://wiki.kavitareader.com/guides/user-settings/reading-profiles/", } diff --git a/UI/Web/src/app/_pipes/breakpoint.pipe.ts b/UI/Web/src/app/_pipes/breakpoint.pipe.ts new file mode 100644 index 000000000..1897b773c --- /dev/null +++ b/UI/Web/src/app/_pipes/breakpoint.pipe.ts @@ -0,0 +1,25 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {translate} from "@jsverse/transloco"; +import {UserBreakpoint} from "../shared/_services/utility.service"; + +@Pipe({ + name: 'breakpoint' +}) +export class BreakpointPipe implements PipeTransform { + + transform(value: UserBreakpoint): string { + const v = parseInt(value + '', 10) as UserBreakpoint; + switch (v) { + case UserBreakpoint.Never: + return translate('breakpoint-pipe.never'); + case UserBreakpoint.Mobile: + return translate('breakpoint-pipe.mobile'); + case UserBreakpoint.Tablet: + return translate('breakpoint-pipe.tablet'); + case UserBreakpoint.Desktop: + return translate('breakpoint-pipe.desktop'); + } + throw new Error("unknown breakpoint value: " + value); + } + +} diff --git a/UI/Web/src/app/_pipes/browse-title.pipe.ts b/UI/Web/src/app/_pipes/browse-title.pipe.ts new file mode 100644 index 000000000..0495e8b8a --- /dev/null +++ b/UI/Web/src/app/_pipes/browse-title.pipe.ts @@ -0,0 +1,78 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {FilterField} from "../_models/metadata/v2/filter-field"; +import {translate} from "@jsverse/transloco"; + +/** + * Responsible for taking a filter field and value (as a string) and translating into a "Browse X" heading for All Series page + * Example: Genre & "Action" -> Browse Action + * Example: Artist & "Joe Shmo" -> Browse Joe Shmo Works + */ +@Pipe({ + name: 'browseTitle' +}) +export class BrowseTitlePipe implements PipeTransform { + + transform(field: FilterField, value: string): string { + switch (field) { + case FilterField.PublicationStatus: + return translate('browse-title-pipe.publication-status', {value}); + case FilterField.AgeRating: + return translate('browse-title-pipe.age-rating', {value}); + case FilterField.UserRating: + return translate('browse-title-pipe.user-rating', {value}); + case FilterField.Tags: + return translate('browse-title-pipe.tag', {value}); + case FilterField.Translators: + return translate('browse-title-pipe.translator', {value}); + case FilterField.Characters: + return translate('browse-title-pipe.character', {value}); + case FilterField.Publisher: + return translate('browse-title-pipe.publisher', {value}); + case FilterField.Editor: + return translate('browse-title-pipe.editor', {value}); + case FilterField.CoverArtist: + return translate('browse-title-pipe.artist', {value}); + case FilterField.Letterer: + return translate('browse-title-pipe.letterer', {value}); + case FilterField.Colorist: + return translate('browse-title-pipe.colorist', {value}); + case FilterField.Inker: + return translate('browse-title-pipe.inker', {value}); + case FilterField.Penciller: + return translate('browse-title-pipe.penciller', {value}); + case FilterField.Writers: + return translate('browse-title-pipe.writer', {value}); + case FilterField.Genres: + return translate('browse-title-pipe.genre', {value}); + case FilterField.Libraries: + return translate('browse-title-pipe.library', {value}); + case FilterField.Formats: + return translate('browse-title-pipe.format', {value}); + case FilterField.ReleaseYear: + return translate('browse-title-pipe.release-year', {value}); + case FilterField.Imprint: + return translate('browse-title-pipe.imprint', {value}); + case FilterField.Team: + return translate('browse-title-pipe.team', {value}); + case FilterField.Location: + return translate('browse-title-pipe.location', {value}); + + // These have no natural links in the app to demand a richer title experience + case FilterField.Languages: + case FilterField.CollectionTags: + case FilterField.ReadProgress: + case FilterField.ReadTime: + case FilterField.Path: + case FilterField.FilePath: + case FilterField.WantToRead: + case FilterField.ReadingDate: + case FilterField.AverageRating: + case FilterField.ReadLast: + case FilterField.Summary: + case FilterField.SeriesName: + default: + return ''; + } + } + +} diff --git a/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts new file mode 100644 index 000000000..f342c0034 --- /dev/null +++ b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts @@ -0,0 +1,108 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {FilterField} from "../_models/metadata/v2/filter-field"; +import {translate} from "@jsverse/transloco"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; + +@Pipe({ + name: 'genericFilterField' +}) +export class GenericFilterFieldPipe implements PipeTransform { + + transform(value: T, entityType: ValidFilterEntity): string { + + switch (entityType) { + case "series": + return this.translateFilterField(value as FilterField); + case "person": + return this.translatePersonFilterField(value as PersonFilterField); + } + } + + private translatePersonFilterField(value: PersonFilterField) { + switch (value) { + case PersonFilterField.Role: + return translate('generic-filter-field-pipe.person-role'); + case PersonFilterField.Name: + return translate('generic-filter-field-pipe.person-name'); + case PersonFilterField.SeriesCount: + return translate('generic-filter-field-pipe.person-series-count'); + case PersonFilterField.ChapterCount: + return translate('generic-filter-field-pipe.person-chapter-count'); + } + } + + private translateFilterField(value: FilterField) { + switch (value) { + case FilterField.AgeRating: + return translate('filter-field-pipe.age-rating'); + case FilterField.Characters: + return translate('filter-field-pipe.characters'); + case FilterField.CollectionTags: + return translate('filter-field-pipe.collection-tags'); + case FilterField.Colorist: + return translate('filter-field-pipe.colorist'); + case FilterField.CoverArtist: + return translate('filter-field-pipe.cover-artist'); + case FilterField.Editor: + return translate('filter-field-pipe.editor'); + case FilterField.Formats: + return translate('filter-field-pipe.formats'); + case FilterField.Genres: + return translate('filter-field-pipe.genres'); + case FilterField.Inker: + return translate('filter-field-pipe.inker'); + case FilterField.Imprint: + return translate('filter-field-pipe.imprint'); + case FilterField.Team: + return translate('filter-field-pipe.team'); + case FilterField.Location: + return translate('filter-field-pipe.location'); + case FilterField.Languages: + return translate('filter-field-pipe.languages'); + case FilterField.Libraries: + return translate('filter-field-pipe.libraries'); + case FilterField.Letterer: + return translate('filter-field-pipe.letterer'); + case FilterField.PublicationStatus: + return translate('filter-field-pipe.publication-status'); + case FilterField.Penciller: + return translate('filter-field-pipe.penciller'); + case FilterField.Publisher: + return translate('filter-field-pipe.publisher'); + case FilterField.ReadProgress: + return translate('filter-field-pipe.read-progress'); + case FilterField.ReadTime: + return translate('filter-field-pipe.read-time'); + case FilterField.ReleaseYear: + return translate('filter-field-pipe.release-year'); + case FilterField.SeriesName: + return translate('filter-field-pipe.series-name'); + case FilterField.Summary: + return translate('filter-field-pipe.summary'); + case FilterField.Tags: + return translate('filter-field-pipe.tags'); + case FilterField.Translators: + return translate('filter-field-pipe.translators'); + case FilterField.UserRating: + return translate('filter-field-pipe.user-rating'); + case FilterField.Writers: + return translate('filter-field-pipe.writers'); + case FilterField.Path: + return translate('filter-field-pipe.path'); + case FilterField.FilePath: + return translate('filter-field-pipe.file-path'); + case FilterField.WantToRead: + return translate('filter-field-pipe.want-to-read'); + case FilterField.ReadingDate: + return translate('filter-field-pipe.read-date'); + case FilterField.ReadLast: + return translate('filter-field-pipe.read-last'); + case FilterField.AverageRating: + return translate('filter-field-pipe.average-rating'); + default: + throw new Error(`Invalid FilterField value: ${value}`); + } + } + +} diff --git a/UI/Web/src/app/_pipes/person-role.pipe.ts b/UI/Web/src/app/_pipes/person-role.pipe.ts index c1395ae5b..1b9ee2163 100644 --- a/UI/Web/src/app/_pipes/person-role.pipe.ts +++ b/UI/Web/src/app/_pipes/person-role.pipe.ts @@ -1,6 +1,6 @@ -import {inject, Pipe, PipeTransform} from '@angular/core'; -import { PersonRole } from '../_models/metadata/person'; -import {translate, TranslocoService} from "@jsverse/transloco"; +import {Pipe, PipeTransform} from '@angular/core'; +import {PersonRole} from '../_models/metadata/person'; +import {translate} from "@jsverse/transloco"; @Pipe({ name: 'personRole', @@ -10,8 +10,6 @@ export class PersonRolePipe implements PipeTransform { transform(value: PersonRole): string { switch (value) { - case PersonRole.Artist: - return translate('person-role-pipe.artist'); case PersonRole.Character: return translate('person-role-pipe.character'); case PersonRole.Colorist: diff --git a/UI/Web/src/app/_pipes/sort-field.pipe.ts b/UI/Web/src/app/_pipes/sort-field.pipe.ts index 13ff4f758..d032de9c8 100644 --- a/UI/Web/src/app/_pipes/sort-field.pipe.ts +++ b/UI/Web/src/app/_pipes/sort-field.pipe.ts @@ -1,6 +1,8 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {SortField} from "../_models/metadata/series-filter"; import {TranslocoService} from "@jsverse/transloco"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Pipe({ name: 'sortField', @@ -11,7 +13,30 @@ export class SortFieldPipe implements PipeTransform { constructor(private translocoService: TranslocoService) { } - transform(value: SortField): string { + transform(value: T, entityType: ValidFilterEntity): string { + + switch (entityType) { + case 'series': + return this.seriesSortFields(value as SortField); + case 'person': + return this.personSortFields(value as PersonSortField); + + } + } + + private personSortFields(value: PersonSortField) { + switch (value) { + case PersonSortField.Name: + return this.translocoService.translate('sort-field-pipe.person-name'); + case PersonSortField.SeriesCount: + return this.translocoService.translate('sort-field-pipe.person-series-count'); + case PersonSortField.ChapterCount: + return this.translocoService.translate('sort-field-pipe.person-chapter-count'); + + } + } + + private seriesSortFields(value: SortField) { switch (value) { case SortField.SortName: return this.translocoService.translate('sort-field-pipe.sort-name'); @@ -32,7 +57,6 @@ export class SortFieldPipe implements PipeTransform { case SortField.Random: return this.translocoService.translate('sort-field-pipe.random'); } - } } diff --git a/UI/Web/src/app/_resolvers/url-filter.resolver.ts b/UI/Web/src/app/_resolvers/url-filter.resolver.ts new file mode 100644 index 000000000..16bc5c752 --- /dev/null +++ b/UI/Web/src/app/_resolvers/url-filter.resolver.ts @@ -0,0 +1,22 @@ +import {Injectable} from "@angular/core"; +import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from "@angular/router"; +import {Observable, of} from "rxjs"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; +import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; + +/** + * Checks the url for a filter and resolves one if applicable, otherwise returns null. + * It is up to the consumer to cast appropriately. + */ +@Injectable({ + providedIn: 'root' +}) +export class UrlFilterResolver implements Resolve { + + constructor(private filterUtilitiesService: FilterUtilitiesService) {} + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + if (!state.url.includes('?')) return of(null); + return this.filterUtilitiesService.decodeFilter(state.url.split('?')[1]); + } +} diff --git a/UI/Web/src/app/_routes/all-series-routing.module.ts b/UI/Web/src/app/_routes/all-series-routing.module.ts index d9dfaaf96..5c4804251 100644 --- a/UI/Web/src/app/_routes/all-series-routing.module.ts +++ b/UI/Web/src/app/_routes/all-series-routing.module.ts @@ -1,7 +1,13 @@ -import { Routes } from "@angular/router"; -import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component"; +import {Routes} from "@angular/router"; +import {AllSeriesComponent} from "../all-series/_components/all-series/all-series.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: AllSeriesComponent, pathMatch: 'full'}, + {path: '', component: AllSeriesComponent, pathMatch: 'full', + runGuardsAndResolvers: 'always', + resolve: { + filter: UrlFilterResolver + } + }, ]; diff --git a/UI/Web/src/app/_routes/bookmark-routing.module.ts b/UI/Web/src/app/_routes/bookmark-routing.module.ts index 6da971e08..2c7c52036 100644 --- a/UI/Web/src/app/_routes/bookmark-routing.module.ts +++ b/UI/Web/src/app/_routes/bookmark-routing.module.ts @@ -1,6 +1,12 @@ -import { Routes } from "@angular/router"; -import { BookmarksComponent } from "../bookmark/_components/bookmarks/bookmarks.component"; +import {Routes} from "@angular/router"; +import {BookmarksComponent} from "../bookmark/_components/bookmarks/bookmarks.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: BookmarksComponent, pathMatch: 'full'}, + {path: '', component: BookmarksComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, ]; diff --git a/UI/Web/src/app/_routes/browse-authors-routing.module.ts b/UI/Web/src/app/_routes/browse-authors-routing.module.ts deleted file mode 100644 index e7aab1b57..000000000 --- a/UI/Web/src/app/_routes/browse-authors-routing.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Routes } from "@angular/router"; -import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component"; -import {BrowseAuthorsComponent} from "../browse-people/browse-authors.component"; - - -export const routes: Routes = [ - {path: '', component: BrowseAuthorsComponent, pathMatch: 'full'}, -]; diff --git a/UI/Web/src/app/_routes/browse-routing.module.ts b/UI/Web/src/app/_routes/browse-routing.module.ts new file mode 100644 index 000000000..be96e8193 --- /dev/null +++ b/UI/Web/src/app/_routes/browse-routing.module.ts @@ -0,0 +1,24 @@ +import {Routes} from "@angular/router"; +import {BrowsePeopleComponent} from "../browse/browse-people/browse-people.component"; +import {BrowseGenresComponent} from "../browse/browse-genres/browse-genres.component"; +import {BrowseTagsComponent} from "../browse/browse-tags/browse-tags.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; + + +export const routes: Routes = [ + // Legacy route + {path: 'authors', component: BrowsePeopleComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, + {path: 'people', component: BrowsePeopleComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, + {path: 'genres', component: BrowseGenresComponent, pathMatch: 'full'}, + {path: 'tags', component: BrowseTagsComponent, pathMatch: 'full'}, +]; diff --git a/UI/Web/src/app/_routes/collections-routing.module.ts b/UI/Web/src/app/_routes/collections-routing.module.ts index 80510c8f6..2b3b0ffd7 100644 --- a/UI/Web/src/app/_routes/collections-routing.module.ts +++ b/UI/Web/src/app/_routes/collections-routing.module.ts @@ -1,9 +1,15 @@ -import { Routes } from '@angular/router'; -import { AllCollectionsComponent } from '../collections/_components/all-collections/all-collections.component'; -import { CollectionDetailComponent } from '../collections/_components/collection-detail/collection-detail.component'; +import {Routes} from '@angular/router'; +import {AllCollectionsComponent} from '../collections/_components/all-collections/all-collections.component'; +import {CollectionDetailComponent} from '../collections/_components/collection-detail/collection-detail.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ {path: '', component: AllCollectionsComponent, pathMatch: 'full'}, - {path: ':id', component: CollectionDetailComponent}, + {path: ':id', component: CollectionDetailComponent, + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, ]; diff --git a/UI/Web/src/app/_routes/library-detail-routing.module.ts b/UI/Web/src/app/_routes/library-detail-routing.module.ts index 04cb3c9dd..3c09a71ee 100644 --- a/UI/Web/src/app/_routes/library-detail-routing.module.ts +++ b/UI/Web/src/app/_routes/library-detail-routing.module.ts @@ -1,7 +1,8 @@ -import { Routes } from '@angular/router'; -import { AuthGuard } from '../_guards/auth.guard'; -import { LibraryAccessGuard } from '../_guards/library-access.guard'; -import { LibraryDetailComponent } from '../library-detail/library-detail.component'; +import {Routes} from '@angular/router'; +import {AuthGuard} from '../_guards/auth.guard'; +import {LibraryAccessGuard} from '../_guards/library-access.guard'; +import {LibraryDetailComponent} from '../library-detail/library-detail.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ @@ -9,12 +10,18 @@ export const routes: Routes = [ path: ':libraryId', runGuardsAndResolvers: 'always', canActivate: [AuthGuard, LibraryAccessGuard], - component: LibraryDetailComponent + component: LibraryDetailComponent, + resolve: { + filter: UrlFilterResolver + }, }, { path: '', runGuardsAndResolvers: 'always', canActivate: [AuthGuard, LibraryAccessGuard], - component: LibraryDetailComponent - } + component: LibraryDetailComponent, + resolve: { + filter: UrlFilterResolver + }, + }, ]; diff --git a/UI/Web/src/app/_routes/want-to-read-routing.module.ts b/UI/Web/src/app/_routes/want-to-read-routing.module.ts index b3301d9f9..b593172c0 100644 --- a/UI/Web/src/app/_routes/want-to-read-routing.module.ts +++ b/UI/Web/src/app/_routes/want-to-read-routing.module.ts @@ -1,6 +1,10 @@ -import { Routes } from '@angular/router'; -import { WantToReadComponent } from '../want-to-read/_components/want-to-read/want-to-read.component'; +import {Routes} from '@angular/router'; +import {WantToReadComponent} from '../want-to-read/_components/want-to-read/want-to-read.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: WantToReadComponent, pathMatch: 'full'}, + {path: '', component: WantToReadComponent, pathMatch: 'full', runGuardsAndResolvers: 'always', resolve: { + filter: UrlFilterResolver + } + }, ]; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 8e8576069..f1f91143f 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -132,6 +132,33 @@ export class AccountService { return roles.some(role => user.roles.includes(role)); } + /** + * If User or Admin, will return false + * @param user + * @param restrictedRoles + */ + hasAnyRestrictedRole(user: User, restrictedRoles: Array = []) { + if (!user || !user.roles) { + return true; + } + + if (restrictedRoles.length === 0) { + return false; + } + + // If the user is an admin, they have the role + if (this.hasAdminRole(user)) { + return false; + } + + + if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) { + return true; + } + + return false; + } + hasAdminRole(user: User) { return user && user.roles.includes(Role.Admin); } diff --git a/UI/Web/src/app/_services/filter.service.ts b/UI/Web/src/app/_services/filter.service.ts index e76c1926f..2b9681e90 100644 --- a/UI/Web/src/app/_services/filter.service.ts +++ b/UI/Web/src/app/_services/filter.service.ts @@ -1,8 +1,7 @@ -import { Injectable } from '@angular/core'; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {Injectable} from '@angular/core'; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import {environment} from "../../environments/environment"; -import { HttpClient } from "@angular/common/http"; -import {JumpKey} from "../_models/jumpbar/jump-key"; +import {HttpClient} from "@angular/common/http"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; @Injectable({ @@ -13,7 +12,7 @@ export class FilterService { baseUrl = environment.apiUrl; constructor(private httpClient: HttpClient) { } - saveFilter(filter: SeriesFilterV2) { + saveFilter(filter: FilterV2) { return this.httpClient.post(this.baseUrl + 'filter/update', filter); } getAllFilters() { @@ -26,5 +25,4 @@ export class FilterService { renameSmartFilter(filter: SmartFilter) { return this.httpClient.post(this.baseUrl + `filter/rename?filterId=${filter.id}&name=${filter.name.trim()}`, {}); } - } diff --git a/UI/Web/src/app/_services/jumpbar.service.ts b/UI/Web/src/app/_services/jumpbar.service.ts index d9919ff57..48ca08705 100644 --- a/UI/Web/src/app/_services/jumpbar.service.ts +++ b/UI/Web/src/app/_services/jumpbar.service.ts @@ -1,5 +1,5 @@ -import { Injectable } from '@angular/core'; -import { JumpKey } from '../_models/jumpbar/jump-key'; +import {Injectable} from '@angular/core'; +import {JumpKey} from '../_models/jumpbar/jump-key'; const keySize = 25; // Height of the JumpBar button @@ -105,14 +105,18 @@ export class JumpbarService { getJumpKeys(data :Array, keySelector: (data: any) => string) { const keys: {[key: string]: number} = {}; data.forEach(obj => { - let ch = keySelector(obj).charAt(0).toUpperCase(); - if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) { - ch = '#'; + try { + let ch = keySelector(obj).charAt(0).toUpperCase(); + if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) { + ch = '#'; + } + if (!keys.hasOwnProperty(ch)) { + keys[ch] = 0; + } + keys[ch] += 1; + } catch (e) { + console.error('Failed to calculate jump key for ', obj, e); } - if (!keys.hasOwnProperty(ch)) { - keys[ch] = 0; - } - keys[ch] += 1; }); return Object.keys(keys).map(k => { k = k.toUpperCase(); diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 314e5c37b..fe0702219 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -1,33 +1,54 @@ -import {HttpClient} from '@angular/common/http'; -import {Injectable} from '@angular/core'; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {inject, Injectable} from '@angular/core'; import {tap} from 'rxjs/operators'; -import {of} from 'rxjs'; +import {map, of} from 'rxjs'; import {environment} from 'src/environments/environment'; import {Genre} from '../_models/metadata/genre'; import {AgeRatingDto} from '../_models/metadata/age-rating-dto'; import {Language} from '../_models/metadata/language'; import {PublicationStatusDto} from '../_models/metadata/publication-status-dto'; -import {Person, PersonRole} from '../_models/metadata/person'; +import {allPeopleRoles, Person, PersonRole} from '../_models/metadata/person'; import {Tag} from '../_models/tag'; import {FilterComparison} from '../_models/metadata/v2/filter-comparison'; import {FilterField} from '../_models/metadata/v2/filter-field'; -import {SortField} from "../_models/metadata/series-filter"; +import {mangaFormatFilters, SortField} from "../_models/metadata/series-filter"; import {FilterCombination} from "../_models/metadata/v2/filter-combination"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import {FilterStatement} from "../_models/metadata/v2/filter-statement"; import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus"; import {LibraryType} from "../_models/library/library"; import {IHasCast} from "../_models/common/i-has-cast"; import {TextResonse} from "../_types/text-response"; import {QueryContext} from "../_models/metadata/v2/query-context"; +import {AgeRatingPipe} from "../_pipes/age-rating.pipe"; +import {MangaFormatPipe} from "../_pipes/manga-format.pipe"; +import {TranslocoService} from "@jsverse/transloco"; +import {LibraryService} from './library.service'; +import {CollectionTagService} from "./collection-tag.service"; +import {PaginatedResult} from "../_models/pagination"; +import {UtilityService} from "../shared/_services/utility.service"; +import {BrowseGenre} from "../_models/metadata/browse/browse-genre"; +import {BrowseTag} from "../_models/metadata/browse/browse-tag"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; +import {PersonRolePipe} from "../_pipes/person-role.pipe"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Injectable({ providedIn: 'root' }) export class MetadataService { + private readonly translocoService = inject(TranslocoService); + private readonly libraryService = inject(LibraryService); + private readonly collectionTagService = inject(CollectionTagService); + private readonly utilityService = inject(UtilityService); + baseUrl = environment.apiUrl; private validLanguages: Array = []; + private ageRatingPipe = new AgeRatingPipe(); + private mangaFormatPipe = new MangaFormatPipe(this.translocoService); + private personRolePipe = new PersonRolePipe(); constructor(private httpClient: HttpClient) { } @@ -74,6 +95,28 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + method); } + getGenreWithCounts(pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + + return this.httpClient.post>(this.baseUrl + 'metadata/genres-with-counts', {}, {observe: 'response', params}).pipe( + map((response: any) => { + return this.utilityService.createPaginatedResult(response) as PaginatedResult; + }) + ); + } + + getTagWithCounts(pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + + return this.httpClient.post>(this.baseUrl + 'metadata/tags-with-counts', {}, {observe: 'response', params}).pipe( + map((response: any) => { + return this.utilityService.createPaginatedResult(response) as PaginatedResult; + }) + ); + } + getAllLanguages(libraries?: Array) { let method = 'metadata/languages' if (libraries != undefined && libraries.length > 0) { @@ -110,19 +153,28 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + 'metadata/people-by-role?role=' + role); } - createDefaultFilterDto(): SeriesFilterV2 { + createDefaultFilterDto(entityType: ValidFilterEntity): FilterV2 { return { - statements: [] as FilterStatement[], + statements: [] as FilterStatement[], combination: FilterCombination.And, limitTo: 0, sortOptions: { isAscending: true, - sortField: SortField.SortName + sortField: (entityType === 'series' ? SortField.SortName : PersonSortField.Name) as TSort } }; } - createDefaultFilterStatement(field: FilterField = FilterField.SeriesName, comparison = FilterComparison.Equal, value = '') { + createDefaultFilterStatement(entityType: ValidFilterEntity) { + switch (entityType) { + case 'series': + return this.createFilterStatement(FilterField.SeriesName); + case 'person': + return this.createFilterStatement(PersonFilterField.Role, FilterComparison.Contains, `${PersonRole.CoverArtist},${PersonRole.Writer}`); + } + } + + createFilterStatement(field: T, comparison = FilterComparison.Equal, value = '') { return { comparison: comparison, field: field, @@ -130,7 +182,7 @@ export class MetadataService { }; } - updateFilter(arr: Array, index: number, filterStmt: FilterStatement) { + updateFilter(arr: Array>, index: number, filterStmt: FilterStatement) { arr[index].comparison = filterStmt.comparison; arr[index].field = filterStmt.field; arr[index].value = filterStmt.value ? filterStmt.value + '' : ''; @@ -140,8 +192,6 @@ export class MetadataService { switch (role) { case PersonRole.Other: break; - case PersonRole.Artist: - break; case PersonRole.CoverArtist: entity.coverArtists = persons; break; @@ -183,4 +233,85 @@ export class MetadataService { break; } } + + /** + * Used to get the underlying Options (for Metadata Filter Dropdowns) + * @param filterField + * @param entityType + */ + getOptionsForFilterField(filterField: T, entityType: ValidFilterEntity) { + + switch (entityType) { + case 'series': + return this.getSeriesOptionsForFilterField(filterField as FilterField); + case 'person': + return this.getPersonOptionsForFilterField(filterField as PersonFilterField); + } + } + + private getPersonOptionsForFilterField(field: PersonFilterField) { + switch (field) { + case PersonFilterField.Role: + return of(allPeopleRoles.map(r => {return {value: r, label: this.personRolePipe.transform(r)}})); + } + return of([]) + } + + private getSeriesOptionsForFilterField(field: FilterField) { + switch (field) { + case FilterField.PublicationStatus: + return this.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => { + return {value: pub.value, label: pub.title} + }))); + case FilterField.AgeRating: + return this.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => { + return {value: rating.value, label: this.ageRatingPipe.transform(rating.value)} + }))); + case FilterField.Genres: + return this.getAllGenres().pipe(map(genres => genres.map(genre => { + return {value: genre.id, label: genre.title} + }))); + case FilterField.Languages: + return this.getAllLanguages().pipe(map(statuses => statuses.map(status => { + return {value: status.isoCode, label: status.title + ` (${status.isoCode})`} + }))); + case FilterField.Formats: + return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => { + return {value: status.value, label: this.mangaFormatPipe.transform(status.value)} + }))); + case FilterField.Libraries: + return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => { + return {value: lib.id, label: lib.name} + }))); + case FilterField.Tags: + return this.getAllTags().pipe(map(statuses => statuses.map(status => { + return {value: status.id, label: status.title} + }))); + case FilterField.CollectionTags: + return this.collectionTagService.allCollections().pipe(map(statuses => statuses.map(status => { + return {value: status.id, label: status.title} + }))); + case FilterField.Characters: return this.getPersonOptions(PersonRole.Character); + case FilterField.Colorist: return this.getPersonOptions(PersonRole.Colorist); + case FilterField.CoverArtist: return this.getPersonOptions(PersonRole.CoverArtist); + case FilterField.Editor: return this.getPersonOptions(PersonRole.Editor); + case FilterField.Inker: return this.getPersonOptions(PersonRole.Inker); + case FilterField.Letterer: return this.getPersonOptions(PersonRole.Letterer); + case FilterField.Penciller: return this.getPersonOptions(PersonRole.Penciller); + case FilterField.Publisher: return this.getPersonOptions(PersonRole.Publisher); + case FilterField.Imprint: return this.getPersonOptions(PersonRole.Imprint); + case FilterField.Team: return this.getPersonOptions(PersonRole.Team); + case FilterField.Location: return this.getPersonOptions(PersonRole.Location); + case FilterField.Translators: return this.getPersonOptions(PersonRole.Translator); + case FilterField.Writers: return this.getPersonOptions(PersonRole.Writer); + } + + return of([]); + } + + private getPersonOptions(role: PersonRole) { + return this.getAllPeopleByRole(role).pipe(map(people => people.map(person => { + return {value: person.id, label: person.name} + }))); + } } diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts index 0ac58b178..fc9148135 100644 --- a/UI/Web/src/app/_services/person.service.ts +++ b/UI/Web/src/app/_services/person.service.ts @@ -6,9 +6,12 @@ import {PaginatedResult} from "../_models/pagination"; import {Series} from "../_models/series"; import {map} from "rxjs/operators"; import {UtilityService} from "../shared/_services/utility.service"; -import {BrowsePerson} from "../_models/person/browse-person"; +import {BrowsePerson} from "../_models/metadata/browse/browse-person"; import {StandaloneChapter} from "../_models/standalone-chapter"; import {TextResonse} from "../_types/text-response"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Injectable({ providedIn: 'root' @@ -43,17 +46,28 @@ export class PersonService { return this.httpClient.get>(this.baseUrl + `person/chapters-by-role?personId=${personId}&role=${role}`); } - getAuthorsToBrowse(pageNum?: number, itemsPerPage?: number) { + getAuthorsToBrowse(filter: FilterV2, pageNum?: number, itemsPerPage?: number) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); - return this.httpClient.post>(this.baseUrl + 'person/all', {}, {observe: 'response', params}).pipe( + return this.httpClient.post>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe( map((response: any) => { return this.utilityService.createPaginatedResult(response) as PaginatedResult; }) ); } + // getAuthorsToBrowse(filter: BrowsePersonFilter, pageNum?: number, itemsPerPage?: number) { + // let params = new HttpParams(); + // params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + // + // return this.httpClient.post>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe( + // map((response: any) => { + // return this.utilityService.createPaginatedResult(response) as PaginatedResult; + // }) + // ); + // } + downloadCover(personId: number) { return this.httpClient.post(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse); } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 9941cd005..05958ee61 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -16,13 +16,14 @@ import {TextResonse} from '../_types/text-response'; import {AccountService} from './account.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {PersonalToC} from "../_models/readers/personal-toc"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import NoSleep from 'nosleep.js'; import {FullProgress} from "../_models/readers/full-progress"; import {Volume} from "../_models/volume"; import {UtilityService} from "../shared/_services/utility.service"; import {translate} from "@jsverse/transloco"; import {ToastrService} from "ngx-toastr"; +import {FilterField} from "../_models/metadata/v2/filter-field"; export const CHAPTER_ID_DOESNT_EXIST = -1; @@ -107,7 +108,7 @@ export class ReaderService { return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page}); } - getAllBookmarks(filter: SeriesFilterV2 | undefined) { + getAllBookmarks(filter: FilterV2 | undefined) { return this.httpClient.post(this.baseUrl + 'reader/all-bookmarks', filter); } diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index b440b1eb7..39e3b720b 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -1,28 +1,26 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { UtilityService } from '../shared/_services/utility.service'; -import { Chapter } from '../_models/chapter'; -import { PaginatedResult } from '../_models/pagination'; -import { Series } from '../_models/series'; -import { RelatedSeries } from '../_models/series-detail/related-series'; -import { SeriesDetail } from '../_models/series-detail/series-detail'; -import { SeriesGroup } from '../_models/series-group'; -import { SeriesMetadata } from '../_models/metadata/series-metadata'; -import { Volume } from '../_models/volume'; -import { ImageService } from './image.service'; -import { TextResonse } from '../_types/text-response'; -import { SeriesFilterV2 } from '../_models/metadata/v2/series-filter-v2'; -import {UserReview} from "../_single-module/review-card/user-review"; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {environment} from 'src/environments/environment'; +import {UtilityService} from '../shared/_services/utility.service'; +import {Chapter} from '../_models/chapter'; +import {PaginatedResult} from '../_models/pagination'; +import {Series} from '../_models/series'; +import {RelatedSeries} from '../_models/series-detail/related-series'; +import {SeriesDetail} from '../_models/series-detail/series-detail'; +import {SeriesGroup} from '../_models/series-group'; +import {SeriesMetadata} from '../_models/metadata/series-metadata'; +import {Volume} from '../_models/volume'; +import {TextResonse} from '../_types/text-response'; +import {FilterV2} from '../_models/metadata/v2/filter-v2'; import {Rating} from "../_models/rating"; import {Recommendation} from "../_models/series-detail/recommendation"; import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail"; import {NextExpectedChapter} from "../_models/series-detail/next-expected-chapter"; import {QueryContext} from "../_models/metadata/v2/query-context"; -import {ExternalSeries} from "../_models/series-detail/external-series"; import {ExternalSeriesMatch} from "../_models/series-detail/external-series-match"; +import {FilterField} from "../_models/metadata/v2/filter-field"; @Injectable({ providedIn: 'root' @@ -33,10 +31,9 @@ export class SeriesService { paginatedResults: PaginatedResult = new PaginatedResult(); paginatedSeriesForTagsResults: PaginatedResult = new PaginatedResult(); - constructor(private httpClient: HttpClient, private imageService: ImageService, - private utilityService: UtilityService) { } + constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } - getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2, context: QueryContext = QueryContext.None) { + getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2, context: QueryContext = QueryContext.None) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -48,7 +45,7 @@ export class SeriesService { ); } - getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -100,7 +97,7 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'reader/mark-unread', {seriesId}); } - getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: FilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); @@ -116,7 +113,7 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'series/recently-updated-series', {}); } - getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2): Observable> { + getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: FilterV2): Observable> { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -134,7 +131,7 @@ export class SeriesService { })); } - getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: FilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -230,5 +227,4 @@ export class SeriesService { updateDontMatch(seriesId: number, dontMatch: boolean) { return this.httpClient.post(this.baseUrl + `series/dont-match?seriesId=${seriesId}&dontMatch=${dontMatch}`, {}, TextResonse); } - } diff --git a/UI/Web/src/app/_services/toggle.service.ts b/UI/Web/src/app/_services/toggle.service.ts index 8b335394a..0ad9813e3 100644 --- a/UI/Web/src/app/_services/toggle.service.ts +++ b/UI/Web/src/app/_services/toggle.service.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@angular/core'; -import { NavigationStart, Router } from '@angular/router'; -import { filter, ReplaySubject, take } from 'rxjs'; +import {Injectable} from '@angular/core'; +import {NavigationStart, Router} from '@angular/router'; +import {filter, ReplaySubject, take} from 'rxjs'; @Injectable({ providedIn: 'root' @@ -29,7 +29,7 @@ export class ToggleService { this.toggleState = !state; this.toggleStateSource.next(this.toggleState); }); - + } set(state: boolean) { diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html index 2d94dd848..322a16bd8 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html @@ -33,10 +33,10 @@ } @else {
@if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) { - {{t('volume-count', {num: item.series.volumes})}} @if (item.series.plusMediaFormat === PlusMediaFormat.Comic) { {{t('issue-count', {num: item.series.chapters})}} } @else { + {{t('volume-count', {num: item.series.volumes})}} {{t('chapter-count', {num: item.series.chapters})}} } } @else { diff --git a/UI/Web/src/app/_single-module/sort-button/sort-button.component.html b/UI/Web/src/app/_single-module/sort-button/sort-button.component.html new file mode 100644 index 000000000..bc02c743d --- /dev/null +++ b/UI/Web/src/app/_single-module/sort-button/sort-button.component.html @@ -0,0 +1,9 @@ + + + diff --git a/UI/Web/src/app/_single-module/sort-button/sort-button.component.scss b/UI/Web/src/app/_single-module/sort-button/sort-button.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/_single-module/sort-button/sort-button.component.ts b/UI/Web/src/app/_single-module/sort-button/sort-button.component.ts new file mode 100644 index 000000000..230a0ee6f --- /dev/null +++ b/UI/Web/src/app/_single-module/sort-button/sort-button.component.ts @@ -0,0 +1,21 @@ +import {ChangeDetectionStrategy, Component, input, model} from '@angular/core'; +import {TranslocoDirective} from "@jsverse/transloco"; + +@Component({ + selector: 'app-sort-button', + imports: [ + TranslocoDirective + ], + templateUrl: './sort-button.component.html', + styleUrl: './sort-button.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SortButtonComponent { + + disabled = input(false); + isAscending = model(true); + + updateSortOrder() { + this.isAscending.set(!this.isAscending()); + } +} diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html index 60f494f38..2ae9ea45b 100644 --- a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html +++ b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html @@ -3,8 +3,17 @@
-
- +
+ + +
+
+ - @for (field of availableFields; track field) { - + @for (field of filterFieldOptions(); track field.value) { + }
@@ -18,7 +18,7 @@
- @if (IsEmptySelected) { + @if (isEmptySelected()) { @if (predicateType$ | async; as predicateType) { @switch (predicateType) { @case (PredicateType.Text) { @@ -50,7 +50,7 @@ @@ -62,10 +62,11 @@
- @if (UiLabel !== null) { - {{t(UiLabel.unit)}} - @if (UiLabel.tooltip) { - + @let label = uiLabel(); + @if (label !== null) { + {{t(label.unit)}} + @if (label.tooltip) { + } }
diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts index 34a1b7db8..4fcca8eac 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts @@ -2,32 +2,41 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, DestroyRef, EventEmitter, inject, + Injector, + input, Input, OnInit, Output, + Signal, } from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; import {FilterStatement} from '../../../_models/metadata/v2/filter-statement'; import {BehaviorSubject, distinctUntilChanged, filter, map, Observable, of, startWith, switchMap, tap} from 'rxjs'; import {MetadataService} from 'src/app/_services/metadata.service'; -import {mangaFormatFilters} from 'src/app/_models/metadata/series-filter'; -import {PersonRole} from 'src/app/_models/metadata/person'; -import {LibraryService} from 'src/app/_services/library.service'; -import {CollectionTagService} from 'src/app/_services/collection-tag.service'; import {FilterComparison} from 'src/app/_models/metadata/v2/filter-comparison'; -import {allFields, FilterField} from 'src/app/_models/metadata/v2/filter-field'; +import {FilterField} from 'src/app/_models/metadata/v2/filter-field'; import {AsyncPipe} from "@angular/common"; -import {FilterFieldPipe} from "../../../_pipes/filter-field.pipe"; import {FilterComparisonPipe} from "../../../_pipes/filter-comparison.pipe"; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; import {Select2, Select2Option} from "ng-select2-component"; import {NgbDate, NgbDateParserFormatter, NgbInputDatepicker, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {TranslocoDirective, TranslocoService} from "@jsverse/transloco"; -import {MangaFormatPipe} from "../../../_pipes/manga-format.pipe"; -import {AgeRatingPipe} from "../../../_pipes/age-rating.pipe"; +import {ValidFilterEntity} from "../../filter-settings"; +import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; + +interface FieldConfig { + type: PredicateType; + baseComparisons: FilterComparison[]; + defaultValue: any; + allowsDateComparisons?: boolean; + allowsNumberComparisons?: boolean; + excludesMustContains?: boolean; + allowsIsEmpty?: boolean; +} enum PredicateType { Text = 1, @@ -54,42 +63,42 @@ const unitLabels: Map = new Map([ [FilterField.ReadLast, new FilterRowUi('unit-read-last')], ]); -const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath]; -const NumberFields = [ - FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, - FilterField.UserRating, FilterField.AverageRating, FilterField.ReadLast -]; -const DropdownFields = [ - FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating, - FilterField.Translators, FilterField.Characters, FilterField.Publisher, - FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, - FilterField.Colorist, FilterField.Inker, FilterField.Penciller, - FilterField.Writers, FilterField.Genres, FilterField.Libraries, - FilterField.Formats, FilterField.CollectionTags, FilterField.Tags, - FilterField.Imprint, FilterField.Team, FilterField.Location -]; -const BooleanFields = [FilterField.WantToRead]; -const DateFields = [FilterField.ReadingDate]; - -const DropdownFieldsWithoutMustContains = [ - FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus -]; -const DropdownFieldsThatIncludeNumberComparisons = [ - FilterField.AgeRating -]; -const NumberFieldsThatIncludeDateComparisons = [ - FilterField.ReleaseYear -]; - -const FieldsThatShouldIncludeIsEmpty = [ - FilterField.Summary, FilterField.UserRating, FilterField.Genres, - FilterField.CollectionTags, FilterField.Tags, FilterField.ReleaseYear, - FilterField.Translators, FilterField.Characters, FilterField.Publisher, - FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, - FilterField.Colorist, FilterField.Inker, FilterField.Penciller, - FilterField.Writers, FilterField.Imprint, FilterField.Team, - FilterField.Location, -]; +// const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath, PersonFilterField.Name]; +// const NumberFields = [ +// FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, +// FilterField.UserRating, FilterField.AverageRating, FilterField.ReadLast +// ]; +// const DropdownFields = [ +// FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating, +// FilterField.Translators, FilterField.Characters, FilterField.Publisher, +// FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, +// FilterField.Colorist, FilterField.Inker, FilterField.Penciller, +// FilterField.Writers, FilterField.Genres, FilterField.Libraries, +// FilterField.Formats, FilterField.CollectionTags, FilterField.Tags, +// FilterField.Imprint, FilterField.Team, FilterField.Location, PersonFilterField.Role +// ]; +// const BooleanFields = [FilterField.WantToRead]; +// const DateFields = [FilterField.ReadingDate]; +// +// const DropdownFieldsWithoutMustContains = [ +// FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus +// ]; +// const DropdownFieldsThatIncludeNumberComparisons = [ +// FilterField.AgeRating +// ]; +// const NumberFieldsThatIncludeDateComparisons = [ +// FilterField.ReleaseYear +// ]; +// +// const FieldsThatShouldIncludeIsEmpty = [ +// FilterField.Summary, FilterField.UserRating, FilterField.Genres, +// FilterField.CollectionTags, FilterField.Tags, FilterField.ReleaseYear, +// FilterField.Translators, FilterField.Characters, FilterField.Publisher, +// FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, +// FilterField.Colorist, FilterField.Inker, FilterField.Penciller, +// FilterField.Writers, FilterField.Imprint, FilterField.Team, +// FilterField.Location, +// ]; const StringComparisons = [ FilterComparison.Equal, @@ -126,7 +135,6 @@ const BooleanComparisons = [ imports: [ ReactiveFormsModule, AsyncPipe, - FilterFieldPipe, FilterComparisonPipe, NgbTooltip, TranslocoDirective, @@ -135,60 +143,75 @@ const BooleanComparisons = [ ], changeDetection: ChangeDetectionStrategy.OnPush }) -export class MetadataFilterRowComponent implements OnInit { - - protected readonly FilterComparison = FilterComparison; - protected readonly PredicateType = PredicateType; +export class MetadataFilterRowComponent implements OnInit { private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); private readonly dateParser = inject(NgbDateParserFormatter); private readonly metadataService = inject(MetadataService); - private readonly libraryService = inject(LibraryService); - private readonly collectionTagService = inject(CollectionTagService); private readonly translocoService = inject(TranslocoService); + private readonly filterUtilitiesService = inject(FilterUtilitiesService); + private readonly injector = inject(Injector); - - @Input() index: number = 0; // This is only for debugging /** * Slightly misleading as this is the initial state and will be updated on the filterStatement event emitter */ - @Input() preset!: FilterStatement; - @Input() availableFields: Array = allFields; - @Output() filterStatement = new EventEmitter(); + @Input() preset!: FilterStatement; + entityType = input.required(); + @Output() filterStatement = new EventEmitter>(); - formGroup: FormGroup = new FormGroup({ - 'comparison': new FormControl(FilterComparison.Equal, []), - 'filterValue': new FormControl('', []), - }); + formGroup!: FormGroup; validComparisons$: BehaviorSubject = new BehaviorSubject([FilterComparison.Equal] as FilterComparison[]); predicateType$: BehaviorSubject = new BehaviorSubject(PredicateType.Text as PredicateType); dropdownOptions$ = of([]); loaded: boolean = false; - private readonly mangaFormatPipe = new MangaFormatPipe(this.translocoService); - private readonly ageRatingPipe = new AgeRatingPipe(); - - get IsEmptySelected() { - return parseInt(this.formGroup.get('comparison')?.value + '', 10) !== FilterComparison.IsEmpty; - } - get UiLabel(): FilterRowUi | null { - const field = parseInt(this.formGroup.get('input')!.value, 10) as FilterField; - if (!unitLabels.has(field)) return null; - return unitLabels.get(field) as FilterRowUi; - } + private comparisonSignal!: Signal; + private inputSignal!: Signal; - get MultipleDropdownAllowed() { - const comp = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison; - return comp === FilterComparison.Contains || comp === FilterComparison.NotContains || comp === FilterComparison.MustContains; - } + isEmptySelected: Signal = computed(() => false); + uiLabel: Signal = computed(() => null); + isMultiSelectDropdownAllowed: Signal = computed(() => false); + filterFieldOptions: Signal<{title: string, value: TFilter}[]> = computed(() => []); ngOnInit() { - this.formGroup.addControl('input', new FormControl(FilterField.SeriesName, [])); + + this.formGroup = new FormGroup({ + 'comparison': new FormControl(FilterComparison.Equal, []), + 'filterValue': new FormControl('', []), + 'input': new FormControl(this.filterUtilitiesService.getDefaultFilterField(this.entityType()), []) + }); + + this.comparisonSignal = toSignal( + this.formGroup.get('comparison')!.valueChanges.pipe( + startWith(this.formGroup.get('comparison')!.value), + map(d => parseInt(d + '', 10) as FilterComparison) + ) + , {requireSync: true, injector: this.injector}); + this.inputSignal = toSignal( + this.formGroup.get('input')!.valueChanges.pipe( + startWith(this.formGroup.get('input')!.value), + map(d => parseInt(d + '', 10) as TFilter) + ) + , {requireSync: true, injector: this.injector}); + + this.isEmptySelected = computed(() => this.comparisonSignal() !== FilterComparison.IsEmpty); + this.uiLabel = computed(() => { + if (!unitLabels.has(this.inputSignal())) return null; + return unitLabels.get(this.inputSignal()) as FilterRowUi; + }); + + this.isMultiSelectDropdownAllowed = computed(() => { + return this.comparisonSignal() === FilterComparison.Contains || this.comparisonSignal() === FilterComparison.NotContains || this.comparisonSignal() === FilterComparison.MustContains; + }); + + this.filterFieldOptions = computed(() => { + return this.filterUtilitiesService.getFilterFields(this.entityType()); + }); this.formGroup.get('input')?.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe((val: string) => this.handleFieldChange(val)); this.populateFromPreset(); @@ -200,14 +223,14 @@ export class MetadataFilterRowComponent implements OnInit { startWith(this.preset.value), distinctUntilChanged(), filter(() => { - const inputVal = parseInt(this.formGroup.get('input')?.value, 10) as FilterField; - return DropdownFields.includes(inputVal); + return this.filterUtilitiesService.getDropdownFields(this.entityType()).includes(this.inputSignal()); }), switchMap((_) => this.getDropdownObservable()), takeUntilDestroyed(this.destroyRef) ); + this.formGroup!.valueChanges.pipe( distinctUntilChanged(), tap(_ => this.propagateFilterUpdate()), @@ -221,11 +244,13 @@ export class MetadataFilterRowComponent implements OnInit { propagateFilterUpdate() { const stmt = { comparison: parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison, - field: parseInt(this.formGroup.get('input')?.value, 10) as FilterField, + field: parseInt(this.formGroup.get('input')?.value, 10) as TFilter, value: this.formGroup.get('filterValue')?.value! }; - if (typeof stmt.value === 'object' && DateFields.includes(stmt.field)) { + const dateFields = this.filterUtilitiesService.getDateFields(this.entityType()); + const booleanFields = this.filterUtilitiesService.getBooleanFields(this.entityType()); + if (typeof stmt.value === 'object' && dateFields.includes(stmt.field)) { stmt.value = this.dateParser.format(stmt.value); } @@ -239,7 +264,7 @@ export class MetadataFilterRowComponent implements OnInit { } if (stmt.comparison !== FilterComparison.IsEmpty) { - if (!stmt.value && (![FilterField.SeriesName, FilterField.Summary].includes(stmt.field) && !BooleanFields.includes(stmt.field))) return; + if (!stmt.value && (![FilterField.SeriesName, FilterField.Summary].includes(stmt.field) && !booleanFields.includes(stmt.field))) return; } this.filterStatement.emit(stmt); @@ -250,15 +275,20 @@ export class MetadataFilterRowComponent implements OnInit { this.formGroup.get('comparison')?.patchValue(this.preset.comparison); this.formGroup.get('input')?.patchValue(this.preset.field); - if (StringFields.includes(this.preset.field)) { + const dropdownFields = this.filterUtilitiesService.getDropdownFields(this.entityType()); + const stringFields = this.filterUtilitiesService.getStringFields(this.entityType()); + const dateFields = this.filterUtilitiesService.getDateFields(this.entityType()); + const booleanFields = this.filterUtilitiesService.getBooleanFields(this.entityType()); + + if (stringFields.includes(this.preset.field)) { this.formGroup.get('filterValue')?.patchValue(val); - } else if (BooleanFields.includes(this.preset.field)) { + } else if (booleanFields.includes(this.preset.field)) { this.formGroup.get('filterValue')?.patchValue(val); - } else if (DateFields.includes(this.preset.field)) { + } else if (dateFields.includes(this.preset.field)) { this.formGroup.get('filterValue')?.patchValue(this.dateParser.parse(val)); } - else if (DropdownFields.includes(this.preset.field)) { - if (this.MultipleDropdownAllowed || val.includes(',')) { + else if (dropdownFields.includes(this.preset.field)) { + if (this.isMultiSelectDropdownAllowed() || val.includes(',')) { this.formGroup.get('filterValue')?.patchValue(val.split(',').map(d => parseInt(d, 10))); } else { if (this.preset.field === FilterField.Languages) { @@ -276,72 +306,28 @@ export class MetadataFilterRowComponent implements OnInit { } getDropdownObservable(): Observable { - const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField; - switch (filterField) { - case FilterField.PublicationStatus: - return this.metadataService.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => { - return {value: pub.value, label: pub.title} - }))); - case FilterField.AgeRating: - return this.metadataService.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => { - return {value: rating.value, label: this.ageRatingPipe.transform(rating.value)} - }))); - case FilterField.Genres: - return this.metadataService.getAllGenres().pipe(map(genres => genres.map(genre => { - return {value: genre.id, label: genre.title} - }))); - case FilterField.Languages: - return this.metadataService.getAllLanguages().pipe(map(statuses => statuses.map(status => { - return {value: status.isoCode, label: status.title + ` (${status.isoCode})`} - }))); - case FilterField.Formats: - return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => { - return {value: status.value, label: this.mangaFormatPipe.transform(status.value)} - }))); - case FilterField.Libraries: - return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => { - return {value: lib.id, label: lib.name} - }))); - case FilterField.Tags: - return this.metadataService.getAllTags().pipe(map(statuses => statuses.map(status => { - return {value: status.id, label: status.title} - }))); - case FilterField.CollectionTags: - return this.collectionTagService.allCollections().pipe(map(statuses => statuses.map(status => { - return {value: status.id, label: status.title} - }))); - case FilterField.Characters: return this.getPersonOptions(PersonRole.Character); - case FilterField.Colorist: return this.getPersonOptions(PersonRole.Colorist); - case FilterField.CoverArtist: return this.getPersonOptions(PersonRole.CoverArtist); - case FilterField.Editor: return this.getPersonOptions(PersonRole.Editor); - case FilterField.Inker: return this.getPersonOptions(PersonRole.Inker); - case FilterField.Letterer: return this.getPersonOptions(PersonRole.Letterer); - case FilterField.Penciller: return this.getPersonOptions(PersonRole.Penciller); - case FilterField.Publisher: return this.getPersonOptions(PersonRole.Publisher); - case FilterField.Imprint: return this.getPersonOptions(PersonRole.Imprint); - case FilterField.Team: return this.getPersonOptions(PersonRole.Team); - case FilterField.Location: return this.getPersonOptions(PersonRole.Location); - case FilterField.Translators: return this.getPersonOptions(PersonRole.Translator); - case FilterField.Writers: return this.getPersonOptions(PersonRole.Writer); - } - return of([]); + const filterField = this.inputSignal(); + return this.metadataService.getOptionsForFilterField(filterField, this.entityType()); } - getPersonOptions(role: PersonRole) { - return this.metadataService.getAllPeopleByRole(role).pipe(map(people => people.map(person => { - return {value: person.id, label: person.name} - }))); - } - - handleFieldChange(val: string) { - const inputVal = parseInt(val, 10) as FilterField; + const inputVal = parseInt(val, 10) as TFilter; + const stringFields = this.filterUtilitiesService.getStringFields(this.entityType()); + const dropdownFields = this.filterUtilitiesService.getDropdownFields(this.entityType()); + const numberFields = this.filterUtilitiesService.getNumberFields(this.entityType()); + const booleanFields = this.filterUtilitiesService.getBooleanFields(this.entityType()); + const dateFields = this.filterUtilitiesService.getDateFields(this.entityType()); + const fieldsThatShouldIncludeIsEmpty = this.filterUtilitiesService.getFieldsThatShouldIncludeIsEmpty(this.entityType()); + const numberFieldsThatIncludeDateComparisons = this.filterUtilitiesService.getNumberFieldsThatIncludeDateComparisons(this.entityType()); + const dropdownFieldsThatIncludeDateComparisons = this.filterUtilitiesService.getDropdownFieldsThatIncludeDateComparisons(this.entityType()); + const dropdownFieldsWithoutMustContains = this.filterUtilitiesService.getDropdownFieldsWithoutMustContains(this.entityType()); + const dropdownFieldsThatIncludeNumberComparisons = this.filterUtilitiesService.getDropdownFieldsThatIncludeNumberComparisons(this.entityType()); - if (StringFields.includes(inputVal)) { + if (stringFields.includes(inputVal)) { let comps = [...StringComparisons]; - if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) { + if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) { comps.push(FilterComparison.IsEmpty); } @@ -356,13 +342,13 @@ export class MetadataFilterRowComponent implements OnInit { return; } - if (NumberFields.includes(inputVal)) { + if (numberFields.includes(inputVal)) { const comps = [...NumberComparisons]; - if (NumberFieldsThatIncludeDateComparisons.includes(inputVal)) { + if (numberFieldsThatIncludeDateComparisons.includes(inputVal)) { comps.push(...DateComparisons); } - if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) { + if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) { comps.push(FilterComparison.IsEmpty); } @@ -378,9 +364,9 @@ export class MetadataFilterRowComponent implements OnInit { return; } - if (DateFields.includes(inputVal)) { + if (dateFields.includes(inputVal)) { const comps = [...DateComparisons]; - if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) { + if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) { comps.push(FilterComparison.IsEmpty); } @@ -395,9 +381,9 @@ export class MetadataFilterRowComponent implements OnInit { return; } - if (BooleanFields.includes(inputVal)) { + if (booleanFields.includes(inputVal)) { let comps = [...DateComparisons]; - if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) { + if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) { comps.push(FilterComparison.IsEmpty); } @@ -413,15 +399,15 @@ export class MetadataFilterRowComponent implements OnInit { return; } - if (DropdownFields.includes(inputVal)) { + if (dropdownFields.includes(inputVal)) { let comps = [...DropdownComparisons]; - if (DropdownFieldsThatIncludeNumberComparisons.includes(inputVal)) { + if (dropdownFieldsThatIncludeNumberComparisons.includes(inputVal)) { comps.push(...NumberComparisons); } - if (DropdownFieldsWithoutMustContains.includes(inputVal)) { + if (dropdownFieldsWithoutMustContains.includes(inputVal)) { comps = comps.filter(c => c !== FilterComparison.MustContains); } - if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) { + if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) { comps.push(FilterComparison.IsEmpty); } @@ -443,4 +429,7 @@ export class MetadataFilterRowComponent implements OnInit { updateIfDateFilled() { this.propagateFilterUpdate(); } + + protected readonly FilterComparison = FilterComparison; + protected readonly PredicateType = PredicateType; } diff --git a/UI/Web/src/app/metadata-filter/filter-settings.ts b/UI/Web/src/app/metadata-filter/filter-settings.ts index 452abee71..092ec4740 100644 --- a/UI/Web/src/app/metadata-filter/filter-settings.ts +++ b/UI/Web/src/app/metadata-filter/filter-settings.ts @@ -1,11 +1,39 @@ -import { SeriesFilterV2 } from "../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; +import {SortField} from "../_models/metadata/series-filter"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; +import {FilterField} from "../_models/metadata/v2/filter-field"; -export class FilterSettings { +/** + * The set of entities that are supported for rich filtering. Each entity must have its own distinct SortField and FilterField enums. + */ +export type ValidFilterEntity = 'series' | 'person'; + +export class FilterSettingsBase { + presetsV2: FilterV2 | undefined; sortDisabled = false; - presetsV2: SeriesFilterV2 | undefined; /** * The number of statements that can be on the filter. Set to 1 to disable adding more. */ statementLimit: number = 0; saveDisabled: boolean = false; - } + type: ValidFilterEntity = 'series'; + supportsSmartFilter: boolean = false; +} + +/** + * Filter Settings for Series entity + */ +export class SeriesFilterSettings extends FilterSettingsBase { + type: ValidFilterEntity = 'series'; + supportsSmartFilter = true; +} + +/** + * Filter Settings for People entity + */ +export class PersonFilterSettings extends FilterSettingsBase { + type: ValidFilterEntity = 'person'; +} + + diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.html b/UI/Web/src/app/metadata-filter/metadata-filter.component.html index 3ec9dbde1..3ef84ce22 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.html +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.html @@ -26,8 +26,8 @@
@@ -41,23 +41,21 @@
- +
-
- - -
+ + @if (filterSettings().supportsSmartFilter) { +
+ + +
+ } + @if (utilityService.getActiveBreakpoint() > Breakpoint.Tablet) { @@ -82,7 +80,7 @@
- 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 c65bb5c16..d1fc264ef 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -2,55 +2,61 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, ContentChild, DestroyRef, + effect, EventEmitter, inject, + input, Input, OnInit, - Output + Output, + Signal } from '@angular/core'; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {NgbCollapse} from '@ng-bootstrap/ng-bootstrap'; import {Breakpoint, UtilityService} from '../shared/_services/utility.service'; import {Library} from '../_models/library/library'; -import {allSortFields, FilterEvent, FilterItem, SortField} from '../_models/metadata/series-filter'; +import {FilterEvent, FilterItem} from '../_models/metadata/series-filter'; import {ToggleService} from '../_services/toggle.service'; -import {FilterSettings} from './filter-settings'; -import {SeriesFilterV2} from '../_models/metadata/v2/series-filter-v2'; +import {FilterV2} from '../_models/metadata/v2/filter-v2'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {DrawerComponent} from '../shared/drawer/drawer.component'; import {AsyncPipe, NgClass, NgTemplateOutlet} from '@angular/common'; import {translate, TranslocoModule, TranslocoService} from "@jsverse/transloco"; -import {SortFieldPipe} from "../_pipes/sort-field.pipe"; import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component"; -import {allFields} from "../_models/metadata/v2/filter-field"; import {FilterService} from "../_services/filter.service"; import {ToastrService} from "ngx-toastr"; import {animate, style, transition, trigger} from "@angular/animations"; +import {SortButtonComponent} from "../_single-module/sort-button/sort-button.component"; +import {FilterSettingsBase} from "./filter-settings"; +import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; + @Component({ - selector: 'app-metadata-filter', - templateUrl: './metadata-filter.component.html', - styleUrls: ['./metadata-filter.component.scss'], - animations: [ - trigger('inOutAnimation', [ - transition(':enter', [ - style({ height: 0, opacity: 0 }), - animate('.5s ease-out', style({ height: 300, opacity: 1 })) - ]), - transition(':leave', [ - style({ height: 300, opacity: 1 }), - animate('.5s ease-in', style({ height: 0, opacity: 0 })) - ]) - ]), - ], - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgTemplateOutlet, DrawerComponent, - ReactiveFormsModule, FormsModule, AsyncPipe, TranslocoModule, - MetadataBuilderComponent, NgClass] + selector: 'app-metadata-filter', + templateUrl: './metadata-filter.component.html', + styleUrls: ['./metadata-filter.component.scss'], + animations: [ + trigger('inOutAnimation', [ + transition(':enter', [ + style({ height: 0, opacity: 0 }), + animate('.5s ease-out', style({ height: 300, opacity: 1 })) + ]), + transition(':leave', [ + style({ height: 300, opacity: 1 }), + animate('.5s ease-in', style({ height: 0, opacity: 0 })) + ]) + ]), + ], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgTemplateOutlet, DrawerComponent, + ReactiveFormsModule, FormsModule, AsyncPipe, TranslocoModule, + MetadataBuilderComponent, NgClass, SortButtonComponent] }) -export class MetadataFilterComponent implements OnInit { +export class MetadataFilterComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); public readonly utilityService = inject(UtilityService); @@ -59,18 +65,16 @@ export class MetadataFilterComponent implements OnInit { private readonly filterService = inject(FilterService); protected readonly toggleService = inject(ToggleService); protected readonly translocoService = inject(TranslocoService); - private readonly sortFieldPipe = new SortFieldPipe(this.translocoService); + protected readonly filterUtilitiesService = inject(FilterUtilitiesService); /** * This toggles the opening/collapsing of the metadata filter code */ @Input() filterOpen: EventEmitter = new EventEmitter(); - /** - * Should filtering be shown on the page - */ - @Input() filteringDisabled: boolean = false; - @Input({required: true}) filterSettings!: FilterSettings; - @Output() applyFilter: EventEmitter = new EventEmitter(); + + filterSettings = input.required>(); + + @Output() applyFilter: EventEmitter> = new EventEmitter(); @ContentChild('[ngbCollapse]') collapse!: NgbCollapse; @@ -86,20 +90,23 @@ export class MetadataFilterComponent implements OnInit { updateApplied: number = 0; fullyLoaded: boolean = false; - filterV2: SeriesFilterV2 | undefined; + filterV2: FilterV2 | undefined; + sortFieldOptions: Signal<{title: string, value: number}[]> = computed(() => []); + filterFieldOptions: Signal<{title: string, value: number}[]> = computed(() => []); + + constructor() { + effect(() => { + const settings = this.filterSettings(); + if (settings?.presetsV2) { + this.filterV2 = this.deepClone(settings.presetsV2); + this.cdRef.markForCheck(); + } + }) + } - protected readonly allSortFields = allSortFields.map(f => { - return {title: this.sortFieldPipe.transform(f), value: f}; - }).sort((a, b) => a.title.localeCompare(b.title)); - protected readonly allFilterFields = allFields; ngOnInit(): void { - if (this.filterSettings === undefined) { - this.filterSettings = new FilterSettings(); - this.cdRef.markForCheck(); - } - if (this.filterOpen) { this.filterOpen.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(openState => { this.filteringCollapsed = !openState; @@ -109,42 +116,19 @@ export class MetadataFilterComponent implements OnInit { } + this.filterFieldOptions = computed(() => { + return this.filterUtilitiesService.getFilterFields(this.filterSettings().type); + }); + + this.sortFieldOptions = computed(() => { + return this.filterUtilitiesService.getSortFields(this.filterSettings().type); + }); + + this.loadFromPresetsAndSetup(); } - // loadSavedFilter(event: Select2UpdateEvent) { - // // Load the filter from the backend and update the screen - // if (event.value === undefined || typeof(event.value) === 'string') return; - // const smartFilter = event.value as SmartFilter; - // this.filterV2 = this.filterUtilitiesService.decodeSeriesFilter(smartFilter.filter); - // this.cdRef.markForCheck(); - // console.log('update event: ', event); - // } - // - // createFilterValue(event: Select2AutoCreateEvent) { - // // Create a new name and filter - // if (!this.filterV2) return; - // this.filterV2.name = event.value; - // this.filterService.saveFilter(this.filterV2).subscribe(() => { - // - // const item = { - // value: { - // filter: this.filterUtilitiesService.encodeSeriesFilter(this.filterV2!), - // name: event.value, - // } as SmartFilter, - // label: event.value - // }; - // this.smartFilters.push(item); - // this.sortGroup.get('name')?.setValue(item); - // this.cdRef.markForCheck(); - // this.toastr.success(translate('toasts.smart-filter-updated')); - // this.apply(); - // }); - // - // console.log('create event: ', event); - // } - close() { this.filterOpen.emit(false); @@ -177,7 +161,7 @@ export class MetadataFilterComponent implements OnInit { return clonedObj; } - handleFilters(filter: SeriesFilterV2) { + handleFilters(filter: FilterV2) { this.filterV2 = filter; } @@ -185,29 +169,34 @@ export class MetadataFilterComponent implements OnInit { loadFromPresetsAndSetup() { this.fullyLoaded = false; - this.filterV2 = this.deepClone(this.filterSettings.presetsV2); + const currentFilterSettings = this.filterSettings(); + this.filterV2 = this.deepClone(currentFilterSettings.presetsV2); + + const defaultSortField = this.sortFieldOptions()[0].value; this.sortGroup = new FormGroup({ - sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []), + sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || defaultSortField, disabled: this.filterSettings().sortDisabled}, []), limitTo: new FormControl(this.filterV2?.limitTo || 0, []), name: new FormControl(this.filterV2?.name || '', []) }); - if (this.filterSettings?.presetsV2?.sortOptions) { - this.isAscendingSort = this.filterSettings?.presetsV2?.sortOptions!.isAscending; + + if (this.filterSettings()?.presetsV2?.sortOptions) { + this.isAscendingSort = this.filterSettings()?.presetsV2?.sortOptions!.isAscending || true; } + this.cdRef.markForCheck(); this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { - if (this.filterV2?.sortOptions === null) { - this.filterV2.sortOptions = { - isAscending: this.isAscendingSort, - sortField: parseInt(this.sortGroup.get('sortField')?.value, 10) - }; - } - this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10); - this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0); - this.filterV2!.name = this.sortGroup.get('name')?.value || ''; - this.cdRef.markForCheck(); + if (this.filterV2?.sortOptions === null) { + this.filterV2.sortOptions = { + isAscending: this.isAscendingSort, + sortField: parseInt(this.sortGroup.get('sortField')?.value, 10) as TSort + }; + } + this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10) as TSort; + this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0); + this.filterV2!.name = this.sortGroup.get('name')?.value || ''; + this.cdRef.markForCheck(); }); this.fullyLoaded = true; @@ -215,13 +204,16 @@ export class MetadataFilterComponent implements OnInit { } - updateSortOrder() { - if (this.filterSettings.sortDisabled) return; - this.isAscendingSort = !this.isAscendingSort; + updateSortOrder(isAscending: boolean) { + if (this.filterSettings().sortDisabled) return; + this.isAscendingSort = isAscending; + if (this.filterV2?.sortOptions === null) { + const defaultSortField = this.sortFieldOptions()[0].value as TSort; + this.filterV2.sortOptions = { isAscending: this.isAscendingSort, - sortField: SortField.SortName + sortField: defaultSortField } } @@ -235,7 +227,7 @@ export class MetadataFilterComponent implements OnInit { } apply() { - this.applyFilter.emit({isFirst: this.updateApplied === 0, filterV2: this.filterV2!}); + this.applyFilter.emit({isFirst: this.updateApplied === 0, filterV2: this.filterV2!} as FilterEvent); if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile && this.updateApplied !== 0) { this.toggleSelected(); @@ -259,9 +251,6 @@ export class MetadataFilterComponent implements OnInit { this.cdRef.markForCheck(); } - setToggle(event: any) { - this.toggleService.set(!this.filteringCollapsed); - } protected readonly Breakpoint = Breakpoint; } 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 9e0f26a0a..089262875 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 @@ -206,6 +206,8 @@
{{t('all-filters')}} + {{t('browse-genres')}} + {{t('browse-tags')}} {{t('announcements')}} {{t('help')}} {{t('logout')}} diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts index 980a1be55..fd4af01f0 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts @@ -47,6 +47,7 @@ import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.comp import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service"; import {WikiLink} from "../../../_models/wiki"; import {NavLinkModalComponent} from "../nav-link-modal/nav-link-modal.component"; +import {MetadataService} from "../../../_services/metadata.service"; @Component({ selector: 'app-nav-header', @@ -70,6 +71,8 @@ export class NavHeaderComponent implements OnInit { protected readonly imageService = inject(ImageService); protected readonly utilityService = inject(UtilityService); protected readonly modalService = inject(NgbModal); + protected readonly metadataService = inject(MetadataService); + protected readonly FilterField = FilterField; protected readonly WikiLink = WikiLink; @@ -159,9 +162,9 @@ export class NavHeaderComponent implements OnInit { }); } - goTo(statement: FilterStatement) { + goTo(statement: FilterStatement) { let params: any = {}; - const filter = this.filterUtilityService.createSeriesV2Filter(); + const filter = this.metadataService.createDefaultFilterDto('series'); filter.statements = [statement]; params['page'] = 1; this.clearSearch(); diff --git a/UI/Web/src/app/person-detail/person-detail.component.ts b/UI/Web/src/app/person-detail/person-detail.component.ts index 31b7f976b..ab3c486bd 100644 --- a/UI/Web/src/app/person-detail/person-detail.component.ts +++ b/UI/Web/src/app/person-detail/person-detail.component.ts @@ -23,7 +23,7 @@ import {PersonRolePipe} from "../_pipes/person-role.pipe"; import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component"; import {FilterComparison} from "../_models/metadata/v2/filter-comparison"; import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import {allPeople, FilterField, personRoleForFilterField} from "../_models/metadata/v2/filter-field"; import {Series} from "../_models/series"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @@ -44,6 +44,7 @@ import {SafeUrlPipe} from "../_pipes/safe-url.pipe"; import {MergePersonModalComponent} from "./_modal/merge-person-modal/merge-person-modal.component"; import {EVENTS, MessageHubService} from "../_services/message-hub.service"; import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component"; +import {MetadataService} from "../_services/metadata.service"; interface PersonMergeEvent { srcId: number, @@ -87,6 +88,7 @@ export class PersonDetailComponent implements OnInit { private readonly themeService = inject(ThemeService); private readonly toastr = inject(ToastrService); private readonly messageHubService = inject(MessageHubService) + private readonly metadataService = inject(MetadataService) protected readonly FilterField = FilterField; @@ -98,7 +100,7 @@ export class PersonDetailComponent implements OnInit { roles$: Observable | null = null; roles: PersonRole[] | null = null; works$: Observable | null = null; - filter: SeriesFilterV2 | null = null; + filter: FilterV2 | null = null; personActions: Array> = this.actionService.getPersonActions(this.handleAction.bind(this)); chaptersByRole: any = {}; anilistUrl: string = ''; @@ -181,7 +183,7 @@ export class PersonDetailComponent implements OnInit { } createFilter(roles: PersonRole[]) { - const filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter(); + const filter = this.metadataService.createDefaultFilterDto('series'); filter.combination = FilterCombination.Or; filter.limitTo = 20; @@ -217,7 +219,7 @@ export class PersonDetailComponent implements OnInit { params['page'] = 1; params['title'] = translate('person-detail.browse-person-by-role-title', {name: this.person!.name, role: personPipe.transform(role)}); - const searchFilter = this.filterUtilityService.createSeriesV2Filter(); + const searchFilter = this.metadataService.createDefaultFilterDto('series'); searchFilter.limitTo = 0; searchFilter.combination = FilterCombination.Or; diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts index e02a22fca..976bebbda 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts @@ -31,7 +31,8 @@ import {User} from "../../../_models/user"; templateUrl: './reading-lists.component.html', styleUrls: ['./reading-lists.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [SideNavCompanionBarComponent, CardActionablesComponent, CardDetailLayoutComponent, CardItemComponent, DecimalPipe, TranslocoDirective, BulkOperationsComponent] + imports: [SideNavCompanionBarComponent, CardActionablesComponent, CardDetailLayoutComponent, CardItemComponent, + DecimalPipe, TranslocoDirective, BulkOperationsComponent] }) export class ReadingListsComponent implements OnInit { protected readonly WikiLink = WikiLink; 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 5992df828..4035f2f41 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 @@ -895,10 +895,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.cdRef.markForCheck(); } - - - - this.isLoading = false; this.cdRef.markForCheck(); }); @@ -1092,19 +1088,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { } - openVolume(volume: Volume) { - if (this.bulkSelectionService.hasSelections()) return; - if (volume.chapters === undefined || volume.chapters?.length === 0) { - this.toastr.error(this.translocoService.translate('series-detail.no-chapters')); - return; - } - - this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'volume', volume.id]); - return; - - - this.readerService.readVolume(this.libraryId, this.seriesId, volume, false); - } openEditChapter(chapter: Chapter) { const ref = this.modalService.open(EditChapterModalComponent, DefaultModalOptions); diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index 49d57efbc..184f31094 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -1,24 +1,16 @@ -import { HttpClient } from '@angular/common/http'; +import {HttpClient} from '@angular/common/http'; import {DestroyRef, inject, Inject, Injectable} from '@angular/core'; -import { Series } from 'src/app/_models/series'; -import { environment } from 'src/environments/environment'; -import { ConfirmService } from '../confirm.service'; -import { Chapter } from 'src/app/_models/chapter'; -import { Volume } from 'src/app/_models/volume'; -import { - asyncScheduler, - BehaviorSubject, - Observable, - tap, - finalize, - of, - filter, -} from 'rxjs'; -import { download, Download } from '../_models/download'; -import { PageBookmark } from 'src/app/_models/readers/page-bookmark'; +import {Series} from 'src/app/_models/series'; +import {environment} from 'src/environments/environment'; +import {ConfirmService} from '../confirm.service'; +import {Chapter} from 'src/app/_models/chapter'; +import {Volume} from 'src/app/_models/volume'; +import {asyncScheduler, BehaviorSubject, filter, finalize, Observable, of, tap,} from 'rxjs'; +import {download, Download} from '../_models/download'; +import {PageBookmark} from 'src/app/_models/readers/page-bookmark'; import {switchMap, take, takeWhile, throttleTime} from 'rxjs/operators'; -import { AccountService } from 'src/app/_services/account.service'; -import { BytesPipe } from 'src/app/_pipes/bytes.pipe'; +import {AccountService} from 'src/app/_services/account.service'; +import {BytesPipe} from 'src/app/_pipes/bytes.pipe'; import {translate} from "@jsverse/transloco"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {SAVER, Saver} from "../../_providers/saver.provider"; @@ -26,7 +18,7 @@ import {UtilityService} from "./utility.service"; import {UserCollection} from "../../_models/collection-tag"; import {RecentlyAddedItem} from "../../_models/recently-added-item"; import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter"; -import {BrowsePerson} from "../../_models/person/browse-person"; +import {BrowsePerson} from "../../_models/metadata/browse/browse-person"; export const DEBOUNCE_TIME = 100; diff --git a/UI/Web/src/app/shared/_services/filter-utilities.service.ts b/UI/Web/src/app/shared/_services/filter-utilities.service.ts index a8c615149..559a70ab1 100644 --- a/UI/Web/src/app/shared/_services/filter-utilities.service.ts +++ b/UI/Web/src/app/shared/_services/filter-utilities.service.ts @@ -1,18 +1,27 @@ import {inject, Injectable} from '@angular/core'; -import {ActivatedRouteSnapshot, Params, Router} from '@angular/router'; -import {SortField, SortOptions} from 'src/app/_models/metadata/series-filter'; +import {Params, Router} from '@angular/router'; +import {allSeriesSortFields, SortField} from 'src/app/_models/metadata/series-filter'; import {MetadataService} from "../../_services/metadata.service"; -import {SeriesFilterV2} from "../../_models/metadata/v2/series-filter-v2"; -import {FilterStatement} from "../../_models/metadata/v2/filter-statement"; +import {FilterV2} from "../../_models/metadata/v2/filter-v2"; import {FilterCombination} from "../../_models/metadata/v2/filter-combination"; -import {FilterField} from "../../_models/metadata/v2/filter-field"; +import {allSeriesFilterFields, FilterField} from "../../_models/metadata/v2/filter-field"; import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; -import { HttpClient } from "@angular/common/http"; +import {HttpClient} from "@angular/common/http"; import {TextResonse} from "../../_types/text-response"; import {environment} from "../../../environments/environment"; import {map, tap} from "rxjs/operators"; -import {of, switchMap} from "rxjs"; -import {Location} from "@angular/common"; +import {switchMap} from "rxjs"; +import {allPersonFilterFields, PersonFilterField} from "../../_models/metadata/v2/person-filter-field"; +import {allPersonSortFields} from "../../_models/metadata/v2/person-sort-field"; +import { + FilterSettingsBase, + PersonFilterSettings, + SeriesFilterSettings, + ValidFilterEntity +} from "../../metadata-filter/filter-settings"; +import {SortFieldPipe} from "../../_pipes/sort-field.pipe"; +import {GenericFilterFieldPipe} from "../../_pipes/generic-filter-field.pipe"; +import {TranslocoService} from "@jsverse/transloco"; @Injectable({ @@ -20,59 +29,64 @@ import {Location} from "@angular/common"; }) export class FilterUtilitiesService { - private readonly location = inject(Location); private readonly router = inject(Router); private readonly metadataService = inject(MetadataService); private readonly http = inject(HttpClient); + private readonly translocoService = inject(TranslocoService); - private apiUrl = environment.apiUrl; + private readonly sortFieldPipe = new SortFieldPipe(this.translocoService); + private readonly genericFilterFieldPipe = new GenericFilterFieldPipe(); - encodeFilter(filter: SeriesFilterV2 | undefined) { + private readonly apiUrl = environment.apiUrl; + + encodeFilter(filter: FilterV2 | undefined) { return this.http.post(this.apiUrl + 'filter/encode', filter, TextResonse); } decodeFilter(encodedFilter: string) { - return this.http.post(this.apiUrl + 'filter/decode', {encodedFilter}).pipe(map(filter => { + return this.http.post(this.apiUrl + 'filter/decode', {encodedFilter}).pipe(map(filter => { if (filter == null) { - filter = this.metadataService.createDefaultFilterDto(); - filter.statements.push(this.createSeriesV2DefaultStatement()); + filter = this.metadataService.createDefaultFilterDto('series'); + filter.statements.push(this.metadataService.createDefaultFilterStatement('series')); } return filter; })) } - updateUrlFromFilter(filter: SeriesFilterV2 | undefined) { + /** + * Encodes the filter and patches into the url + * @param filter + */ + updateUrlFromFilter(filter: FilterV2 | undefined) { return this.encodeFilter(filter).pipe(tap(encodedFilter => { window.history.replaceState(window.location.href, '', window.location.href.split('?')[0]+ '?' + encodedFilter); })); } - filterPresetsFromUrl(snapshot: ActivatedRouteSnapshot) { - const filter = this.metadataService.createDefaultFilterDto(); - filter.statements.push(this.createSeriesV2DefaultStatement()); - if (!window.location.href.includes('?')) return of(filter); - - return this.decodeFilter(window.location.href.split('?')[1]); - } - /** - * Applies and redirects to the passed page with the filter encoded + * Applies and redirects to the passed page with the filter encoded (Series only) * @param page * @param filter * @param comparison * @param value */ applyFilter(page: Array, filter: FilterField, comparison: FilterComparison, value: string) { - const dto = this.createSeriesV2Filter(); - dto.statements.push(this.metadataService.createDefaultFilterStatement(filter, comparison, value + '')); + const dto = this.metadataService.createDefaultFilterDto('series'); + dto.statements.push(this.metadataService.createFilterStatement(filter, comparison, value + '')); return this.encodeFilter(dto).pipe(switchMap(encodedFilter => { return this.router.navigateByUrl(page.join('/') + '?' + encodedFilter); })); } - applyFilterWithParams(page: Array, filter: SeriesFilterV2, extraParams: Params) { + /** + * (Series only) + * @param page + * @param filter + * @param extraParams + */ + applyFilterWithParams(page: Array, filter: FilterV2, extraParams: Params) { return this.encodeFilter(filter).pipe(switchMap(encodedFilter => { let url = page.join('/') + '?' + encodedFilter; url += Object.keys(extraParams).map(k => `&${k}=${extraParams[k]}`).join(''); @@ -81,23 +95,228 @@ export class FilterUtilitiesService { })); } - createSeriesV2Filter(): SeriesFilterV2 { - return { - combination: FilterCombination.And, - statements: [], - limitTo: 0, - sortOptions: { - isAscending: true, - sortField: SortField.SortName - }, - }; + + createPersonV2Filter(): FilterV2 { + return { + combination: FilterCombination.And, + statements: [], + limitTo: 0, + sortOptions: { + isAscending: true, + sortField: SortField.SortName + }, + }; } - createSeriesV2DefaultStatement(): FilterStatement { - return { - comparison: FilterComparison.Equal, - value: '', - field: FilterField.SeriesName - } + /** + * Returns the Sort Fields for the Metadata filter based on the entity. + * @param type + */ + getSortFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return allSeriesSortFields.map(f => { + return {title: this.sortFieldPipe.transform(f, type), value: f}; + }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; + case 'person': + return allPersonSortFields.map(f => { + return {title: this.sortFieldPipe.transform(f, type), value: f}; + }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; + default: + return [] as {title: string, value: T}[]; + } + } + + /** + * Returns the Filter Fields for the Metadata filter based on the entity. + * @param type + */ + getFilterFields(type: ValidFilterEntity): {title: string, value: T}[] { + switch (type) { + case 'series': + return allSeriesFilterFields.map(f => { + return {title: this.genericFilterFieldPipe.transform(f, type), value: f}; + }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; + case 'person': + return allPersonFilterFields.map(f => { + return {title: this.genericFilterFieldPipe.transform(f, type), value: f}; + }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; + default: + return [] as {title: string, value: T}[]; + } + } + + /** + * Returns the default field for the Series or Person entity aka what should be there if there are no statements + * @param type + */ + getDefaultFilterField(type: ValidFilterEntity) { + switch (type) { + case 'series': + return FilterField.SeriesName as unknown as T; + case 'person': + return PersonFilterField.Role as unknown as T; + } + } + + /** + * Returns the appropriate Dropdown Fields based on the entity type + * @param type + */ + getDropdownFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating, + FilterField.Translators, FilterField.Characters, FilterField.Publisher, + FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, + FilterField.Colorist, FilterField.Inker, FilterField.Penciller, + FilterField.Writers, FilterField.Genres, FilterField.Libraries, + FilterField.Formats, FilterField.CollectionTags, FilterField.Tags, + FilterField.Imprint, FilterField.Team, FilterField.Location + ] as unknown as T[]; + case 'person': + return [ + PersonFilterField.Role + ] as unknown as T[]; + } + } + + /** + * Returns the applicable String fields + * @param type + */ + getStringFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath, PersonFilterField.Name + ] as unknown as T[]; + case 'person': + return [ + PersonFilterField.Name + ] as unknown as T[]; + } + } + + getNumberFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, + FilterField.UserRating, FilterField.AverageRating, FilterField.ReadLast + ] as unknown as T[]; + case 'person': + return [ + PersonFilterField.ChapterCount, PersonFilterField.SeriesCount + ] as unknown as T[]; + } + } + + getBooleanFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.WantToRead + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getDateFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.ReadingDate + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getNumberFieldsThatIncludeDateComparisons(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.ReleaseYear + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getDropdownFieldsThatIncludeDateComparisons(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.AgeRating + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getDropdownFieldsWithoutMustContains(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getDropdownFieldsThatIncludeNumberComparisons(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.AgeRating + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getFieldsThatShouldIncludeIsEmpty(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.Summary, FilterField.UserRating, FilterField.Genres, + FilterField.CollectionTags, FilterField.Tags, FilterField.ReleaseYear, + FilterField.Translators, FilterField.Characters, FilterField.Publisher, + FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, + FilterField.Colorist, FilterField.Inker, FilterField.Penciller, + FilterField.Writers, FilterField.Imprint, FilterField.Team, + FilterField.Location + ] as unknown as T[]; + case 'person': + return [] as unknown as T[]; + } + } + + getDefaultSettings(entityType: ValidFilterEntity | "other" | undefined): FilterSettingsBase { + if (entityType === 'other' || entityType === undefined) { + // It doesn't matter, return series type + return new SeriesFilterSettings(); + } + + if (entityType == 'series') return new SeriesFilterSettings(); + if (entityType == 'person') return new PersonFilterSettings(); + + return new SeriesFilterSettings(); } } diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index afb63ab1d..da90ca412 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -1,5 +1,5 @@ import {HttpParams} from '@angular/common/http'; -import {Injectable} from '@angular/core'; +import {Inject, Injectable, signal, Signal} from '@angular/core'; import {Chapter} from 'src/app/_models/chapter'; import {LibraryType} from 'src/app/_models/library/library'; import {MangaFormat} from 'src/app/_models/manga-format'; @@ -8,6 +8,8 @@ import {Series} from 'src/app/_models/series'; import {Volume} from 'src/app/_models/volume'; import {translate} from "@jsverse/transloco"; import {debounceTime, ReplaySubject, shareReplay} from "rxjs"; +import {DOCUMENT} from "@angular/common"; +import getComputedStyle from "@popperjs/core/lib/dom-utils/getComputedStyle"; export enum KEY_CODES { RIGHT_ARROW = 'ArrowRight', @@ -27,12 +29,37 @@ export enum KEY_CODES { SHIFT = 'Shift' } +/** + * Use {@link UserBreakpoint} and {@link UtilityService.activeUserBreakpoint} for breakpoint that should depend on user settings + */ export enum Breakpoint { Mobile = 768, Tablet = 1280, Desktop = 1440 } +/* +Breakpoints, but they're derived from css vars in the theme + */ +export enum UserBreakpoint { + /** + * This is to be used in the UI/as value to disable the functionality with breakpoint, will not actually be set as a breakpoint + */ + Never = 0, + /** + * --mobile-breakpoint + */ + Mobile = 1, + /** + * --tablet-breakpoint + */ + Tablet = 2, + /** + * --desktop-breakpoint, does not actually matter as everything that's not mobile or tablet will be desktop + */ + Desktop = 3, +} + @Injectable({ providedIn: 'root' @@ -42,11 +69,19 @@ export class UtilityService { public readonly activeBreakpointSource = new ReplaySubject(1); public readonly activeBreakpoint$ = this.activeBreakpointSource.asObservable().pipe(debounceTime(60), shareReplay({bufferSize: 1, refCount: true})); + /** + * The currently active breakpoint, is {@link UserBreakpoint.Never} until the app has loaded + */ + public readonly activeUserBreakpoint = signal(UserBreakpoint.Never); + // TODO: I need an isPhone/Tablet so that I can easily trigger different views mangaFormatKeys: string[] = []; + constructor(@Inject(DOCUMENT) private document: Document) { + } + sortChapters = (a: Chapter, b: Chapter) => { return a.minNumber - b.minNumber; @@ -132,6 +167,34 @@ export class UtilityService { return Breakpoint.Desktop; } + updateUserBreakpoint(): void { + this.activeUserBreakpoint.set(this.getActiveUserBreakpoint()); + } + + private getActiveUserBreakpoint(): UserBreakpoint { + const style = getComputedStyle(this.document.body) + const mobileBreakPoint = this.parseOrDefault(style.getPropertyValue('--setting-mobile-breakpoint'), Breakpoint.Mobile); + const tabletBreakPoint = this.parseOrDefault(style.getPropertyValue('--setting-tablet-breakpoint'), Breakpoint.Tablet); + //const desktopBreakPoint = this.parseOrDefault(style.getPropertyValue('--setting-desktop-breakpoint'), Breakpoint.Desktop); + + if (window.innerWidth <= mobileBreakPoint) { + return UserBreakpoint.Mobile; + } else if (window.innerWidth <= tabletBreakPoint) { + return UserBreakpoint.Tablet; + } + + // Fallback to desktop + return UserBreakpoint.Desktop; + } + + private parseOrDefault(s: string, def: T): T { + const ret = parseInt(s, 10); + if (isNaN(ret)) { + return def; + } + return ret as T; + } + isInViewport(element: Element, additionalTopOffset: number = 0) { const rect = element.getBoundingClientRect(); return ( diff --git a/UI/Web/src/app/sidenav/_components/dashboard-stream-list-item/dashboard-stream-list-item.component.ts b/UI/Web/src/app/sidenav/_components/dashboard-stream-list-item/dashboard-stream-list-item.component.ts index acc1ca28c..267c5123c 100644 --- a/UI/Web/src/app/sidenav/_components/dashboard-stream-list-item/dashboard-stream-list-item.component.ts +++ b/UI/Web/src/app/sidenav/_components/dashboard-stream-list-item/dashboard-stream-list-item.component.ts @@ -1,14 +1,13 @@ import {ChangeDetectionStrategy, Component, EventEmitter, inject, Input, Output} from '@angular/core'; -import {APP_BASE_HREF, NgClass, NgIf} from '@angular/common'; +import {APP_BASE_HREF, NgClass} from '@angular/common'; import {TranslocoDirective} from "@jsverse/transloco"; import {DashboardStream} from "../../../_models/dashboard/dashboard-stream"; import {StreamNamePipe} from "../../../_pipes/stream-name.pipe"; -import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum"; import {StreamType} from "../../../_models/dashboard/stream-type.enum"; @Component({ selector: 'app-dashboard-stream-list-item', - imports: [TranslocoDirective, StreamNamePipe, NgClass, NgIf], + imports: [TranslocoDirective, StreamNamePipe, NgClass], templateUrl: './dashboard-stream-list-item.component.html', styleUrls: ['./dashboard-stream-list-item.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html index a20571b91..7e3deb5af 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html @@ -76,7 +76,7 @@ @case (SideNavStreamType.BrowseAuthors) { + [cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode" icon="fa-users" [title]="t('browse-people')" link="/browse/authors/"> } @case (SideNavStreamType.SmartFilter) { diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts index 617db2500..be9d841b5 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts @@ -38,7 +38,7 @@ import {ReadingProfileService} from "../../../_services/reading-profile.service" export class SideNavComponent implements OnInit { protected readonly WikiLink = WikiLink; - protected readonly ItemLimit = 10; + protected readonly ItemLimit = 13; protected readonly SideNavStreamType = SideNavStreamType; protected readonly SettingsTabId = SettingsTabId; protected readonly Breakpoint = Breakpoint; diff --git a/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts index 6118eae83..ad86bef89 100644 --- a/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts +++ b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts @@ -4,7 +4,6 @@ import {SideNavStream} from "../../../_models/sidenav/sidenav-stream"; import {StreamNamePipe} from "../../../_pipes/stream-name.pipe"; import {TranslocoDirective} from "@jsverse/transloco"; import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum"; -import {RouterLink} from "@angular/router"; @Component({ selector: 'app-sidenav-stream-list-item', diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts index 2333dee25..797124c4f 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts @@ -20,7 +20,13 @@ import { } from 'src/app/admin/_modals/directory-picker/directory-picker.component'; import {ConfirmService} from 'src/app/shared/confirm.service'; import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; -import {allLibraryTypes, Library, LibraryType} from 'src/app/_models/library/library'; +import { + allKavitaPlusMetadataApplicableTypes, + allKavitaPlusScrobbleEligibleTypes, + allLibraryTypes, + Library, + LibraryType +} from 'src/app/_models/library/library'; import {ImageService} from 'src/app/_services/image.service'; import {LibraryService} from 'src/app/_services/library.service'; import {UploadService} from 'src/app/_services/upload.service'; @@ -103,8 +109,8 @@ export class LibrarySettingsModalComponent implements OnInit { includeInDashboard: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), includeInRecommended: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), includeInSearch: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - manageCollections: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - manageReadingLists: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), + manageCollections: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), + manageReadingLists: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), allowScrobbling: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), allowMetadataMatching: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), collapseSeriesRelationships: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), @@ -125,13 +131,12 @@ export class LibrarySettingsModalComponent implements OnInit { get IsKavitaPlusEligible() { const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType; - return libType === LibraryType.Manga || libType === LibraryType.LightNovel; + return allKavitaPlusScrobbleEligibleTypes.includes(libType); } get IsMetadataDownloadEligible() { const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType; - return libType === LibraryType.Manga || libType === LibraryType.LightNovel - || libType === LibraryType.ComicVine || libType === LibraryType.Comic; + return allKavitaPlusMetadataApplicableTypes.includes(libType); } ngOnInit(): void { @@ -232,6 +237,7 @@ export class LibrarySettingsModalComponent implements OnInit { this.libraryForm.get('allowMetadataMatching')?.disable(); } + this.cdRef.markForCheck(); }), takeUntilDestroyed(this.destroyRef) diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.html b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.html index 5e4f9b2cd..b8c1ec8af 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.html +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.html @@ -9,7 +9,7 @@ @for(section of sections; track section.title + section.children.length; let idx = $index;) { @if (hasAnyChildren(user, section)) { -
{{t(section.title)}}
+
{{t(section.title)}}
@for(item of section.children; track item.fragment) { @if (accountService.hasAnyRole(user, item.roles, item.restrictRoles)) { diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts index 98ba48968..d76a84cc8 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts @@ -192,6 +192,7 @@ export class PreferenceNavComponent implements AfterViewInit { } else { return this.manageService.getAllKavitaPlusSeries({ matchStateOption: MatchStateOption.Error, + libraryType: -1, searchTerm: '' }).pipe( takeUntilDestroyed(this.destroyRef), @@ -272,13 +273,11 @@ export class PreferenceNavComponent implements AfterViewInit { hasAnyChildren(user: User, section: PrefSection) { // Filter out items where the user has a restricted role const visibleItems = section.children.filter(item => - item.restrictRoles.length === 0 || !this.accountService.hasAnyRole(user, item.restrictRoles) + (item.restrictRoles.length === 0 || !this.accountService.hasAnyRestrictedRole(user, item.restrictRoles)) && + (item.roles.length === 0 || this.accountService.hasAnyRole(user, item.roles)) ); - // Check if the user has any allowed roles in the remaining items - return visibleItems.some(item => - this.accountService.hasAnyRole(user, item.roles) - ); + return visibleItems.length > 0; } collapse() { diff --git a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.html b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.html index 7acbe9196..d7f4c6bb9 100644 --- a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.html +++ b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.html @@ -10,13 +10,13 @@

{{t('description')}}

-

{{t('extra-tip')}}

+

{{t('extra-tip')}} {{t('wiki-title')}}

-
-
+
+
@if (readingProfiles.length < virtualScrollerBreakPoint) { @for (readingProfile of readingProfiles; track readingProfile.id) { @@ -32,7 +32,7 @@
-
+
@if (selectedProfile === null) {

{{t('no-selected')}}

@@ -46,7 +46,9 @@
- {{readingProfileForm.get('name')!.value}} + + {{readingProfileForm.get('name')!.value}} + @@ -250,6 +252,22 @@
} +
+ + + {{readingProfileForm.get('disableWidthOverride')!.value | breakpoint}} + + + + + +
+
} diff --git a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.scss b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.scss index 13f341a32..ba232dbae 100644 --- a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.scss +++ b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.scss @@ -1,7 +1,5 @@ @use '../../../series-detail-common'; - - .group-item { background-color: transparent; diff --git a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts index 2bc4ece7d..e64e938f6 100644 --- a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts +++ b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts @@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnIni import {ReadingProfileService} from "../../_services/reading-profile.service"; import { bookLayoutModes, - bookWritingStyles, + bookWritingStyles, breakPoints, layoutModes, pageSplitOptions, pdfScrollModes, @@ -47,6 +47,8 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {LoadingComponent} from "../../shared/loading/loading.component"; import {ToastrService} from "ngx-toastr"; import {ConfirmService} from "../../shared/confirm.service"; +import {WikiLink} from "../../_models/wiki"; +import {BreakpointPipe} from "../../_pipes/breakpoint.pipe"; enum TabId { ImageReader = "image-reader", @@ -85,6 +87,7 @@ enum TabId { NgbNavOutlet, LoadingComponent, NgbTooltip, + BreakpointPipe, ], templateUrl: './manage-reading-profiles.component.html', styleUrl: './manage-reading-profiles.component.scss', @@ -193,6 +196,7 @@ export class ManageReadingProfilesComponent implements OnInit { this.readingProfileForm.addControl('backgroundColor', new FormControl(this.selectedProfile.backgroundColor, [])); this.readingProfileForm.addControl('allowAutomaticWebtoonReaderDetection', new FormControl(this.selectedProfile.allowAutomaticWebtoonReaderDetection, [])); this.readingProfileForm.addControl('widthOverride', new FormControl(this.selectedProfile.widthOverride, [Validators.min(0), Validators.max(100)])); + this.readingProfileForm.addControl('disableWidthOverride', new FormControl(this.selectedProfile.disableWidthOverride, [])) // Epub reader this.readingProfileForm.addControl('bookReaderFontFamily', new FormControl(this.selectedProfile.bookReaderFontFamily, [])); @@ -237,10 +241,10 @@ export class ManageReadingProfilesComponent implements OnInit { } else { const profile = this.packData(); this.readingProfileService.updateProfile(profile).subscribe({ - next: _ => { + next: newProfile => { this.readingProfiles = this.readingProfiles.map(p => { if (p.id !== profile.id) return p; - return profile; + return newProfile; }); this.cdRef.markForCheck(); }, @@ -260,6 +264,7 @@ export class ManageReadingProfilesComponent implements OnInit { data.pageSplitOption = parseInt(data.pageSplitOption as unknown as string); data.readerMode = parseInt(data.readerMode as unknown as string); data.layoutMode = parseInt(data.layoutMode as unknown as string); + data.disableWidthOverride = parseInt(data.disableWidthOverride as unknown as string); data.bookReaderReadingDirection = parseInt(data.bookReaderReadingDirection as unknown as string); data.bookReaderWritingStyle = parseInt(data.bookReaderWritingStyle as unknown as string); @@ -316,4 +321,6 @@ export class ManageReadingProfilesComponent implements OnInit { protected readonly pdfScrollModes = pdfScrollModes; protected readonly TabId = TabId; protected readonly ReadingProfileKind = ReadingProfileKind; + protected readonly WikiLink = WikiLink; + protected readonly breakPoints = breakPoints; } 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 8ef4f814c..56742bc57 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.html +++ b/UI/Web/src/app/volume-detail/volume-detail.component.html @@ -170,7 +170,7 @@ [libraryId]="libraryId" [libraryType]="libraryType" [actions]="chapterActions" - (selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, volume.chapters.length, $event)" + (selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, volume!.chapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true" > } diff --git a/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.ts b/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.ts index 91fc9f143..b16d1f9fc 100644 --- a/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.ts +++ b/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.ts @@ -1,43 +1,49 @@ -import { DOCUMENT, NgStyle, NgIf, DecimalPipe } from '@angular/common'; +import {DecimalPipe, DOCUMENT, NgStyle} from '@angular/common'; import { AfterContentChecked, ChangeDetectionStrategy, ChangeDetectorRef, - Component, DestroyRef, + Component, + DestroyRef, ElementRef, EventEmitter, - HostListener, inject, Inject, OnInit, ViewChild } from '@angular/core'; -import { Title } from '@angular/platform-browser'; -import { Router, ActivatedRoute } from '@angular/router'; -import { take, debounceTime } from 'rxjs'; -import { BulkSelectionService } from 'src/app/cards/bulk-selection.service'; -import { FilterSettings } from 'src/app/metadata-filter/filter-settings'; -import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service'; -import { UtilityService, KEY_CODES } from 'src/app/shared/_services/utility.service'; -import { SeriesRemovedEvent } from 'src/app/_models/events/series-removed-event'; -import { JumpKey } from 'src/app/_models/jumpbar/jump-key'; -import { Pagination } from 'src/app/_models/pagination'; -import { Series } from 'src/app/_models/series'; -import { FilterEvent } from 'src/app/_models/metadata/series-filter'; -import { Action, ActionItem } from 'src/app/_services/action-factory.service'; -import { ActionService } from 'src/app/_services/action.service'; -import { ImageService } from 'src/app/_services/image.service'; -import { JumpbarService } from 'src/app/_services/jumpbar.service'; -import { MessageHubService, EVENTS } from 'src/app/_services/message-hub.service'; -import { ScrollService } from 'src/app/_services/scroll.service'; -import { SeriesService } from 'src/app/_services/series.service'; +import {Title} from '@angular/platform-browser'; +import {ActivatedRoute, Router} from '@angular/router'; +import {debounceTime, take} from 'rxjs'; +import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; +import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service'; +import {UtilityService} from 'src/app/shared/_services/utility.service'; +import {SeriesRemovedEvent} from 'src/app/_models/events/series-removed-event'; +import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; +import {Pagination} from 'src/app/_models/pagination'; +import {Series} from 'src/app/_models/series'; +import {FilterEvent, SortField} from 'src/app/_models/metadata/series-filter'; +import {Action, ActionItem} from 'src/app/_services/action-factory.service'; +import {ActionService} from 'src/app/_services/action.service'; +import {ImageService} from 'src/app/_services/image.service'; +import {JumpbarService} from 'src/app/_services/jumpbar.service'; +import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service'; +import {ScrollService} from 'src/app/_services/scroll.service'; +import {SeriesService} from 'src/app/_services/series.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import { SeriesCardComponent } from '../../../cards/series-card/series-card.component'; -import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component'; -import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component'; -import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; +import {SeriesCardComponent} from '../../../cards/series-card/series-card.component'; +import {CardDetailLayoutComponent} from '../../../cards/card-detail-layout/card-detail-layout.component'; +import {BulkOperationsComponent} from '../../../cards/bulk-operations/bulk-operations.component'; +import { + SideNavCompanionBarComponent +} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import {translate, TranslocoDirective} from "@jsverse/transloco"; -import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../../../_models/metadata/v2/filter-v2"; +import {FilterField} from "../../../_models/metadata/v2/filter-field"; +import {SeriesFilterSettings} from "../../../metadata-filter/filter-settings"; +import {MetadataService} from "../../../_services/metadata.service"; +import {FilterStatement} from "../../../_models/metadata/v2/filter-statement"; +import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; @Component({ @@ -52,15 +58,16 @@ export class WantToReadComponent implements OnInit, AfterContentChecked { @ViewChild('scrollingBlock') scrollingBlock: ElementRef | undefined; @ViewChild('companionBar') companionBar: ElementRef | undefined; private readonly destroyRef = inject(DestroyRef); + private readonly metadataService = inject(MetadataService); isLoading: boolean = true; series: Array = []; pagination: Pagination = new Pagination(); - filter: SeriesFilterV2 | undefined = undefined; - filterSettings: FilterSettings = new FilterSettings(); + filter: FilterV2 | undefined = undefined; + filterSettings: SeriesFilterSettings = new SeriesFilterSettings(); refresh: EventEmitter = new EventEmitter(); - filterActiveCheck!: SeriesFilterV2; + filterActiveCheck!: FilterV2; filterActive: boolean = false; jumpbarKeys: Array = []; @@ -105,13 +112,23 @@ export class WantToReadComponent implements OnInit, AfterContentChecked { this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.titleService.setTitle('Kavita - ' + translate('want-to-read.title')); - this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { - this.filter = filter; - this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter(); - this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement()); + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.filter = data['filter'] as FilterV2; + + const defaultStmt = {field: FilterField.WantToRead, value: 'true', comparison: FilterComparison.Equal} as FilterStatement; + + if (this.filter == null) { + this.filter = this.metadataService.createDefaultFilterDto('series'); + this.filter.statements.push(defaultStmt); + } + + + this.filterActiveCheck = this.metadataService.createDefaultFilterDto('series'); + this.filterActiveCheck!.statements.push(defaultStmt); this.filterSettings.presetsV2 = this.filter; + this.cdRef.markForCheck(); }); @@ -165,7 +182,7 @@ export class WantToReadComponent implements OnInit, AfterContentChecked { }); } - updateFilter(data: FilterEvent) { + updateFilter(data: FilterEvent) { if (data.filterV2 === undefined) return; this.filter = data.filterV2; diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index b9ab24ae5..2a2d40c4f 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -719,6 +719,8 @@ "library-name-header": "Library", "valid-until-header": "Next Refresh", "actions-header": "Actions", + "library-type": "Library Type", + "matched-state-label": "Match State", "matched-status-label": "Matched", "unmatched-status-label": "Not Matched", "blacklist-status-label": "Needs Manual Match", @@ -1034,7 +1036,7 @@ "collections": "Collections", "reading-lists": "Reading Lists", "bookmarks": "Bookmarks", - "browse-authors": "Browse Authors", + "browse-people": "Browse People", "filter-label": "{{common.filter}}", "all-series": "All Series", "clear": "{{common.clear}}", @@ -1047,10 +1049,31 @@ "edit": "{{common.edit}}" }, - "browse-authors": { - "title": "Browse Authors & Writers", + "browse-people": { + "title": "Browse People", "author-count": "{{num}} People", - "cover-image-description": "{{edit-series-modal.cover-image-description}}" + "cover-image-description": "{{edit-series-modal.cover-image-description}}", + "issue-count": "{{common.issue-count}}", + "series-count": "{{common.series-count}}", + "roles-label": "Roles", + "sort-label": "Sort", + "name-label": "Name", + "issue-count-label": "Issue Count", + "series-count-label": "Series Count" + }, + + "browse-genres": { + "title": "Browse Genres", + "genre-count": "{{num}} Genres", + "issue-count": "{{common.issue-count}}", + "series-count": "{{common.series-count}}" + }, + + "browse-tags": { + "title": "Browse Tags", + "genre-count": "{{num}} Tags", + "issue-count": "{{common.issue-count}}", + "series-count": "{{common.series-count}}" }, "person-detail": { @@ -1832,7 +1855,9 @@ "all-filters": "Smart Filters", "nav-link-header": "Navigation Options", "close": "{{common.close}}", - "person-aka-status": "Matches an alias" + "person-aka-status": "Matches an alias", + "browse-genres": "Browse Genres", + "browse-tags": "Browse Tags" }, "promoted-icon": { @@ -2060,7 +2085,10 @@ "release-year": "Release Year", "read-progress": "Last Read", "average-rating": "Average Rating", - "random": "Random" + "random": "Random", + "person-name": "Name", + "person-series-count": "Series Count", + "person-chapter-count": "Chapter Count" }, "edit-series-modal": { @@ -2483,7 +2511,7 @@ "reading-lists": "{{side-nav.reading-lists}}", "bookmarks": "{{side-nav.bookmarks}}", "all-series": "{{side-nav.all-series}}", - "browse-authors": "{{side-nav.browse-authors}}" + "browse-authors": "{{side-nav.browse-people}}" }, "filter-field-pipe": { @@ -2559,6 +2587,38 @@ "prompt": "Question" }, + "browse-title-pipe": { + "publication-status": "{{value}} works", + "age-rating": "Rated {{value}}", + "user-rating": "{{value}} star rating", + "tag": "Has Tag {{value}}", + "translator": "Translated by {{value}}", + "character": "Has character {{value}}", + "publisher": "Published by {{value}}", + "editor": "Edited by {{value}}", + "artist": "Drawn by {{value}}", + "letterer": "Lettered by {{value}}", + "colorist": "Colored by {{value}}", + "inker": "Inked by {{value}}", + "penciller": "Pencilled by {{value}}", + "writer": "Written by {{value}}", + "genre": "Has Genre {{value}}", + "library": "Within {{value}} library", + "format": "Format of {{value}}", + "release-year": "Released in {{value}}", + "imprint": "Imprint of {{value}}", + "team": "Team {{value}}", + "location": "In {{value}} location" + }, + + "generic-filter-field-pipe": { + "person-role": "Role", + "person-name": "Name", + "person-series-count": "Series Count", + "person-chapter-count": "Chapter Count" + }, + + "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.", @@ -2822,10 +2882,17 @@ "pdf-light": "Light", "pdf-dark": "Dark" }, + "breakpoint-pipe": { + "never": "Never", + "mobile": "Mobile", + "tablet": "Tablet", + "desktop": "Desktop" + }, "manage-reading-profiles": { "description": "Not all your series may be read in the same way, set up distinct reading profiles per library or series to make getting back in your series as seamless as possible.", - "extra-tip": "Assign reading profiles via the action menu on series and libraries, or in bulk. When changing settings in a reader, a hidden profile is created that remembers your choices for that series (not for pdfs). This profile is removed when you assign or update one of your own reading profiles to the series.", + "extra-tip": "Assign reading profiles via the action menu on series and libraries, or in bulk. When changing settings in a reader, a hidden profile is created that remembers your choices for that series (not for pdfs). This profile is removed when you assign one of your own reading profiles to the series. More information can be found on the", + "wiki-title": "wiki", "profiles-title": "Your reading profiles", "default-profile": "Default", "add": "{{common.add}}", @@ -2860,6 +2927,8 @@ "allow-auto-webtoon-reader-tooltip": "Switch into Webtoon Reader mode if pages look like a webtoon. Some false positives may occur.", "width-override-label": "{{manga-reader.width-override-label}}", "width-override-tooltip": "Override width of images in the reader", + "disable-width-override-label": "Disable width override", + "disable-width-override-tooltip": "Prevent the width override from taking effect when your screen is at least the configured breakpoint or smaller", "reset": "{{common.reset}}", "book-reader-settings-title": "Book Reader", @@ -2971,6 +3040,7 @@ "author-count": "{{num}} Authors", "item-count": "{{num}} Items", "chapter-count": "{{num}} Chapters", + "issue-count": "{{num}} Issues", "no-data": "No Data", "book-num": "Book", diff --git a/UI/Web/src/theme/components/_sidenav.scss b/UI/Web/src/theme/components/_sidenav.scss index c724cec17..33faf7d82 100644 --- a/UI/Web/src/theme/components/_sidenav.scss +++ b/UI/Web/src/theme/components/_sidenav.scss @@ -28,7 +28,7 @@ } //START closed state of the sidebar &.closed { - width: 2.825rem; + width: 3.125rem; overflow-x: hidden; overflow-y: hidden; background-color: var(--side-nav-closed-bg-color); @@ -36,6 +36,11 @@ height: calc((var(--vh) * 100) - 6.5rem); border-radius: unset; + // For Firefox + @supports (-moz-appearance: none) { + width: 2.5rem; + } + .side-nav { .side-nav-item { color: var(--side-nav-item-closed-color); @@ -46,6 +51,10 @@ } } + .phone-hidden:first-of-type { + margin-left: unset; + } + .active-highlight { opacity: 0; } @@ -83,10 +92,10 @@ display: flex; &:first-of-type { - text-align: center; width: 2.5rem; min-width: 2.5rem; margin-left: 0.3rem; + justify-content: center; } &:last-child { @@ -95,9 +104,7 @@ } div { - align-items: center; height: 100%; - justify-content: inherit; padding: 0 0.625rem; i { @@ -193,10 +200,12 @@ .side-nav { overflow-x: hidden; padding-bottom: 0.625rem; + padding-left: 1.125rem; .side-nav-header { color: #ffffff; font-size: 1rem; + margin-left: unset; &:first-of-type { margin-top: 0.7rem; @@ -207,7 +216,6 @@ font-size: 1rem; min-height: 1.875rem; justify-content: unset; - margin-left: 1.125rem; &.active { .side-nav-text { @@ -220,6 +228,14 @@ margin-left: 0.75rem; font-size: 0.9rem; color: #999999; + + div { + display: flex; + + .badge { + align-self: center; + } + } } .card-actions { @@ -278,9 +294,9 @@ } } } - //START sidebar closed + //START sidebar bottom closed &.closed { - width: 3.4375rem; + width: 2.5rem; overflow-x: hidden; overflow-y: auto; background-color: unset; @@ -295,7 +311,7 @@ } } } - //END sidebar closed + //END sidebar bottom closed } //END kavita+ subscription bottom heart button diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index c0cba6def..7ec33d613 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -442,4 +442,10 @@ /** Search **/ --input-hint-border-color: #aeaeae; --input-hint-text-color: lightgrey; + + /** Breakpoint **/ + --setting-mobile-breakpoint: 768; + --setting-tablet-breakpoint: 1280; + --setting-desktop-breakpoint: 1440; + }