diff --git a/API.Tests/Extensions/SeriesFilterTests.cs b/API.Tests/Extensions/SeriesFilterTests.cs new file mode 100644 index 000000000..2774ad78e --- /dev/null +++ b/API.Tests/Extensions/SeriesFilterTests.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs.Filtering.v2; +using API.Extensions.QueryExtensions.Filtering; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace API.Tests.Extensions; + +public class SeriesFilterTests : AbstractDbTest +{ + + protected override Task ResetDb() + { + return Task.CompletedTask; + } + + #region HasLanguage + + [Fact] + public async Task HasLanguage_Works() + { + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Contains, new List() { }).ToListAsync(); + + } + + #endregion +} diff --git a/API/Constants/CacheProfiles.cs b/API/Constants/CacheProfiles.cs index dd25f27a6..bf5414eba 100644 --- a/API/Constants/CacheProfiles.cs +++ b/API/Constants/CacheProfiles.cs @@ -15,6 +15,10 @@ public static class EasyCacheProfiles /// Cache the libraries on the server /// public const string Library = "library"; + /// + /// Metadata filter + /// + public const string Filter = "filter"; public const string KavitaPlusReviews = "kavita+reviews"; public const string KavitaPlusRecommendations = "kavita+recommendations"; public const string KavitaPlusRatings = "kavita+ratings"; diff --git a/API/Controllers/FilterController.cs b/API/Controllers/FilterController.cs new file mode 100644 index 000000000..7b6e41ef8 --- /dev/null +++ b/API/Controllers/FilterController.cs @@ -0,0 +1,59 @@ +using System; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.DTOs.Filtering.v2; +using EasyCaching.Core; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +/// +/// This is responsible for Filter caching +/// +public class FilterController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IEasyCachingProviderFactory _cacheFactory; + + public FilterController(IUnitOfWork unitOfWork, IEasyCachingProviderFactory cacheFactory) + { + _unitOfWork = unitOfWork; + _cacheFactory = cacheFactory; + } + + [HttpGet] + public async Task> GetFilter(string name) + { + var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter); + if (string.IsNullOrEmpty(name)) return Ok(null); + var filter = await provider.GetAsync(name); + if (filter.HasValue) + { + filter.Value.Name = name; + return Ok(filter.Value); + } + + return Ok(null); + } + + /// + /// Caches the filter in the backend and returns a temp string for retrieving. + /// + /// The cache line lives for only 1 hour + /// + /// + [HttpPost("create-temp")] + public async Task> CreateTempFilter(FilterV2Dto filterDto) + { + var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter); + var name = filterDto.Name; + if (string.IsNullOrEmpty(filterDto.Name)) + { + name = Guid.NewGuid().ToString(); + } + + await provider.SetAsync(name, filterDto, TimeSpan.FromHours(1)); + return name; + } +} diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index a5d4996ff..e950bc1d5 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -138,19 +138,14 @@ public class MetadataController : BaseApiController /// String separated libraryIds or null for all ratings /// [HttpGet("languages")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new []{"libraryIds"})] public async Task>> GetAllLanguages(string? libraryIds) { var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); - if (ids is {Count: > 0}) - { - return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids)); - } - - - return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync()); + return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids)); } + [HttpGet("all-languages")] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)] public IEnumerable GetAllValidLanguages() @@ -163,6 +158,7 @@ public class MetadataController : BaseApiController }).Where(l => !string.IsNullOrEmpty(l.IsoCode)); } + /// /// Returns summary for the chapter /// diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 814ce7843..0f0b2880e 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -10,6 +10,7 @@ using API.Data.Repositories; using API.DTOs; using API.DTOs.CollectionTags; using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; using API.DTOs.OPDS; using API.DTOs.Search; using API.Entities; @@ -65,6 +66,8 @@ public class OpdsController : BaseApiController SortOptions = null, PublicationStatus = new List() }; + + private readonly FilterV2Dto _filterV2Dto = new FilterV2Dto(); private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default; private const int PageSize = 20; @@ -201,6 +204,8 @@ public class OpdsController : BaseApiController Links = new List() { CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries/{library.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}") } }); } @@ -226,21 +231,19 @@ public class OpdsController : BaseApiController var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix); SetFeedId(feed, "collections"); - foreach (var tag in tags) + + feed.Entries.AddRange(tags.Select(tag => new FeedEntry() { - feed.Entries.Add(new FeedEntry() + Id = tag.Id.ToString(), + Title = tag.Title, + Summary = tag.Summary, + Links = new List() { - Id = tag.Id.ToString(), - Title = tag.Title, - Summary = tag.Summary, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionId={tag.Id}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionId={tag.Id}&apiKey={apiKey}") - } - }); - } + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}") + } + })); return CreateXmlResult(SerializeXml(feed)); } @@ -315,6 +318,8 @@ public class OpdsController : BaseApiController Links = new List() { CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}") } }); } @@ -378,17 +383,27 @@ public class OpdsController : BaseApiController return BadRequest(await _localizationService.Translate(userId, "no-library-access")); } - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, GetUserParams(pageNumber), _filterDto); + var filter = new FilterV2Dto + { + Statements = new List() { + new () + { + Comparison = FilterComparison.Equal, + Field = FilterField.Libraries, + Value = libraryId + string.Empty + } + } + }; + + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber), filter); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id)); var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey, prefix); SetFeedId(feed, $"library-{library.Name}"); AddPagination(feed, series, $"{prefix}{apiKey}/libraries/{libraryId}"); - foreach (var seriesDto in series) - { - feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)); - } + feed.Entries.AddRange(series.Select(seriesDto => + CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl))); return CreateXmlResult(SerializeXml(feed)); } @@ -401,7 +416,7 @@ public class OpdsController : BaseApiController if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); var (baseUrl, prefix) = await GetPrefix(); - var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, userId, GetUserParams(pageNumber), _filterDto); + var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, GetUserParams(pageNumber), _filterV2Dto); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id)); var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{prefix}{apiKey}/recently-added", apiKey, prefix); @@ -730,8 +745,10 @@ public class OpdsController : BaseApiController return new FeedEntry() { Id = seriesDto.Id.ToString(), - Title = $"{seriesDto.Name} ({seriesDto.Format})", - Summary = seriesDto.Summary, + Title = $"{seriesDto.Name}", + Summary = $"Format: {seriesDto.Format}" + (string.IsNullOrWhiteSpace(metadata.Summary) + ? string.Empty + : $" Summary: {metadata.Summary}"), Authors = metadata.Writers.Select(p => new FeedAuthor() { Name = p.Name, @@ -756,7 +773,8 @@ public class OpdsController : BaseApiController return new FeedEntry() { Id = searchResultDto.SeriesId.ToString(), - Title = $"{searchResultDto.Name} ({searchResultDto.Format})", + Title = $"{searchResultDto.Name}", + Summary = $"Format: {searchResultDto.Format}", Links = new List() { CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/series/{searchResultDto.SeriesId}"), diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 57a0e00a4..39748325f 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -8,6 +8,7 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; @@ -596,7 +597,7 @@ public class ReaderController : BaseApiController /// Only supports SeriesNameQuery /// [HttpPost("all-bookmarks")] - public async Task>> GetAllBookmarks(FilterDto filterDto) + public async Task>> GetAllBookmarks(FilterV2Dto filterDto) { return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(User.GetUserId(), filterDto)); } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index c420a955f..a8c1a82d1 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Constants; @@ -6,6 +7,7 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; using API.DTOs.Metadata; using API.DTOs.SeriesDetail; using API.Entities; @@ -53,7 +55,16 @@ public class SeriesController : BaseApiController _recommendationCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations); } + /// + /// Gets series with the applied Filter + /// + /// This is considered v1 and no longer used by Kavita, but will be supported for sometime. See series/v2 + /// + /// + /// + /// [HttpPost] + [Obsolete("use v2")] public async Task>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); @@ -70,6 +81,30 @@ public class SeriesController : BaseApiController return Ok(series); } + /// + /// Gets series with the applied Filter + /// + /// + /// + /// + [HttpPost("v2")] + public async Task>> GetSeriesForLibraryV2([FromQuery] UserParams userParams, [FromBody] FilterV2Dto filterDto) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var series = + await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto); + + //TODO: We might want something like libraryId as source so that I don't have to muck with the groups + + // Apply progress/rating information (I can't work out how to do this in initial query) + if (series == null) return BadRequest("Could not get series for library"); + + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + + return Ok(series); + } + /// /// Fetches a Series for a given Id /// @@ -207,7 +242,7 @@ public class SeriesController : BaseApiController } /// - /// Gets all recently added series + /// Gets all recently added series. Obsolete, use recently-added-v2 /// /// /// @@ -215,6 +250,7 @@ public class SeriesController : BaseApiController /// [ResponseCache(CacheProfileName = "Instant")] [HttpPost("recently-added")] + [Obsolete("use recently-added-v2")] public async Task>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); @@ -231,6 +267,30 @@ public class SeriesController : BaseApiController return Ok(series); } + /// + /// Gets all recently added series + /// + /// + /// + /// + [ResponseCache(CacheProfileName = "Instant")] + [HttpPost("recently-added-v2")] + public async Task>> GetRecentlyAddedV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var series = + await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, userParams, filterDto); + + // Apply progress/rating information (I can't work out how to do this in initial query) + if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series")); + + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + + return Ok(series); + } + /// /// Returns series that were recently updated, like adding or removing a chapter /// @@ -251,11 +311,11 @@ public class SeriesController : BaseApiController /// /// [HttpPost("all")] - public async Task>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) + public async Task>> GetAllSeries(FilterV2Dto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var series = - await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); + await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto); // Apply progress/rating information (I can't work out how to do this in initial query) if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series")); @@ -270,16 +330,15 @@ public class SeriesController : BaseApiController /// /// Fetches series that are on deck aka have progress on them. /// - /// /// /// Default of 0 meaning all libraries /// [ResponseCache(CacheProfileName = "Instant")] [HttpPost("on-deck")] - public async Task>> GetOnDeck(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) + public async Task>> GetOnDeck([FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, filterDto); + var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, null); await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, pagedList); @@ -288,6 +347,7 @@ public class SeriesController : BaseApiController return Ok(pagedList); } + /// /// Removes a series from displaying on deck until the next read event on that series /// diff --git a/API/Controllers/WantToReadController.cs b/API/Controllers/WantToReadController.cs index 563a55995..3fb33a822 100644 --- a/API/Controllers/WantToReadController.cs +++ b/API/Controllers/WantToReadController.cs @@ -1,9 +1,11 @@ -using System.Linq; +using System; +using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; using API.DTOs.WantToRead; using API.Extensions; using API.Helpers; @@ -33,12 +35,13 @@ public class WantToReadController : BaseApiController } /// - /// Return all Series that are in the current logged in user's Want to Read list, filtered + /// Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2) /// /// /// /// [HttpPost] + [Obsolete("use v2 instead")] public async Task>> GetWantToRead([FromQuery] UserParams userParams, FilterDto filterDto) { userParams ??= new UserParams(); @@ -50,6 +53,24 @@ public class WantToReadController : BaseApiController return Ok(pagedList); } + /// + /// Return all Series that are in the current logged in user's Want to Read list, filtered + /// + /// + /// + /// + [HttpPost("v2")] + public async Task>> GetWantToReadV2([FromQuery] UserParams userParams, FilterV2Dto filterDto) + { + userParams ??= new UserParams(); + var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserV2Async(User.GetUserId(), userParams, filterDto); + Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); + + await _unitOfWork.SeriesRepository.AddSeriesModifiers(User.GetUserId(), pagedList); + + return Ok(pagedList); + } + [HttpGet] public async Task> IsSeriesInWantToRead([FromQuery] int seriesId) { diff --git a/API/DTOs/Filtering/v2/FilterCombination.cs b/API/DTOs/Filtering/v2/FilterCombination.cs new file mode 100644 index 000000000..d011cb000 --- /dev/null +++ b/API/DTOs/Filtering/v2/FilterCombination.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.Filtering.v2; + +public enum FilterCombination +{ + Or = 0, + And = 1 +} diff --git a/API/DTOs/Filtering/v2/FilterComparision.cs b/API/DTOs/Filtering/v2/FilterComparision.cs new file mode 100644 index 000000000..6e3dc6fd8 --- /dev/null +++ b/API/DTOs/Filtering/v2/FilterComparision.cs @@ -0,0 +1,51 @@ +using System.ComponentModel; + +namespace API.DTOs.Filtering.v2; + +public enum FilterComparison +{ + [Description("Equal")] + Equal = 0, + GreaterThan = 1, + GreaterThanEqual = 2, + LessThan = 3, + LessThanEqual = 4, + /// + /// + /// + /// Only works with IList + Contains = 5, + /// + /// Performs a LIKE %value% + /// + Matches = 6, + NotContains = 7, + /// + /// Not Equal to + /// + NotEqual = 9, + /// + /// String starts with + /// + BeginsWith = 10, + /// + /// String ends with + /// + EndsWith = 11, + /// + /// Is Date before X + /// + IsBefore = 12, + /// + /// Is Date after X + /// + IsAfter = 13, + /// + /// Is Date between now and X seconds ago + /// + IsInLast = 14, + /// + /// Is Date not between now and X seconds ago + /// + IsNotInLast = 15, +} diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs new file mode 100644 index 000000000..a780013ce --- /dev/null +++ b/API/DTOs/Filtering/v2/FilterField.cs @@ -0,0 +1,32 @@ +namespace API.DTOs.Filtering.v2; + +/// +/// Represents the field which will dictate the value type and the Extension used for filtering +/// +public enum FilterField +{ + Summary = 0, + SeriesName = 1, + PublicationStatus = 2, + Languages = 3, + AgeRating = 4, + UserRating = 5, + Tags = 6, + CollectionTags = 7, + Translators = 8, + Characters = 9, + Publisher = 10, + Editor = 11, + CoverArtist = 12, + Letterer = 13, + Colorist = 14, + Inker = 15, + Penciller = 16, + Writers = 17, + Genres = 18, + Libraries = 19, + ReadProgress = 20, + Formats = 21, + ReleaseYear = 22, + ReadTime = 23 +} diff --git a/API/DTOs/Filtering/v2/FilterStatementDto.cs b/API/DTOs/Filtering/v2/FilterStatementDto.cs new file mode 100644 index 000000000..a6192093e --- /dev/null +++ b/API/DTOs/Filtering/v2/FilterStatementDto.cs @@ -0,0 +1,8 @@ +namespace API.DTOs.Filtering.v2; + +public class FilterStatementDto +{ + public FilterComparison Comparison { get; set; } + public FilterField Field { get; set; } + public string Value { get; set; } +} diff --git a/API/DTOs/Filtering/v2/FilterV2Dto.cs b/API/DTOs/Filtering/v2/FilterV2Dto.cs new file mode 100644 index 000000000..2dff500f7 --- /dev/null +++ b/API/DTOs/Filtering/v2/FilterV2Dto.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + + +namespace API.DTOs.Filtering.v2; + + + +/// +/// Metadata filtering for v2 API only +/// +public class FilterV2Dto +{ + /// + /// The name of the filter + /// + public string? Name { get; set; } + public ICollection Statements { get; set; } = new List(); + public FilterCombination Combination { get; set; } = FilterCombination.And; + public SortOptions 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/SeriesDto.cs b/API/DTOs/SeriesDto.cs index 59b7708cb..a8ec37d9c 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -12,7 +12,6 @@ public class SeriesDto : IHasReadTimeEstimate public string? OriginalName { get; init; } public string? LocalizedName { get; init; } public string? SortName { get; init; } - public string? Summary { get; init; } public int Pages { get; init; } public bool CoverImageLocked { get; set; } /// diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index 065ce4a0f..db139b865 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -11,6 +11,7 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; using Kavita.Common.Extensions; @@ -45,8 +46,7 @@ public interface ILibraryRepository Task GetTotalFiles(); IEnumerable GetJumpBarAsync(int libraryId); Task> GetAllAgeRatingsDtosForLibrariesAsync(List libraryIds); - Task> GetAllLanguagesForLibrariesAsync(List libraryIds); - Task> GetAllLanguagesForLibrariesAsync(); + Task> GetAllLanguagesForLibrariesAsync(List? libraryIds); IEnumerable GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds); Task DoAnySeriesFoldersMatch(IEnumerable folders); Task GetLibraryCoverImageAsync(int libraryId); @@ -260,10 +260,10 @@ public class LibraryRepository : ILibraryRepository .ToListAsync(); } - public async Task> GetAllLanguagesForLibrariesAsync(List libraryIds) + public async Task> GetAllLanguagesForLibrariesAsync(List? libraryIds) { var ret = await _context.Series - .Where(s => libraryIds.Contains(s.LibraryId)) + .WhereIf(libraryIds is {Count: > 0} , s => libraryIds.Contains(s.LibraryId)) .Select(s => s.Metadata.Language) .AsSplitQuery() .AsNoTracking() @@ -272,33 +272,33 @@ public class LibraryRepository : ILibraryRepository return ret .Where(s => !string.IsNullOrEmpty(s)) - .Select(s => new LanguageDto() - { - Title = CultureInfo.GetCultureInfo(s).DisplayName, - IsoCode = s - }) + .DistinctBy(Parser.Normalize) + .Select(GetCulture) + .Where(s => s != null) .OrderBy(s => s.Title) .ToList(); } - public async Task> GetAllLanguagesForLibrariesAsync() + private static LanguageDto GetCulture(string s) { - var ret = await _context.Series - .Select(s => s.Metadata.Language) - .AsSplitQuery() - .AsNoTracking() - .Distinct() - .ToListAsync(); - - return ret - .Where(s => !string.IsNullOrEmpty(s)) - .Select(s => new LanguageDto() + try + { + return new LanguageDto() { Title = CultureInfo.GetCultureInfo(s).DisplayName, IsoCode = s - }) - .OrderBy(s => s.Title) - .ToList(); + }; + } + catch (Exception) + { + // ignored + } + + return new LanguageDto() + { + Title = s, + IsoCode = s + };; } public IEnumerable GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds) diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 9a5534b94..b261ec270 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Drawing; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -10,6 +9,7 @@ using API.Data.Scanner; using API.DTOs; using API.DTOs.CollectionTags; using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; using API.DTOs.Metadata; using API.DTOs.ReadingLists; using API.DTOs.Search; @@ -20,7 +20,9 @@ using API.Entities.Enums; using API.Entities.Metadata; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Extensions.QueryExtensions.Filtering; using API.Helpers; +using API.Helpers.Converters; using API.Services; using API.Services.Tasks; using API.Services.Tasks.Scanner; @@ -95,8 +97,9 @@ public interface ISeriesRepository /// Task AddSeriesModifiers(int userId, IList series); Task GetSeriesCoverImageAsync(int seriesId); - Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter); + Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter); Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); + Task> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter); Task GetSeriesMetadata(int seriesId); Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); Task> GetFilesForSeries(int seriesId); @@ -118,6 +121,7 @@ public interface ISeriesRepository Task GetSeriesForMangaFile(int mangaFileId, int userId); Task GetSeriesForChapter(int chapterId, int userId); Task> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter); + Task> GetWantToReadForUserV2Async(int userId, UserParams userParams, FilterV2Dto filter); Task> GetWantToReadForUserAsync(int userId); Task IsSeriesInWantToRead(int userId, int seriesId); Task GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None); @@ -140,6 +144,7 @@ public interface ISeriesRepository Task GetAverageUserRating(int seriesId, int userId); Task RemoveFromOnDeck(int seriesId, int userId); Task ClearOnDeckRemoval(int seriesId, int userId); + Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto); } public class SeriesRepository : ISeriesRepository @@ -300,6 +305,7 @@ public class SeriesRepository : ISeriesRepository /// /// /// + [Obsolete("Use GetSeriesDtoForLibraryIdAsync")] public async Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter) { var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.None); @@ -605,6 +611,18 @@ public class SeriesRepository : ISeriesRepository return await query.ToListAsync(); } + public async Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto) + { + var query = await CreateFilteredSearchQueryableV2(userId, filterDto, QueryContext.None); + + var retSeries = query + .ProjectTo(_mapper.ConfigurationProvider) + .AsSplitQuery() + .AsNoTracking(); + + return await PagedList.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); + } + public async Task AddSeriesModifiers(int userId, IList series) { @@ -644,7 +662,6 @@ public class SeriesRepository : ISeriesRepository } - /// /// Returns a list of Series that were added, ordered by Created desc /// @@ -653,6 +670,7 @@ public class SeriesRepository : ISeriesRepository /// Contains pagination information /// Optional filter on query /// + [Obsolete("Use GetRecentlyAddedV2")] public async Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter) { var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.Dashboard); @@ -666,6 +684,19 @@ public class SeriesRepository : ISeriesRepository return await PagedList.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); } + public async Task> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter) + { + var query = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.Dashboard); + + var retSeries = query + .OrderByDescending(s => s.Created) + .ProjectTo(_mapper.ConfigurationProvider) + .AsSplitQuery() + .AsNoTracking(); + + return await PagedList.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); + } + private IList ExtractFilters(int libraryId, int userId, FilterDto filter, ref List userLibraries, out List allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter, out bool hasRatingFilter, out bool hasProgressFilter, out IList seriesIds, out bool hasAgeRating, out bool hasTagsFilter, @@ -759,7 +790,7 @@ public class SeriesRepository : ISeriesRepository /// Pagination information /// Optional (default null) filter on query /// - public async Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter) + public async Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter) { var settings = await _context.ServerSetting .Select(x => x) @@ -780,11 +811,6 @@ public class SeriesRepository : ISeriesRepository .Select(d => d.SeriesId) .AsEnumerable(); - // var onDeckRemovals = _context.AppUser.Where(u => u.Id == userId) - // .SelectMany(u => u.OnDeckRemovals.Select(d => d.Id)) - // .AsEnumerable(); - - var query = _context.Series .Where(s => usersSeriesIds.Contains(s.Id)) .Where(s => !onDeckRemovals.Contains(s.Id)) @@ -814,6 +840,7 @@ public class SeriesRepository : ISeriesRepository private async Task> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, QueryContext queryContext) { // NOTE: Why do we even have libraryId when the filter has the actual libraryIds? + // TODO: Remove this method var userLibraries = await GetUserLibrariesForFilteredQuery(libraryId, userId, queryContext); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); var onlyParentSeries = await _context.AppUserPreferences.Where(u => u.AppUserId == userId) @@ -828,29 +855,47 @@ public class SeriesRepository : ISeriesRepository var query = _context.Series .AsNoTracking() - .WhereIf(hasGenresFilter, s => s.Metadata.Genres.Any(g => filter.Genres.Contains(g.Id))) - .WhereIf(hasPeopleFilter, s => s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id))) - .WhereIf(hasCollectionTagFilter, - s => s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id))) - .WhereIf(hasRatingFilter, s => s.Ratings.Any(r => r.Rating >= filter.Rating && r.AppUserId == userId)) - .WhereIf(hasProgressFilter, s => seriesIds.Contains(s.Id)) - .WhereIf(hasAgeRating, s => filter.AgeRating.Contains(s.Metadata.AgeRating)) - .WhereIf(hasTagsFilter, s => s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id))) - .WhereIf(hasLanguageFilter, s => filter.Languages.Contains(s.Metadata.Language)) - .WhereIf(hasReleaseYearMinFilter, s => s.Metadata.ReleaseYear >= filter.ReleaseYearRange!.Min) - .WhereIf(hasReleaseYearMaxFilter, s => s.Metadata.ReleaseYear <= filter.ReleaseYearRange!.Max) - .WhereIf(hasPublicationFilter, s => filter.PublicationStatus.Contains(s.Metadata.PublicationStatus)) - .WhereIf(hasSeriesNameFilter, s => EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%") - || EF.Functions.Like(s.OriginalName!, $"%{filter.SeriesNameQuery}%") - || EF.Functions.Like(s.LocalizedName!, $"%{filter.SeriesNameQuery}%")) + // This new style can handle any filterComparision coming from the user + .HasLanguage(hasLanguageFilter, FilterComparison.Contains, filter.Languages) + .HasReleaseYear(hasReleaseYearMaxFilter, FilterComparison.LessThanEqual, filter.ReleaseYearRange?.Max) + .HasReleaseYear(hasReleaseYearMinFilter, FilterComparison.GreaterThanEqual, filter.ReleaseYearRange?.Min) + .HasName(hasSeriesNameFilter, FilterComparison.Matches, filter.SeriesNameQuery) + .HasRating(hasRatingFilter, FilterComparison.GreaterThanEqual, filter.Rating, userId) + .HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating) + .HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus) + .HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags) + .HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags) + .HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres) + .HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!) + .HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0) + + // This needs different treatment + .HasPeople(hasPeopleFilter, FilterComparison.Contains, allPeopleIds) .WhereIf(onlyParentSeries, s => s.RelationOf.Count == 0 || s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel)) - .Where(s => userLibraries.Contains(s.LibraryId)) - .Where(s => formats.Contains(s.Format)); + .Where(s => userLibraries.Contains(s.LibraryId)); + + if (filter.ReadStatus.InProgress) + { + query = query.HasReadingProgress(hasProgressFilter, FilterComparison.GreaterThan, + 0, userId) + .HasReadingProgress(hasProgressFilter, FilterComparison.LessThan, + 100, userId); + } else if (filter.ReadStatus.Read) + { + query = query.HasReadingProgress(hasProgressFilter, FilterComparison.Equal, + 100, userId); + } + else if (filter.ReadStatus.NotRead) + { + query = query.HasReadingProgress(hasProgressFilter, FilterComparison.Equal, + 0, userId); + } if (userRating.AgeRating != AgeRating.NotApplicable) { + // this if statement is included in the extension query = query.RestrictAgainstAgeRestriction(userRating); } @@ -889,7 +934,109 @@ public class SeriesRepository : ISeriesRepository }; } - return query; + return query.AsSplitQuery(); + } + + private async Task> CreateFilteredSearchQueryableV2(int userId, FilterV2Dto filter, QueryContext queryContext, IQueryable? query = null) + { + // NOTE: Why do we even have libraryId when the filter has the actual libraryIds? + var userLibraries = await GetUserLibrariesForFilteredQuery(0, userId, queryContext); + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); + var onlyParentSeries = await _context.AppUserPreferences.Where(u => u.AppUserId == userId) + .Select(u => u.CollapseSeriesRelationships) + .SingleOrDefaultAsync(); + + query ??= _context.Series + .AsNoTracking(); + + var filterLibs = new List(); + + // First setup any FilterField.Libraries in the statements, as these don't have any traditional query statements applied here + if (filter.Statements != null) + { + foreach (var stmt in filter.Statements.Where(stmt => stmt.Field == FilterField.Libraries)) + { + filterLibs.Add(int.Parse(stmt.Value)); + } + + // Remove as filterLibs now has everything + filter.Statements = filter.Statements.Where(stmt => stmt.Field != FilterField.Libraries).ToList(); + } + + + query = BuildFilterQuery(userId, filter, query); + + query = query + .WhereIf(userLibraries.Count > 0, s => userLibraries.Contains(s.LibraryId)) + .WhereIf(filterLibs.Count > 0, s => filterLibs.Contains(s.LibraryId)) + .WhereIf(onlyParentSeries, s => + s.RelationOf.Count == 0 || + s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel)); + + if (userRating.AgeRating != AgeRating.NotApplicable) + { + // this if statement is included in the extension + query = query.RestrictAgainstAgeRestriction(userRating); + } + + return ApplyLimit(query + .Sort(filter.SortOptions) + .AsSplitQuery(), filter.LimitTo); + } + + private static IQueryable BuildFilterQuery(int userId, FilterV2Dto filterDto, IQueryable query) + { + if (filterDto.Statements == null || !filterDto.Statements.Any()) return query; + + + var queries = filterDto.Statements + .Select(statement => BuildFilterGroup(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 ApplyLimit(IQueryable query, int limit) + { + return limit <= 0 ? query : query.Take(limit); + } + + private static IQueryable BuildFilterGroup(int userId, FilterStatementDto statement, IQueryable query) + { + var (value, _) = FilterFieldValueConverter.ConvertValue(statement.Field, statement.Value); + return statement.Field switch + { + FilterField.Summary => query.HasSummary(true, statement.Comparison, (string) value), + FilterField.SeriesName => query.HasName(true, statement.Comparison, (string) value), + FilterField.PublicationStatus => query.HasPublicationStatus(true, statement.Comparison, + (IList) value), + FilterField.Languages => query.HasLanguage(true, statement.Comparison, (IList) value), + FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList) value), + FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId), + FilterField.Tags => query.HasTags(true, statement.Comparison, (IList) value), + FilterField.CollectionTags => query.HasCollectionTags(true, statement.Comparison, (IList) value), + FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList) value), + FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList) value), + FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList) value), + FilterField.Editor => query.HasPeople(true, statement.Comparison, (IList) value), + FilterField.CoverArtist => query.HasPeople(true, statement.Comparison, (IList) value), + FilterField.Letterer => query.HasPeople(true, statement.Comparison, (IList) value), + FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList) value), + FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList) value), + FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList) value), + FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList) value), + FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList) value), + FilterField.Libraries => + // This is handled in the code before this as it's handled in a more general, combined manner + query, + FilterField.ReadProgress => query.HasReadingProgress(true, statement.Comparison, (int) value, userId), + FilterField.Formats => query.HasFormat(true, statement.Comparison, (IList) value), + FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value), + FilterField.ReadTime => query.HasAverageReadTime(true, statement.Comparison, (int) value), + _ => throw new ArgumentOutOfRangeException() + }; } private async Task> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, IQueryable sQuery) @@ -919,41 +1066,10 @@ public class SeriesRepository : ISeriesRepository || EF.Functions.Like(s.LocalizedName!, $"%{filter.SeriesNameQuery}%")) .Where(s => userLibraries.Contains(s.LibraryId) && formats.Contains(s.Format)) + .Sort(filter.SortOptions) .AsNoTracking(); - // If no sort options, default to using SortName - filter.SortOptions ??= new SortOptions() - { - IsAscending = true, - SortField = SortField.SortName - }; - - if (filter.SortOptions.IsAscending) - { - query = filter.SortOptions.SortField switch - { - SortField.SortName => query.OrderBy(s => s.SortName!.ToLower()), - SortField.CreatedDate => query.OrderBy(s => s.Created), - SortField.LastModifiedDate => query.OrderBy(s => s.LastModified), - SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded), - SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead), - _ => query - }; - } - else - { - query = filter.SortOptions.SortField switch - { - SortField.SortName => query.OrderByDescending(s => s.SortName!.ToLower()), - SortField.CreatedDate => query.OrderByDescending(s => s.Created), - SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified), - SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded), - SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead), - _ => query - }; - } - - return query; + return query.AsSplitQuery(); } public async Task GetSeriesMetadata(int seriesId) @@ -1615,6 +1731,7 @@ public class SeriesRepository : ISeriesRepository .AsEnumerable(); } + [Obsolete("Use GetWantToReadForUserV2Async")] public async Task> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter) { var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); @@ -1630,6 +1747,21 @@ public class SeriesRepository : ISeriesRepository return await PagedList.CreateAsync(filteredQuery.ProjectTo(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize); } + public async Task> GetWantToReadForUserV2Async(int userId, UserParams userParams, FilterV2Dto filter) + { + var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); + var query = _context.AppUser + .Where(user => user.Id == userId) + .SelectMany(u => u.WantToRead) + .Where(s => libraryIds.Contains(s.LibraryId)) + .AsSplitQuery() + .AsNoTracking(); + + var filteredQuery = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.None, query); + + return await PagedList.CreateAsync(filteredQuery.ProjectTo(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize); + } + public async Task> GetWantToReadForUserAsync(int userId) { var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 2db949794..b2caa9d89 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -7,6 +7,7 @@ using API.Constants; using API.DTOs; using API.DTOs.Account; using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; using API.DTOs.Reader; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; @@ -53,7 +54,7 @@ public interface IUserRepository Task> GetBookmarkDtosForSeries(int userId, int seriesId); Task> GetBookmarkDtosForVolume(int userId, int volumeId); Task> GetBookmarkDtosForChapter(int userId, int chapterId); - Task> GetAllBookmarkDtos(int userId, FilterDto filter); + Task> GetAllBookmarkDtos(int userId, FilterV2Dto filter); Task> GetAllBookmarksAsync(); Task GetBookmarkForPage(int page, int chapterId, int userId); Task GetBookmarkAsync(int bookmarkId); @@ -374,29 +375,71 @@ public class UserRepository : IUserRepository /// /// Only supports SeriesNameQuery /// - public async Task> GetAllBookmarkDtos(int userId, FilterDto filter) + public async Task> GetAllBookmarkDtos(int userId, FilterV2Dto filter) { var query = _context.AppUserBookmark .Where(x => x.AppUserId == userId) .OrderBy(x => x.Created) .AsNoTracking(); - if (string.IsNullOrEmpty(filter.SeriesNameQuery)) + var filterStatement = filter.Statements.FirstOrDefault(f => f.Field == FilterField.SeriesName); + if (filterStatement == null || string.IsNullOrWhiteSpace(filterStatement.Value)) return await query .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - var seriesNameQueryNormalized = filter.SeriesNameQuery.ToNormalized(); + var queryString = filterStatement.Value.ToNormalized(); var filterSeriesQuery = query.Join(_context.Series, b => b.SeriesId, s => s.Id, (bookmark, series) => new { bookmark, series - }) - .Where(o => (EF.Functions.Like(o.series.Name, $"%{filter.SeriesNameQuery}%")) - || (o.series.OriginalName != null && EF.Functions.Like(o.series.OriginalName, $"%{filter.SeriesNameQuery}%")) - || (o.series.LocalizedName != null && EF.Functions.Like(o.series.LocalizedName, $"%{filter.SeriesNameQuery}%")) - || (EF.Functions.Like(o.series.NormalizedName, $"%{seriesNameQueryNormalized}%")) - ); + }); + + switch (filterStatement.Comparison) + { + case FilterComparison.Equal: + filterSeriesQuery = filterSeriesQuery.Where(s => s.series.Name.Equals(queryString) + || s.series.OriginalName.Equals(queryString) + || s.series.LocalizedName.Equals(queryString) + || s.series.SortName.Equals(queryString)); + break; + case FilterComparison.BeginsWith: + filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.series.Name, $"{queryString}%") + ||EF.Functions.Like(s.series.OriginalName, $"{queryString}%") + || EF.Functions.Like(s.series.LocalizedName, $"{queryString}%") + || EF.Functions.Like(s.series.SortName, $"{queryString}%")); + break; + case FilterComparison.EndsWith: + filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.series.Name, $"%{queryString}") + ||EF.Functions.Like(s.series.OriginalName, $"%{queryString}") + || EF.Functions.Like(s.series.LocalizedName, $"%{queryString}") + || EF.Functions.Like(s.series.SortName, $"%{queryString}")); + break; + case FilterComparison.Matches: + filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.series.Name, $"%{queryString}%") + ||EF.Functions.Like(s.series.OriginalName, $"%{queryString}%") + || EF.Functions.Like(s.series.LocalizedName, $"%{queryString}%") + || EF.Functions.Like(s.series.SortName, $"%{queryString}%")); + break; + case FilterComparison.NotEqual: + filterSeriesQuery = filterSeriesQuery.Where(s => s.series.Name != queryString + || s.series.OriginalName != queryString + || s.series.LocalizedName != queryString + || s.series.SortName != queryString); + break; + case FilterComparison.NotContains: + case FilterComparison.GreaterThan: + case FilterComparison.GreaterThanEqual: + case FilterComparison.LessThan: + case FilterComparison.LessThanEqual: + case FilterComparison.Contains: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + default: + break; + } query = filterSeriesQuery.Select(o => o.bookmark); diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index f6c2844d9..3bfaf9e10 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -83,6 +83,7 @@ public static class ApplicationServiceExtensions options.UseInMemory(EasyCacheProfiles.License); options.UseInMemory(EasyCacheProfiles.Library); options.UseInMemory(EasyCacheProfiles.RevokedJwt); + options.UseInMemory(EasyCacheProfiles.Filter); // KavitaPlus stuff options.UseInMemory(EasyCacheProfiles.KavitaPlusReviews); diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs new file mode 100644 index 000000000..33a733d2a --- /dev/null +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -0,0 +1,515 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using API.DTOs.Filtering.v2; +using API.Entities; +using API.Entities.Enums; +using Kavita.Common; +using Microsoft.EntityFrameworkCore; + +namespace API.Extensions.QueryExtensions.Filtering; + +#nullable enable + +public static class SeriesFilter +{ + + public static IQueryable HasLanguage(this IQueryable queryable, bool condition, + FilterComparison comparison, IList languages) + { + if (languages.Count == 0 || !condition) return queryable; + + switch (comparison) + { + case FilterComparison.Equal: + return queryable.Where(s => s.Metadata.Language.Equals(languages.First())); + case FilterComparison.Contains: + return queryable.Where(s => languages.Contains(s.Metadata.Language)); + case FilterComparison.NotContains: + return queryable.Where(s => !languages.Contains(s.Metadata.Language)); + case FilterComparison.NotEqual: + return queryable.Where(s => !s.Metadata.Language.Equals(languages.First())); + case FilterComparison.Matches: + return queryable.Where(s => EF.Functions.Like(s.Metadata.Language, $"{languages.First()}%")); + case FilterComparison.GreaterThan: + case FilterComparison.GreaterThanEqual: + case FilterComparison.LessThan: + case FilterComparison.LessThanEqual: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + } + + public static IQueryable HasReleaseYear(this IQueryable queryable, bool condition, + FilterComparison comparison, int? releaseYear) + { + if (!condition || releaseYear == null) return queryable; + + switch (comparison) + { + case FilterComparison.Equal: + return queryable.Where(s => s.Metadata.ReleaseYear == releaseYear); + case FilterComparison.GreaterThan: + case FilterComparison.IsAfter: + return queryable.Where(s => s.Metadata.ReleaseYear > releaseYear); + case FilterComparison.GreaterThanEqual: + return queryable.Where(s => s.Metadata.ReleaseYear >= releaseYear); + case FilterComparison.LessThan: + case FilterComparison.IsBefore: + return queryable.Where(s => s.Metadata.ReleaseYear < releaseYear); + case FilterComparison.LessThanEqual: + return queryable.Where(s => s.Metadata.ReleaseYear <= releaseYear); + case FilterComparison.IsInLast: + return queryable.Where(s => s.Metadata.ReleaseYear >= DateTime.Now.Year - (int) releaseYear); + case FilterComparison.IsNotInLast: + return queryable.Where(s => s.Metadata.ReleaseYear < DateTime.Now.Year - (int) releaseYear); + case FilterComparison.Matches: + case FilterComparison.Contains: + case FilterComparison.NotContains: + case FilterComparison.NotEqual: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + throw new KavitaException($"{comparison} not applicable for Series.ReleaseYear"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + } + + + public static IQueryable HasRating(this IQueryable queryable, bool condition, + FilterComparison comparison, int rating, int userId) + { + if (rating < 0 || !condition || userId <= 0) return queryable; + + switch (comparison) + { + case FilterComparison.Equal: + return queryable.Where(s => s.Ratings.Any(r => r.Rating == rating && r.AppUserId == userId)); + case FilterComparison.GreaterThan: + return queryable.Where(s => s.Ratings.Any(r => r.Rating > rating && r.AppUserId == userId)); + case FilterComparison.GreaterThanEqual: + return queryable.Where(s => s.Ratings.Any(r => r.Rating >= rating && r.AppUserId == userId)); + case FilterComparison.LessThan: + return queryable.Where(s => s.Ratings.Any(r => r.Rating < rating && r.AppUserId == userId)); + case FilterComparison.LessThanEqual: + return queryable.Where(s => s.Ratings.Any(r => r.Rating <= rating && r.AppUserId == userId)); + case FilterComparison.Contains: + case FilterComparison.Matches: + case FilterComparison.NotContains: + case FilterComparison.NotEqual: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + throw new KavitaException($"{comparison} not applicable for Series.Rating"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + } + + public static IQueryable HasAgeRating(this IQueryable queryable, bool condition, + FilterComparison comparison, IList ratings) + { + if (!condition || ratings.Count == 0) return queryable; + + var firstRating = ratings.First(); + switch (comparison) + { + case FilterComparison.Equal: + return queryable.Where(s => s.Metadata.AgeRating == firstRating); + case FilterComparison.GreaterThan: + return queryable.Where(s => s.Metadata.AgeRating > firstRating); + case FilterComparison.GreaterThanEqual: + return queryable.Where(s => s.Metadata.AgeRating >= firstRating); + case FilterComparison.LessThan: + return queryable.Where(s => s.Metadata.AgeRating < firstRating); + case FilterComparison.LessThanEqual: + return queryable.Where(s => s.Metadata.AgeRating <= firstRating); + case FilterComparison.Contains: + return queryable.Where(s => ratings.Contains(s.Metadata.AgeRating)); + case FilterComparison.NotContains: + return queryable.Where(s => !ratings.Contains(s.Metadata.AgeRating)); + case FilterComparison.NotEqual: + return queryable.Where(s => s.Metadata.AgeRating != firstRating); + case FilterComparison.Matches: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + throw new KavitaException($"{comparison} not applicable for Series.AgeRating"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + } + public static IQueryable HasAverageReadTime(this IQueryable queryable, bool condition, + FilterComparison comparison, int avgReadTime) + { + if (!condition || avgReadTime < 0) return queryable; + + switch (comparison) + { + case FilterComparison.NotEqual: + return queryable.Where(s => s.AvgHoursToRead != avgReadTime); + case FilterComparison.Equal: + return queryable.Where(s => s.AvgHoursToRead == avgReadTime); + case FilterComparison.GreaterThan: + return queryable.Where(s => s.AvgHoursToRead > avgReadTime); + case FilterComparison.GreaterThanEqual: + return queryable.Where(s => s.AvgHoursToRead >= avgReadTime); + case FilterComparison.LessThan: + return queryable.Where(s => s.AvgHoursToRead < avgReadTime); + case FilterComparison.LessThanEqual: + return queryable.Where(s => s.AvgHoursToRead <= avgReadTime); + case FilterComparison.Contains: + case FilterComparison.Matches: + case FilterComparison.NotContains: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + throw new KavitaException($"{comparison} not applicable for Series.AverageReadTime"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + } + + public static IQueryable HasPublicationStatus(this IQueryable queryable, bool condition, + FilterComparison comparison, IList pubStatues) + { + if (!condition || pubStatues.Count == 0) return queryable; + + var firstStatus = pubStatues.First(); + switch (comparison) + { + case FilterComparison.Equal: + return queryable.Where(s => s.Metadata.PublicationStatus == firstStatus); + case FilterComparison.Contains: + return queryable.Where(s => pubStatues.Contains(s.Metadata.PublicationStatus)); + case FilterComparison.NotContains: + return queryable.Where(s => !pubStatues.Contains(s.Metadata.PublicationStatus)); + case FilterComparison.NotEqual: + return queryable.Where(s => s.Metadata.PublicationStatus != firstStatus); + case FilterComparison.GreaterThan: + case FilterComparison.GreaterThanEqual: + case FilterComparison.LessThan: + case FilterComparison.LessThanEqual: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + case FilterComparison.Matches: + throw new KavitaException($"{comparison} not applicable for Series.PublicationStatus"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + } + + /// + /// + /// + /// This is more taxing on memory as the percentage calculation must be done in Memory + /// + /// + public static IQueryable HasReadingProgress(this IQueryable queryable, bool condition, + FilterComparison comparison, int readProgress, int userId) + { + if (!condition) return queryable; + + var subQuery = queryable + .Include(s => s.Progress) + .Where(s => s.Progress != null) + .Select(s => new + { + Series = s, + Percentage = Math.Truncate(((double) s.Progress + .Where(p => p != null && p.AppUserId == userId) + .Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0) * 100)) + }) + .AsEnumerable(); + + switch (comparison) + { + case FilterComparison.Equal: + subQuery = subQuery.Where(s => s.Percentage == readProgress); + break; + case FilterComparison.GreaterThan: + subQuery = subQuery.Where(s => s.Percentage > readProgress); + break; + case FilterComparison.GreaterThanEqual: + subQuery = subQuery.Where(s => s.Percentage >= readProgress); + break; + case FilterComparison.LessThan: + subQuery = subQuery.Where(s => s.Percentage < readProgress); + break; + case FilterComparison.LessThanEqual: + subQuery = subQuery.Where(s => s.Percentage <= readProgress); + break; + case FilterComparison.NotEqual: + subQuery = subQuery.Where(s => s.Percentage != readProgress); + break; + case FilterComparison.Matches: + case FilterComparison.Contains: + case FilterComparison.NotContains: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + throw new KavitaException($"{comparison} not applicable for Series.ReadProgress"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + + var ids = subQuery.Select(s => s.Series.Id).ToList(); + return queryable.Where(s => ids.Contains(s.Id)); + } + + public static IQueryable HasTags(this IQueryable queryable, bool condition, + FilterComparison comparison, IList tags) + { + if (!condition || tags.Count == 0) return queryable; + + switch (comparison) + { + case FilterComparison.Equal: + case FilterComparison.Contains: + return queryable.Where(s => s.Metadata.Tags.Any(t => tags.Contains(t.Id))); + case FilterComparison.NotEqual: + case FilterComparison.NotContains: + return queryable.Where(s => s.Metadata.Tags.Any(t => !tags.Contains(t.Id))); + case FilterComparison.GreaterThan: + case FilterComparison.GreaterThanEqual: + case FilterComparison.LessThan: + case FilterComparison.LessThanEqual: + case FilterComparison.Matches: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + throw new KavitaException($"{comparison} not applicable for Series.Tags"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + } + + public static IQueryable HasPeople(this IQueryable queryable, bool condition, + FilterComparison comparison, IList people) + { + if (!condition || people.Count == 0) return queryable; + + switch (comparison) + { + case FilterComparison.Equal: + case FilterComparison.Contains: + return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.Id))); + case FilterComparison.NotEqual: + case FilterComparison.NotContains: + return queryable.Where(s => s.Metadata.People.Any(t => !people.Contains(t.Id))); + case FilterComparison.GreaterThan: + case FilterComparison.GreaterThanEqual: + case FilterComparison.LessThan: + case FilterComparison.LessThanEqual: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + case FilterComparison.Matches: + throw new KavitaException($"{comparison} not applicable for Series.People"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + } + + public static IQueryable HasGenre(this IQueryable queryable, bool condition, + FilterComparison comparison, IList genres) + { + if (!condition || genres.Count == 0) return queryable; + + switch (comparison) + { + case FilterComparison.Equal: + case FilterComparison.Contains: + return queryable.Where(s => s.Metadata.Genres.Any(p => genres.Contains(p.Id))); + case FilterComparison.NotEqual: + case FilterComparison.NotContains: + return queryable.Where(s => s.Metadata.Genres.All(p => !genres.Contains(p.Id))); + case FilterComparison.GreaterThan: + case FilterComparison.GreaterThanEqual: + case FilterComparison.LessThan: + case FilterComparison.LessThanEqual: + case FilterComparison.Matches: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + throw new KavitaException($"{comparison} not applicable for Series.Genres"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + } + + public static IQueryable HasFormat(this IQueryable queryable, bool condition, + FilterComparison comparison, IList formats) + { + if (!condition || formats.Count == 0) return queryable; + + switch (comparison) + { + case FilterComparison.Equal: + case FilterComparison.Contains: + return queryable.Where(s => formats.Contains(s.Format)); + case FilterComparison.NotContains: + case FilterComparison.NotEqual: + return queryable.Where(s => !formats.Contains(s.Format)); + case FilterComparison.GreaterThan: + case FilterComparison.GreaterThanEqual: + case FilterComparison.LessThan: + case FilterComparison.LessThanEqual: + case FilterComparison.Matches: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + throw new KavitaException($"{comparison} not applicable for Series.Format"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + } + + public static IQueryable HasCollectionTags(this IQueryable queryable, bool condition, + FilterComparison comparison, IList collectionTags) + { + if (!condition || collectionTags.Count == 0) return queryable; + + //var first = collectionTags.First(); + switch (comparison) + { + case FilterComparison.Equal: + case FilterComparison.Contains: + return queryable.Where(s => s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id))); + case FilterComparison.NotContains: + case FilterComparison.NotEqual: + return queryable.Where(s => !s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id))); + case FilterComparison.GreaterThan: + case FilterComparison.GreaterThanEqual: + case FilterComparison.LessThan: + case FilterComparison.LessThanEqual: + case FilterComparison.Matches: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + throw new KavitaException($"{comparison} not applicable for Series.CollectionTags"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + } + + public static IQueryable HasName(this IQueryable queryable, bool condition, + FilterComparison comparison, string queryString) + { + if (string.IsNullOrEmpty(queryString) || !condition) return queryable; + + switch (comparison) + { + case FilterComparison.Equal: + return queryable.Where(s => s.Name.Equals(queryString) + || s.OriginalName.Equals(queryString) + || s.LocalizedName.Equals(queryString) + || s.SortName.Equals(queryString)); + case FilterComparison.BeginsWith: + return queryable.Where(s => EF.Functions.Like(s.Name, $"{queryString}%") + ||EF.Functions.Like(s.OriginalName, $"{queryString}%") + || EF.Functions.Like(s.LocalizedName, $"{queryString}%") + || EF.Functions.Like(s.SortName, $"{queryString}%")); + case FilterComparison.EndsWith: + return queryable.Where(s => EF.Functions.Like(s.Name, $"%{queryString}") + ||EF.Functions.Like(s.OriginalName, $"%{queryString}") + || EF.Functions.Like(s.LocalizedName, $"%{queryString}") + || EF.Functions.Like(s.SortName, $"%{queryString}")); + case FilterComparison.Matches: + return queryable.Where(s => EF.Functions.Like(s.Name, $"%{queryString}%") + ||EF.Functions.Like(s.OriginalName, $"%{queryString}%") + || EF.Functions.Like(s.LocalizedName, $"%{queryString}%") + || EF.Functions.Like(s.SortName, $"%{queryString}%")); + case FilterComparison.NotEqual: + return queryable.Where(s => s.Name != queryString + || s.OriginalName != queryString + || s.LocalizedName != queryString + || s.SortName != queryString); + case FilterComparison.NotContains: + case FilterComparison.GreaterThan: + case FilterComparison.GreaterThanEqual: + case FilterComparison.LessThan: + case FilterComparison.LessThanEqual: + case FilterComparison.Contains: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + throw new KavitaException($"{comparison} not applicable for Series.Name"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"); + } + } + + public static IQueryable HasSummary(this IQueryable queryable, bool condition, + FilterComparison comparison, string queryString) + { + if (string.IsNullOrEmpty(queryString) || !condition) return queryable; + + switch (comparison) + { + case FilterComparison.Equal: + return queryable.Where(s => s.Metadata.Summary.Equals(queryString)); + case FilterComparison.BeginsWith: + return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"{queryString}%")); + case FilterComparison.EndsWith: + return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"%{queryString}")); + case FilterComparison.Matches: + return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"%{queryString}%")); + case FilterComparison.NotEqual: + return queryable.Where(s => s.Metadata.Summary != queryString); + case FilterComparison.NotContains: + case FilterComparison.GreaterThan: + case FilterComparison.GreaterThanEqual: + case FilterComparison.LessThan: + case FilterComparison.LessThanEqual: + case FilterComparison.Contains: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + throw new KavitaException($"{comparison} not applicable for Series.Metadata.Summary"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"); + } + } +} diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs b/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs new file mode 100644 index 000000000..ac075fc21 --- /dev/null +++ b/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs @@ -0,0 +1,53 @@ +using System.Linq; +using API.DTOs.Filtering; +using API.Entities; + +namespace API.Extensions.QueryExtensions.Filtering; + +public static class SeriesSort +{ + /// + /// Applies the correct sort based on + /// + /// + /// + /// + public static IQueryable Sort(this IQueryable query, SortOptions? sortOptions) + { + // If no sort options, default to using SortName + sortOptions ??= new SortOptions() + { + IsAscending = true, + SortField = SortField.SortName + }; + + if (sortOptions.IsAscending) + { + query = sortOptions.SortField switch + { + SortField.SortName => query.OrderBy(s => s.SortName.ToLower()), + SortField.CreatedDate => query.OrderBy(s => s.Created), + SortField.LastModifiedDate => query.OrderBy(s => s.LastModified), + SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded), + SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead), + SortField.ReleaseYear => query.OrderBy(s => s.Metadata.ReleaseYear), + _ => query + }; + } + else + { + query = sortOptions.SortField switch + { + SortField.SortName => query.OrderByDescending(s => s.SortName.ToLower()), + SortField.CreatedDate => query.OrderByDescending(s => s.Created), + SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified), + SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded), + SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead), + SortField.ReleaseYear => query.OrderByDescending(s => s.Metadata.ReleaseYear), + _ => query + }; + } + + return query; + } +} diff --git a/API/Extensions/QueryExtensions/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs index f25ea12f0..c01297d3e 100644 --- a/API/Extensions/QueryExtensions/QueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -110,6 +110,55 @@ public static class QueryableExtensions return condition ? queryable.Where(predicate) : queryable; } + public static IQueryable WhereLike(this IQueryable queryable, bool condition, Expression> propertySelector, string searchQuery) + where T : class + { + if (!condition || string.IsNullOrEmpty(searchQuery)) return queryable; + + var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) }); + var dbFunctions = typeof(EF).GetMethod(nameof(EF.Functions))?.Invoke(null, null); + var searchExpression = Expression.Constant($"%{searchQuery}%"); + var likeExpression = Expression.Call(method, Expression.Constant(dbFunctions), propertySelector.Body, searchExpression); + var lambda = Expression.Lambda>(likeExpression, propertySelector.Parameters[0]); + + return queryable.Where(lambda); + } + + /// + /// Performs a WhereLike that ORs multiple fields + /// + /// + /// + /// + /// + /// + /// + public static IQueryable WhereLike(this IQueryable queryable, bool condition, List>> propertySelectors, string searchQuery) + where T : class + { + if (!condition || string.IsNullOrEmpty(searchQuery)) return queryable; + + var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) }); + var dbFunctions = typeof(EF).GetMethod(nameof(EF.Functions))?.Invoke(null, null); + var searchExpression = Expression.Constant($"%{searchQuery}%"); + + Expression orExpression = null; + foreach (var propertySelector in propertySelectors) + { + var likeExpression = Expression.Call(method, Expression.Constant(dbFunctions), propertySelector.Body, searchExpression); + var lambda = Expression.Lambda>(likeExpression, propertySelector.Parameters[0]); + orExpression = orExpression == null ? lambda.Body : Expression.OrElse(orExpression, lambda.Body); + } + + if (orExpression == null) + { + throw new ArgumentNullException(nameof(orExpression)); + } + + var combinedLambda = Expression.Lambda>(orExpression, propertySelectors[0].Parameters[0]); + return queryable.Where(combinedLambda); + } + public static IQueryable SortBy(this IQueryable query, ScrobbleEventSortField sort, bool isDesc = false) { if (isDesc) diff --git a/API/Helpers/Converters/FilterFieldValueConverter.cs b/API/Helpers/Converters/FilterFieldValueConverter.cs new file mode 100644 index 000000000..226a2d99f --- /dev/null +++ b/API/Helpers/Converters/FilterFieldValueConverter.cs @@ -0,0 +1,76 @@ +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 FilterFieldValueConverter +{ + public static (object Value, Type Type) ConvertValue(FilterField field, string value) + { + return field switch + { + FilterField.SeriesName => (value, typeof(string)), + FilterField.ReleaseYear => (int.Parse(value), typeof(int)), + FilterField.Languages => (value.Split(',').ToList(), typeof(IList)), + FilterField.PublicationStatus => (value.Split(',') + .Select(x => (PublicationStatus) Enum.Parse(typeof(PublicationStatus), x)) + .ToList(), typeof(IList)), + FilterField.Summary => (value, typeof(string)), + FilterField.AgeRating => (value.Split(',') + .Select(x => (AgeRating) Enum.Parse(typeof(AgeRating), x)) + .ToList(), typeof(IList)), + FilterField.UserRating => (int.Parse(value), typeof(int)), + FilterField.Tags => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.CollectionTags => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.Translators => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.Characters => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.Publisher => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.Editor => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.CoverArtist => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.Letterer => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.Colorist => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.Inker => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.Penciller => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.Writers => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.Genres => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.Libraries => (value.Split(',') + .Select(int.Parse) + .ToList(), typeof(IList)), + FilterField.ReadProgress => (int.Parse(value), typeof(int)), + FilterField.Formats => (value.Split(',') + .Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x)) + .ToList(), typeof(IList)), + FilterField.ReadTime => (int.Parse(value), typeof(int)), + _ => throw new ArgumentException("Invalid field type") + }; + } +} diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index e4f997767..bc14967a3 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -35,7 +35,7 @@ public class StatsService : IStatsService private readonly IUnitOfWork _unitOfWork; private readonly DataContext _context; private readonly IStatisticService _statisticService; - private const string ApiUrl = "https://stats.kavitareader.com"; + private const string ApiUrl = "https://stats.kavitareader.com"; // "" public StatsService(ILogger logger, IUnitOfWork unitOfWork, DataContext context, IStatisticService statisticService) { diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs index cce1a0540..b6b0ade47 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -54,7 +54,6 @@ public class TokenService : ITokenService }; var roles = await _userManager.GetRolesAsync(user); - claims.AddRange(roles.Select(role => new Claim(Role, role))); var credentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature); diff --git a/API/Startup.cs b/API/Startup.cs index ed68f050e..5f4ec69d7 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -317,7 +317,7 @@ public class Startup .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials() // For SignalR token query param - .WithOrigins("http://localhost:4200", $"http://{GetLocalIpAddress()}:4200", $"http://{GetLocalIpAddress()}:5000", "https://kavita.majora2007.duckdns.org") + .WithOrigins("http://localhost:4200", $"http://{GetLocalIpAddress()}:4200", $"http://{GetLocalIpAddress()}:5000") .WithExposedHeaders("Content-Disposition", "Pagination")); } else @@ -327,7 +327,6 @@ public class Startup .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials() // For SignalR token query param - .WithOrigins("https://kavita.majora2007.duckdns.org") .WithExposedHeaders("Content-Disposition", "Pagination")); } diff --git a/UI/Web/src/app/_models/metadata/language.ts b/UI/Web/src/app/_models/metadata/language.ts index c88ff3939..e8f606bec 100644 --- a/UI/Web/src/app/_models/metadata/language.ts +++ b/UI/Web/src/app/_models/metadata/language.ts @@ -1,4 +1,5 @@ export interface Language { isoCode: string; title: string; -} \ No newline at end of file +} + diff --git a/UI/Web/src/app/_models/metadata/series-filter.ts b/UI/Web/src/app/_models/metadata/series-filter.ts index 18ef0257c..87f064f2a 100644 --- a/UI/Web/src/app/_models/metadata/series-filter.ts +++ b/UI/Web/src/app/_models/metadata/series-filter.ts @@ -1,4 +1,6 @@ import { MangaFormat } from "../manga-format"; +import { SeriesFilterV2 } from "./v2/series-filter-v2"; +import {FilterField} from "./v2/filter-field"; export interface FilterItem { title: string; @@ -6,38 +8,6 @@ export interface FilterItem { selected: boolean; } -export interface Range { - min: T; - max: T; -} - -export interface SeriesFilter { - formats: Array; - libraries: Array, - readStatus: ReadStatus; - genres: Array; - writers: Array; - artists: Array; - penciller: Array; - inker: Array; - colorist: Array; - letterer: Array; - coverArtist: Array; - editor: Array; - publisher: Array; - character: Array; - translators: Array; - collectionTags: Array; - rating: number; - ageRating: Array; - sortOptions: SortOptions | null; - tags: Array; - languages: Array; - publicationStatus: Array; - seriesNameQuery: string; - releaseYearRange: Range | null; -} - export interface SortOptions { sortField: SortField; isAscending: boolean; @@ -52,11 +22,9 @@ export enum SortField { ReleaseYear = 6, } -export interface ReadStatus { - notRead: boolean, - inProgress: boolean, - read: boolean, -} +export const allSortFields = Object.keys(SortField) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as SortField[]; export const mangaFormatFilters = [ { @@ -82,7 +50,7 @@ export const mangaFormatFilters = [ ]; export interface FilterEvent { - filter: SeriesFilter; + filterV2: SeriesFilterV2; isFirst: boolean; } diff --git a/UI/Web/src/app/_models/metadata/v2/filter-combination.ts b/UI/Web/src/app/_models/metadata/v2/filter-combination.ts new file mode 100644 index 000000000..05f1ee668 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/filter-combination.ts @@ -0,0 +1,4 @@ +export enum FilterCombination { + Or = 0, + And = 1 +} diff --git a/UI/Web/src/app/_models/metadata/v2/filter-comparison.ts b/UI/Web/src/app/_models/metadata/v2/filter-comparison.ts new file mode 100644 index 000000000..64e5571bd --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/filter-comparison.ts @@ -0,0 +1,45 @@ +export enum FilterComparison { + Equal = 0, + GreaterThan =1, + GreaterThanEqual = 2, + LessThan = 3, + LessThanEqual = 4, + /// + /// + /// + /// Only works with IList + Contains = 5, + /// + /// Performs a LIKE %value% + /// + Matches = 6, + NotContains = 7, + /// + /// Not Equal to + /// + NotEqual = 9, + /// + /// String starts with + /// + BeginsWith = 10, + /// + /// String ends with + /// + EndsWith = 11, + /// + /// Is Date before X + /// + IsBefore = 12, + /// + /// Is Date after X + /// + IsAfter = 13, + /// + /// Is Date between now and X seconds ago + /// + IsInLast = 14, + /// + /// Is Date not between now and X seconds ago + /// + IsNotInLast = 15, +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/metadata/v2/filter-field.ts b/UI/Web/src/app/_models/metadata/v2/filter-field.ts new file mode 100644 index 000000000..0c62af3e4 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/filter-field.ts @@ -0,0 +1,33 @@ +export enum FilterField +{ + None = -1, + Summary = 0, + SeriesName = 1, + PublicationStatus = 2, + Languages = 3, + AgeRating = 4, + UserRating = 5, + Tags = 6, + CollectionTags = 7, + Translators = 8, + Characters = 9, + Publisher = 10, + Editor = 11, + CoverArtist = 12, + Letterer = 13, + Colorist = 14, + Inker = 15, + Penciller = 16, + Writers = 17, + Genres = 18, + Libraries = 19, + ReadProgress = 20, + Formats = 21, + ReleaseYear = 22, + ReadTime = 23 +} + +export const allFields = Object.keys(FilterField) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) + .sort((a, b) => a - b) as FilterField[]; diff --git a/UI/Web/src/app/_models/metadata/v2/filter-statement.ts b/UI/Web/src/app/_models/metadata/v2/filter-statement.ts new file mode 100644 index 000000000..d031927a2 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/filter-statement.ts @@ -0,0 +1,8 @@ +import { FilterComparison } from "./filter-comparison"; +import { FilterField } from "./filter-field"; + +export interface FilterStatement { + comparison: FilterComparison; + field: FilterField; + value: string; +} \ No newline at end of file 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 new file mode 100644 index 000000000..c13244644 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts @@ -0,0 +1,11 @@ +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/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 86cf72618..786a41084 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -55,7 +55,6 @@ export class AccountService { private messageHub: MessageHubService, private themeService: ThemeService) { messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate), map(evt => evt.payload as UserUpdateEvent), - tap(u => console.log('user update: ', u)), filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username), switchMap(() => this.refreshAccount())) .subscribe(() => {}); @@ -307,7 +306,6 @@ export class AccountService { private refreshAccount() { - console.log('Refreshing account'); if (this.currentUser === null || this.currentUser === undefined) return of(); return this.httpClient.get(this.baseUrl + 'account/refresh-account').pipe(map((user: User) => { if (user) { diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index f6660482e..775cc456b 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -92,6 +92,8 @@ export enum Action { * Removes the Series from On Deck inclusion */ RemoveFromOnDeck = 19, + AddRuleGroup = 20, + RemoveRuleGroup = 21 } export interface ActionItem { @@ -178,6 +180,15 @@ export class ActionFactoryService { return this.applyCallbackToList(this.bookmarkActions, callback); } + getMetadataFilterActions(callback: (action: ActionItem, data: any) => void) { + const actions = [ + {title: 'add-rule-group-and', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback}, + {title: 'add-rule-group-or', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback}, + {title: 'remove-rule-group', action: Action.RemoveRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback}, + ]; + return this.applyCallbackToList(actions, callback); + } + dummyCallback(action: ActionItem, data: any) {} filterSendToAction(actions: Array>, chapter: Chapter) { diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index da34eafd5..d5dfd05ff 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -1,16 +1,23 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { of } from 'rxjs'; +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; import {map, tap} from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { Genre } from '../_models/metadata/genre'; -import { AgeRating } from '../_models/metadata/age-rating'; -import { AgeRatingDto } from '../_models/metadata/age-rating-dto'; -import { Language } from '../_models/metadata/language'; -import { PublicationStatusDto } from '../_models/metadata/publication-status-dto'; -import { Person } from '../_models/metadata/person'; -import { Tag } from '../_models/tag'; -import { TextResonse } from '../_types/text-response'; +import {of, ReplaySubject, switchMap} from 'rxjs'; +import {environment} from 'src/environments/environment'; +import {Genre} from '../_models/metadata/genre'; +import {AgeRating} from '../_models/metadata/age-rating'; +import {AgeRatingDto} from '../_models/metadata/age-rating-dto'; +import {Language} from '../_models/metadata/language'; +import {PublicationStatusDto} from '../_models/metadata/publication-status-dto'; +import {Person} from '../_models/metadata/person'; +import {Tag} from '../_models/tag'; +import {TextResonse} from '../_types/text-response'; +import {FilterComparison} from '../_models/metadata/v2/filter-comparison'; +import {FilterField} from '../_models/metadata/v2/filter-field'; +import {Router} from "@angular/router"; +import {SortField} from "../_models/metadata/series-filter"; +import {FilterCombination} from "../_models/metadata/v2/filter-combination"; +import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {FilterStatement} from "../_models/metadata/v2/filter-statement"; @Injectable({ providedIn: 'root' @@ -19,10 +26,37 @@ export class MetadataService { baseUrl = environment.apiUrl; + private currentThemeSource = new ReplaySubject(1); + private ageRatingTypes: {[key: number]: string} | undefined = undefined; private validLanguages: Array = []; - constructor(private httpClient: HttpClient) { } + constructor(private httpClient: HttpClient, private router: Router) { } + + applyFilter(page: Array, filter: FilterField, comparison: FilterComparison, value: string) { + const dto: SeriesFilterV2 = { + statements: [this.createDefaultFilterStatement(filter, comparison, value + '')], + combination: FilterCombination.Or, + limitTo: 0 + }; + // + // console.log('navigating to: ', this.filterUtilityService.urlFromFilterV2(page.join('/'), dto)); + // this.router.navigateByUrl(this.filterUtilityService.urlFromFilterV2(page.join('/'), dto)); + + // Creates a temp name for the filter + this.httpClient.post(this.baseUrl + 'filter/create-temp', dto, TextResonse).pipe(map(name => { + dto.name = name; + }), switchMap((_) => { + let params: any = {}; + params['filterName'] = dto.name; + return this.router.navigate(page, {queryParams: params}); + })).subscribe(); + + } + + getFilter(filterName: string) { + return this.httpClient.get(this.baseUrl + 'filter?name=' + filterName); + } getAgeRating(ageRating: AgeRating) { if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) { @@ -78,6 +112,7 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + method); } + /** * All the potential language tags there can be */ @@ -100,4 +135,30 @@ export class MetadataService { getChapterSummary(chapterId: number) { return this.httpClient.get(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, TextResonse); } + + createDefaultFilterDto(): SeriesFilterV2 { + return { + statements: [] as FilterStatement[], + combination: FilterCombination.And, + limitTo: 0, + sortOptions: { + isAscending: true, + sortField: SortField.SortName + } + }; + } + + createDefaultFilterStatement(field: FilterField = FilterField.SeriesName, comparison = FilterComparison.Equal, value = '') { + return { + comparison: comparison, + field: field, + value: value + }; + } + + updateFilter(arr: Array, index: number, filterStmt: FilterStatement) { + arr[index].comparison = filterStmt.comparison; + arr[index].field = filterStmt.field; + arr[index].value = filterStmt.value + ''; + } } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 17216a90f..7c6547abf 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -10,7 +10,6 @@ import { MangaFormat } from '../_models/manga-format'; import { BookmarkInfo } from '../_models/manga-reader/bookmark-info'; import { PageBookmark } from '../_models/readers/page-bookmark'; import { ProgressBookmark } from '../_models/readers/progress-bookmark'; -import { SeriesFilter } from '../_models/metadata/series-filter'; import { UtilityService } from '../shared/_services/utility.service'; import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service'; import { FileDimension } from '../manga-reader/_models/file-dimension'; @@ -19,6 +18,7 @@ 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"; export const CHAPTER_ID_DOESNT_EXIST = -1; export const CHAPTER_ID_NOT_FETCHED = -2; @@ -70,12 +70,8 @@ export class ReaderService { return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page}); } - getAllBookmarks(filter: SeriesFilter | undefined) { - let params = new HttpParams(); - params = this.utilityService.addPaginationIfExists(params, undefined, undefined); - const data = this.filterUtilityService.createSeriesFilter(filter); - - return this.httpClient.post(this.baseUrl + 'reader/all-bookmarks', data); + getAllBookmarks(filter: SeriesFilterV2 | undefined) { + return this.httpClient.post(this.baseUrl + 'reader/all-bookmarks', filter); } getBookmarks(chapterId: number) { diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 659125e41..34f17b45f 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -12,12 +12,12 @@ 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 { SeriesFilter } from '../_models/metadata/series-filter'; 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 {Rating} from "../_models/rating"; import {Recommendation} from "../_models/series-detail/recommendation"; @@ -32,26 +32,26 @@ export class SeriesService { paginatedSeriesForTagsResults: PaginatedResult = new PaginatedResult(); constructor(private httpClient: HttpClient, private imageService: ImageService, - private utilityService: UtilityService, private filterUtilityService: FilterUtilitiesService) { } + private utilityService: UtilityService) { } - getAllSeries(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { + getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); - const data = this.filterUtilityService.createSeriesFilter(filter); + const data = filter || {}; return this.httpClient.post>(this.baseUrl + 'series/all', data, {observe: 'response', params}).pipe( - map((response: any) => { - return this.utilityService.createPaginatedResult(response, this.paginatedResults); - }) + map((response: any) => { + return this.utilityService.createPaginatedResult(response, this.paginatedResults); + }) ); } - getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { + getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); - const data = this.filterUtilityService.createSeriesFilter(filter); + const data = filter || {}; - return this.httpClient.post>(this.baseUrl + 'series?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( + return this.httpClient.post>(this.baseUrl + 'series/v2', data, {observe: 'response', params}).pipe( map((response: any) => { return this.utilityService.createPaginatedResult(response, this.paginatedResults); }) @@ -102,12 +102,12 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'reader/mark-unread', {seriesId}); } - getRecentlyAdded(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { - const data = this.filterUtilityService.createSeriesFilter(filter); + getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); - return this.httpClient.post(this.baseUrl + 'series/recently-added?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( + const data = filter || {}; + return this.httpClient.post(this.baseUrl + 'series/recently-added-v2', data, {observe: 'response', params}).pipe( map(response => { return this.utilityService.createPaginatedResult(response, new PaginatedResult()); }) @@ -118,13 +118,12 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'series/recently-updated-series', {}); } - getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter): Observable> { - const data = this.filterUtilityService.createSeriesFilter(filter); - + getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2): Observable> { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + const data = filter || {}; - return this.httpClient.post(this.baseUrl + 'want-to-read/', data, {observe: 'response', params}).pipe( + return this.httpClient.post(this.baseUrl + 'want-to-read/v2', data, {observe: 'response', params}).pipe( map(response => { return this.utilityService.createPaginatedResult(response, new PaginatedResult()); })); @@ -137,11 +136,10 @@ export class SeriesService { })); } - getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { - const data = this.filterUtilityService.createSeriesFilter(filter); - + getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + const data = filter || {}; return this.httpClient.post(this.baseUrl + 'series/on-deck?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( map(response => { diff --git a/UI/Web/src/app/cards/dynamic-list.pipe.ts b/UI/Web/src/app/_single-module/card-actionables/_pipes/dynamic-list.pipe.ts similarity index 100% rename from UI/Web/src/app/cards/dynamic-list.pipe.ts rename to UI/Web/src/app/_single-module/card-actionables/_pipes/dynamic-list.pipe.ts diff --git a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html similarity index 100% rename from UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html rename to UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html diff --git a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.scss b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss similarity index 100% rename from UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.scss rename to UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss diff --git a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts similarity index 98% rename from UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts rename to UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts index ab9fa9ebd..e9e9952dc 100644 --- a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts @@ -4,8 +4,8 @@ import { take } from 'rxjs'; import { AccountService } from 'src/app/_services/account.service'; import { Action, ActionItem } from 'src/app/_services/action-factory.service'; import {CommonModule} from "@angular/common"; -import {DynamicListPipe} from "../../dynamic-list.pipe"; import {TranslocoDirective} from "@ngneat/transloco"; +import {DynamicListPipe} from "./_pipes/dynamic-list.pipe"; @Component({ selector: 'app-card-actionables', diff --git a/UI/Web/src/app/_single-module/spoiler/spoiler.component.ts b/UI/Web/src/app/_single-module/spoiler/spoiler.component.ts index 2e248c284..4dcbfc82d 100644 --- a/UI/Web/src/app/_single-module/spoiler/spoiler.component.ts +++ b/UI/Web/src/app/_single-module/spoiler/spoiler.component.ts @@ -34,7 +34,6 @@ export class SpoilerComponent implements OnInit{ ngOnInit() { this.isCollapsed = true; this.cdRef.markForCheck(); - console.log('html: ', this.html) } diff --git a/UI/Web/src/app/admin/library-selector/library-selector.component.html b/UI/Web/src/app/admin/library-selector/library-selector.component.html index 8a252bec8..68fe1980d 100644 --- a/UI/Web/src/app/admin/library-selector/library-selector.component.html +++ b/UI/Web/src/app/admin/library-selector/library-selector.component.html @@ -4,7 +4,7 @@
- +
  • diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.html b/UI/Web/src/app/admin/manage-users/manage-users.component.html index 5edd2ab2e..e6d452c56 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.html +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.html @@ -28,7 +28,7 @@ + placement="top" [ngbTooltip]="t('resend-invite-tooltip')" [attr.aria-label]="t('resend-invite-alt', {user: member.username | titlecase})">{{t('resend')}} + + + +
    +
    + +
    + +
    +
    +
    + +
    + + + + + +
    +
    +
    +
    + +
    + +
    + +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    + + + + diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-builder/metadata-builder.component.scss b/UI/Web/src/app/metadata-filter/_components/metadata-builder/metadata-builder.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-builder/metadata-builder.component.ts b/UI/Web/src/app/metadata-filter/_components/metadata-builder/metadata-builder.component.ts new file mode 100644 index 000000000..5eeafeb0f --- /dev/null +++ b/UI/Web/src/app/metadata-filter/_components/metadata-builder/metadata-builder.component.ts @@ -0,0 +1,102 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, DestroyRef, + EventEmitter, + inject, + Input, + OnInit, + Output +} from '@angular/core'; +import {MetadataService} from 'src/app/_services/metadata.service'; +import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; +import {SeriesFilterV2} from 'src/app/_models/metadata/v2/series-filter-v2'; +import {NgForOf, NgIf, UpperCasePipe} from "@angular/common"; +import {MetadataFilterRowComponent} from "../metadata-filter-row/metadata-filter-row.component"; +import {FilterStatement} from "../../../_models/metadata/v2/filter-statement"; +import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms"; +import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; +import {FilterCombination} from "../../../_models/metadata/v2/filter-combination"; +import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; +import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; +import {allFields, FilterField} from "../../../_models/metadata/v2/filter-field"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {tap} from "rxjs/operators"; +import {translate, TranslocoDirective} from "@ngneat/transloco"; + +@Component({ + selector: 'app-metadata-builder', + templateUrl: './metadata-builder.component.html', + styleUrls: ['./metadata-builder.component.scss'], + standalone: true, + imports: [ + NgIf, + MetadataFilterRowComponent, + NgForOf, + CardActionablesComponent, + FormsModule, + NgbTooltip, + UpperCasePipe, + ReactiveFormsModule, + TranslocoDirective + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MetadataBuilderComponent implements OnInit { + + @Input({required: true}) filter!: SeriesFilterV2; + @Input() availableFilterFields = allFields; + @Output() update: EventEmitter = new EventEmitter(); + + private readonly cdRef = inject(ChangeDetectorRef); + private readonly metadataService = inject(MetadataService); + protected readonly utilityService = inject(UtilityService); + protected readonly filterUtilityService = inject(FilterUtilitiesService); + private readonly destroyRef = inject(DestroyRef); + + formGroup: FormGroup = new FormGroup({}); + + groupOptions: Array<{value: FilterCombination, title: string}> = [ + {value: FilterCombination.Or, title: translate('metadata-builder.or')}, + {value: FilterCombination.And, title: translate('metadata-builder.and')}, + ]; + + get Breakpoint() { return Breakpoint; } + + + ngOnInit() { + if (this.filter === undefined) { + // I've left this in to see if it ever happens or not + console.error('No filter, creating one in metadata-builder') + // If there is no default preset, let's open with series name + this.filter = this.filterUtilityService.createSeriesV2Filter(); + this.filter.statements.push({ + value: '', + comparison: FilterComparison.Equal, + field: FilterField.SeriesName + }); + } + + this.formGroup.addControl('comparison', new FormControl(this.filter?.combination || FilterCombination.Or, [])); + this.formGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef), tap(values => { + this.filter.combination = parseInt(this.formGroup.get('comparison')?.value, 10); + this.update.emit(this.filter); + })).subscribe() + } + + addFilter() { + this.filter.statements = [this.metadataService.createDefaultFilterStatement(), ...this.filter.statements]; + } + + removeFilter(index: number) { + this.filter.statements = this.filter.statements.slice(0, index).concat(this.filter.statements.slice(index + 1)) + this.cdRef.markForCheck(); + } + + updateFilter(index: number, filterStmt: FilterStatement) { + this.metadataService.updateFilter(this.filter.statements, index, filterStmt); + this.update.emit(this.filter); + } + +} diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html new file mode 100644 index 000000000..c47c61338 --- /dev/null +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html @@ -0,0 +1,38 @@ + +
    +
    +
    + + + +
    + +
    + +
    + +
    + + + + + + + + + + + + + +
    + + +
    +
    diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.scss b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.scss new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..9f9a612b4 --- /dev/null +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts @@ -0,0 +1,246 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + EventEmitter, + inject, + Input, + OnInit, + Output +} from '@angular/core'; +import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; +import {FilterStatement} from '../../../_models/metadata/v2/filter-statement'; +import {BehaviorSubject, distinctUntilChanged, 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 {AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase} from "@angular/common"; +import {FilterFieldPipe} from "../../_pipes/filter-field.pipe"; +import {FilterComparisonPipe} from "../../_pipes/filter-comparison.pipe"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; + +enum PredicateType { + Text = 1, + Number = 2, + Dropdown = 3, +} + +const StringFields = [FilterField.SeriesName, FilterField.Summary]; +const NumberFields = [FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, FilterField.UserRating]; +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 +]; + +const StringComparisons = [FilterComparison.Equal, + FilterComparison.NotEqual, + FilterComparison.BeginsWith, + FilterComparison.EndsWith, + FilterComparison.Matches]; +const DateComparisons = [FilterComparison.IsBefore, FilterComparison.IsAfter, FilterComparison.IsInLast, FilterComparison.IsNotInLast]; +const NumberComparisons = [FilterComparison.Equal, + FilterComparison.NotEqual, + FilterComparison.LessThan, + FilterComparison.LessThanEqual, + FilterComparison.GreaterThan, + FilterComparison.GreaterThanEqual]; +const DropdownComparisons = [FilterComparison.Equal, + FilterComparison.NotEqual, + FilterComparison.Contains, + FilterComparison.NotContains]; + +@Component({ + selector: 'app-metadata-row-filter', + templateUrl: './metadata-filter-row.component.html', + styleUrls: ['./metadata-filter-row.component.scss'], + standalone: true, + imports: [ + ReactiveFormsModule, + AsyncPipe, + FilterFieldPipe, + FilterComparisonPipe, + NgSwitch, + NgSwitchCase, + NgForOf, + NgIf + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MetadataFilterRowComponent implements OnInit { + + @Input() preset!: FilterStatement; + @Input() availableFields: Array = allFields; + @Output() filterStatement = new EventEmitter(); + + private readonly cdRef = inject(ChangeDetectorRef); + private readonly destroyRef = inject(DestroyRef); + + formGroup: FormGroup = new FormGroup({ + 'comparison': new FormControl(FilterComparison.Equal, []), + 'filterValue': new FormControl('', []), + }); + validComparisons$: BehaviorSubject = new BehaviorSubject([FilterComparison.Equal] as FilterComparison[]); + predicateType$: BehaviorSubject = new BehaviorSubject(PredicateType.Text as PredicateType); + dropdownOptions$ = of<{value: number, title: string}[]>([]); + + + loaded: boolean = false; + + + get PredicateType() { return PredicateType }; + + constructor(private readonly metadataService: MetadataService, private readonly libraryService: LibraryService, + private readonly collectionTagService: CollectionTagService) {} + + ngOnInit() { + this.formGroup.addControl('input', new FormControl(FilterField.SeriesName, [])); + + this.formGroup.get('input')?.valueChanges.subscribe((val: string) => this.handleFieldChange(val)); + this.populateFromPreset(); + + this.buildDisabledList(); + + + // Dropdown dynamic option selection + this.dropdownOptions$ = this.formGroup.get('input')!.valueChanges.pipe( + startWith(this.preset.value), + switchMap((_) => this.getDropdownObservable()), + tap((opts) => { + const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField; + const filterComparison = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison; + if (this.preset.field === filterField && this.preset.comparison === filterComparison) { + //console.log('using preset value for dropdown option') + return; + } + + this.formGroup.get('filterValue')?.setValue(opts[0].value); + }), + takeUntilDestroyed(this.destroyRef) + ); + + this.formGroup.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe(_ => { + this.filterStatement.emit({ + comparison: parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison, + field: parseInt(this.formGroup.get('input')?.value, 10) as FilterField, + value: this.formGroup.get('filterValue')?.value! + }); + }); + + this.loaded = true; + this.cdRef.markForCheck(); + } + + buildDisabledList() { + + } + + populateFromPreset() { + if (StringFields.includes(this.preset.field)) { + this.formGroup.get('filterValue')?.patchValue(this.preset.value); + } else { + this.formGroup.get('filterValue')?.patchValue(parseInt(this.preset.value, 10)); + } + + this.formGroup.get('comparison')?.patchValue(this.preset.comparison); + this.formGroup.get('input')?.setValue(this.preset.field); + this.cdRef.markForCheck(); + } + + getDropdownObservable(): Observable<{value: any, title: string}[]> { + 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, title: pub.title} + }))); + case FilterField.AgeRating: + return this.metadataService.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => { + return {value: rating.value, title: rating.title} + }))); + case FilterField.Genres: + return this.metadataService.getAllGenres().pipe(map(genres => genres.map(genre => { + return {value: genre.id, title: genre.title} + }))); + case FilterField.Languages: + return this.metadataService.getAllLanguages().pipe(map(statuses => statuses.map(status => { + return {value: status.isoCode, title: status.title + `(${status.isoCode})`} + }))); + case FilterField.Formats: + return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => { + return {value: status.value, title: status.title} + }))); + case FilterField.Libraries: + return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => { + return {value: lib.id, title: lib.name} + }))); + case FilterField.Tags: + return this.metadataService.getAllTags().pipe(map(statuses => statuses.map(status => { + return {value: status.id, title: status.title} + }))); + case FilterField.CollectionTags: + return this.collectionTagService.allTags().pipe(map(statuses => statuses.map(status => { + return {value: status.id, title: 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.Translators: return this.getPersonOptions(PersonRole.Translator); + case FilterField.Writers: return this.getPersonOptions(PersonRole.Writer); + } + return of([]); + } + + getPersonOptions(role: PersonRole) { + return this.metadataService.getAllPeople().pipe(map(people => people.filter(p2 => p2.role === role).map(person => { + return {value: person.id, title: person.name} + }))) + } + + + handleFieldChange(val: string) { + const inputVal = parseInt(val, 10) as FilterField; + + if (StringFields.includes(inputVal)) { + this.validComparisons$.next(StringComparisons); + + this.predicateType$.next(PredicateType.Text); + if (this.loaded) this.formGroup.get('filterValue')?.setValue(''); + + return; + } + + if (NumberFields.includes(inputVal)) { + let comps = [...NumberComparisons]; + if (inputVal === FilterField.ReleaseYear) { + comps.push(...DateComparisons); + } + this.validComparisons$.next(comps); + this.predicateType$.next(PredicateType.Number); + if (this.loaded) this.formGroup.get('filterValue')?.setValue(''); + return; + } + + if (DropdownFields.includes(inputVal)) { + let comps = [...DropdownComparisons]; + if (inputVal === FilterField.AgeRating) { + comps.push(...NumberComparisons); + } + this.validComparisons$.next(comps); + this.predicateType$.next(PredicateType.Dropdown); + } + } + +} diff --git a/UI/Web/src/app/metadata-filter/_pipes/filter-comparison.pipe.ts b/UI/Web/src/app/metadata-filter/_pipes/filter-comparison.pipe.ts new file mode 100644 index 000000000..72e2e13a1 --- /dev/null +++ b/UI/Web/src/app/metadata-filter/_pipes/filter-comparison.pipe.ts @@ -0,0 +1,48 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { FilterComparison } from 'src/app/_models/metadata/v2/filter-comparison'; +import {translate} from "@ngneat/transloco"; + +@Pipe({ + name: 'filterComparison', + standalone: true +}) +export class FilterComparisonPipe implements PipeTransform { + + transform(value: FilterComparison): string { + switch (value) { + case FilterComparison.BeginsWith: + return translate('filter-comparison-pipe.begins-with'); + case FilterComparison.Contains: + return translate('filter-comparison-pipe.contains'); + case FilterComparison.Equal: + return translate('filter-comparison-pipe.equal'); + case FilterComparison.GreaterThan: + return translate('filter-comparison-pipe.greater-than'); + case FilterComparison.GreaterThanEqual: + return translate('filter-comparison-pipe.greater-than-or-equal'); + case FilterComparison.LessThan: + return translate('filter-comparison-pipe.less-than'); + case FilterComparison.LessThanEqual: + return translate('filter-comparison-pipe.less-than-or-equal'); + case FilterComparison.Matches: + return translate('filter-comparison-pipe.matches'); + case FilterComparison.NotContains: + return translate('filter-comparison-pipe.does-not-contain'); + case FilterComparison.NotEqual: + return translate('filter-comparison-pipe.not-equal'); + case FilterComparison.EndsWith: + return translate('filter-comparison-pipe.ends-with'); + case FilterComparison.IsBefore: + return translate('filter-comparison-pipe.is-before'); + case FilterComparison.IsAfter: + return translate('filter-comparison-pipe.is-after'); + case FilterComparison.IsInLast: + return translate('filter-comparison-pipe.is-in-last'); + case FilterComparison.IsNotInLast: + return translate('filter-comparison-pipe.is-not-in-last'); + default: + throw new Error(`Invalid FilterComparison value: ${value}`); + } + } + +} diff --git a/UI/Web/src/app/metadata-filter/_pipes/filter-field.pipe.ts b/UI/Web/src/app/metadata-filter/_pipes/filter-field.pipe.ts new file mode 100644 index 000000000..bbd96d334 --- /dev/null +++ b/UI/Web/src/app/metadata-filter/_pipes/filter-field.pipe.ts @@ -0,0 +1,66 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { FilterField } from 'src/app/_models/metadata/v2/filter-field'; +import {translate} from "@ngneat/transloco"; + +@Pipe({ + name: 'filterField', + standalone: true +}) +export class FilterFieldPipe implements PipeTransform { + + transform(value: FilterField): string { + 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.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'); + default: + throw new Error(`Invalid FilterField value: ${value}`); + } + } + +} diff --git a/UI/Web/src/app/metadata-filter/filter-settings.ts b/UI/Web/src/app/metadata-filter/filter-settings.ts index c80ed96ac..490c362c1 100644 --- a/UI/Web/src/app/metadata-filter/filter-settings.ts +++ b/UI/Web/src/app/metadata-filter/filter-settings.ts @@ -1,24 +1,6 @@ -import { SeriesFilter } from "../_models/metadata/series-filter"; +import { SeriesFilterV2 } from "../_models/metadata/v2/series-filter-v2"; export class FilterSettings { - libraryDisabled = false; - formatDisabled = false; - collectionDisabled = false; - genresDisabled = false; - peopleDisabled = false; - readProgressDisabled = false; - ratingDisabled = false; sortDisabled = false; - ageRatingDisabled = false; - tagsDisabled = false; - languageDisabled = false; - publicationStatusDisabled = false; - searchNameDisabled = false; - releaseYearDisabled = false; - presets: SeriesFilter | undefined; - /** - * Should the filter section be open by default - * @deprecated This is deprecated UX pattern. New style is to show highlight on filter button. - */ - openByDefault = false; - } \ No newline at end of file + presetsV2: SeriesFilterV2 | undefined; + } 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 6df87f1f4..65b49aa58 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.html +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.html @@ -19,357 +19,39 @@ - {{t('format-tooltip')}}
    -
    -
    - - - - - {{item.title}} - - - {{item.title}} - - -
    -
    - -
    -
    - - - - {{item.name}} - - - {{item.name}} - - -
    -
    - -
    -
    - - - - - {{item.title}} - - - {{item.title}} - - -
    -
    - -
    -
    - - - - {{item.title}} - - - {{item.title}} - - -
    -
    - -
    -
    - - - - {{item.title}} - - - {{item.title}} - - -
    -
    +
    -
    - -
    -
    - - - - {{item.name}} - - - {{item.name}} - - -
    -
    - -
    -
    - - - - {{item.name}} - - - {{item.name}} - - -
    -
    - -
    -
    - - - - {{item.name}} - - - {{item.name}} - - -
    -
    - -
    -
    - - - - {{item.name}} - - - {{item.name}} - - -
    -
    - -
    -
    - - - - {{item.name}} - - - {{item.name}} - - -
    -
    - -
    -
    - - - - {{item.name}} - - - {{item.name}} - - -
    -
    - -
    -
    - - - - {{item.name}} - - - {{item.name}} - - -
    -
    - -
    -
    - - - - {{item.name}} - - - {{item.name}} - - -
    -
    - -
    -
    - - - - {{item.name}} - - - {{item.name}} - - -
    -
    - -
    -
    - - - - {{item.name}} - - - {{item.name}} - - -
    -
    -
    -
    -
    - -
    -
    - - + +
    +
    +
    + +
    -
    - - -
    -
    - - -
    - -
    - -
    - -
    - - - - - -
    -
    - -
    - - - - {{item.title}} - - - {{item.title}} - - -
    - -
    - - - - {{item.title}} - - - {{item.title}} - - -
    - -
    - - - - {{item.title}} - - - {{item.title}} - - -
    -
    -
    -
    -
    -
    -
    - - - {{t('series-name-tooltip')}} - -
    -
    -
    -
    -
    -
    - - -
    -
    - -
    -
    - - -
    -
    -
    -
    -
    -
    +
    +
    - -
    -
    -
    -
    - -
    -
    - -
    +
    + +
    + +
    +
    + +
    +
    - 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 324dbdeb9..6a06a9fc4 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -2,40 +2,31 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - ContentChild, DestroyRef, + ContentChild, + DestroyRef, EventEmitter, inject, Input, OnInit, Output } from '@angular/core'; -import { FormControl, FormGroup, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms'; -import { NgbCollapse, NgbTooltip, NgbRating } from '@ng-bootstrap/ng-bootstrap'; -import { distinctUntilChanged, forkJoin, map, Observable, of, ReplaySubject } from 'rxjs'; -import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service'; -import { Breakpoint, UtilityService } from '../shared/_services/utility.service'; -import { TypeaheadSettings } from '../typeahead/_models/typeahead-settings'; -import { CollectionTag } from '../_models/collection-tag'; -import { Genre } from '../_models/metadata/genre'; -import { Library } from '../_models/library'; -import { MangaFormat } from '../_models/manga-format'; -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 { FilterEvent, FilterItem, mangaFormatFilters, SeriesFilter, SortField } from '../_models/metadata/series-filter'; -import { Tag } from '../_models/tag'; -import { CollectionTagService } from '../_services/collection-tag.service'; -import { LibraryService } from '../_services/library.service'; -import { MetadataService } from '../_services/metadata.service'; -import { ToggleService } from '../_services/toggle.service'; -import { FilterSettings } from './filter-settings'; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {NgbCollapse, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; +import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service'; +import {Breakpoint, UtilityService} from '../shared/_services/utility.service'; +import {Library} from '../_models/library'; +import {allSortFields, FilterEvent, FilterItem, SortField} 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 {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import { TypeaheadComponent } from '../typeahead/_components/typeahead.component'; -import { DrawerComponent } from '../shared/drawer/drawer.component'; -import { NgIf, NgTemplateOutlet, AsyncPipe } from '@angular/common'; +import {TypeaheadComponent} from '../typeahead/_components/typeahead.component'; +import {DrawerComponent} from '../shared/drawer/drawer.component'; +import {AsyncPipe, NgForOf, NgIf, NgTemplateOutlet} from '@angular/common'; import {TranslocoModule} from "@ngneat/transloco"; import {SortFieldPipe} from "../pipe/sort-field.pipe"; +import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component"; +import {allFields} from "../_models/metadata/v2/filter-field"; @Component({ selector: 'app-metadata-filter', @@ -44,7 +35,7 @@ import {SortFieldPipe} from "../pipe/sort-field.pipe"; changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [NgIf, NgbCollapse, NgTemplateOutlet, DrawerComponent, NgbTooltip, TypeaheadComponent, - ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe] + ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe, MetadataBuilderComponent, NgForOf] }) export class MetadataFilterComponent implements OnInit { @@ -66,47 +57,34 @@ export class MetadataFilterComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); - formatSettings: TypeaheadSettings> = new TypeaheadSettings(); - librarySettings: TypeaheadSettings = new TypeaheadSettings(); - genreSettings: TypeaheadSettings = new TypeaheadSettings(); - collectionSettings: TypeaheadSettings = new TypeaheadSettings(); - ageRatingSettings: TypeaheadSettings = new TypeaheadSettings(); - publicationStatusSettings: TypeaheadSettings = new TypeaheadSettings(); - tagsSettings: TypeaheadSettings = new TypeaheadSettings(); - languageSettings: TypeaheadSettings = new TypeaheadSettings(); - peopleSettings: {[PersonRole: string]: TypeaheadSettings} = {}; - resetTypeaheads: ReplaySubject = new ReplaySubject(1); - /** * Controls the visibility of extended controls that sit below the main header. */ filteringCollapsed: boolean = true; - filter!: SeriesFilter; libraries: Array> = []; - - readProgressGroup!: FormGroup; sortGroup!: FormGroup; - seriesNameGroup!: FormGroup; - releaseYearRange!: FormGroup; isAscendingSort: boolean = true; updateApplied: number = 0; fullyLoaded: boolean = false; + filterV2: SeriesFilterV2 | undefined; + allSortFields = allSortFields; + allFilterFields = allFields; - get PersonRole(): typeof PersonRole { - return PersonRole; + handleFilters(filter: SeriesFilterV2) { + this.filterV2 = filter; } - get SortField(): typeof SortField { - return SortField; - } - constructor(private libraryService: LibraryService, private metadataService: MetadataService, private utilityService: UtilityService, - private collectionTagService: CollectionTagService, public toggleService: ToggleService, - private readonly cdRef: ChangeDetectorRef, private filterUtilitySerivce: FilterUtilitiesService) { + private readonly cdRef = inject(ChangeDetectorRef); + + + constructor(private utilityService: UtilityService, + public toggleService: ToggleService, + private filterUtilityService: FilterUtilitiesService) { } ngOnInit(): void { @@ -123,78 +101,6 @@ export class MetadataFilterComponent implements OnInit { }); } - this.filter = this.filterUtilitySerivce.createSeriesFilter(); - this.readProgressGroup = new FormGroup({ - read: new FormControl({value: this.filter.readStatus.read, disabled: this.filterSettings.readProgressDisabled}, []), - notRead: new FormControl({value: this.filter.readStatus.notRead, disabled: this.filterSettings.readProgressDisabled}, []), - inProgress: new FormControl({value: this.filter.readStatus.inProgress, disabled: this.filterSettings.readProgressDisabled}, []), - }); - - this.sortGroup = new FormGroup({ - sortField: new FormControl({value: this.filter.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []), - }); - - this.seriesNameGroup = new FormGroup({ - seriesNameQuery: new FormControl({value: this.filter.seriesNameQuery || '', disabled: this.filterSettings.searchNameDisabled}, []) - }); - - this.releaseYearRange = new FormGroup({ - min: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999), Validators.maxLength(4), Validators.minLength(4)]), - max: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999), Validators.maxLength(4), Validators.minLength(4)]) - }); - - this.readProgressGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(changes => { - this.filter.readStatus.read = this.readProgressGroup.get('read')?.value; - this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value; - this.filter.readStatus.notRead = this.readProgressGroup.get('notRead')?.value; - - let sum = 0; - sum += (this.filter.readStatus.read ? 1 : 0); - sum += (this.filter.readStatus.inProgress ? 1 : 0); - sum += (this.filter.readStatus.notRead ? 1 : 0); - - if (sum === 1) { - if (this.filter.readStatus.read) this.readProgressGroup.get('read')?.disable({ emitEvent: false }); - if (this.filter.readStatus.notRead) this.readProgressGroup.get('notRead')?.disable({ emitEvent: false }); - if (this.filter.readStatus.inProgress) this.readProgressGroup.get('inProgress')?.disable({ emitEvent: false }); - } else { - this.readProgressGroup.get('read')?.enable({ emitEvent: false }); - this.readProgressGroup.get('notRead')?.enable({ emitEvent: false }); - this.readProgressGroup.get('inProgress')?.enable({ emitEvent: false }); - } - this.cdRef.markForCheck(); - }); - - this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(changes => { - if (this.filter.sortOptions == null) { - this.filter.sortOptions = { - isAscending: this.isAscendingSort, - sortField: parseInt(this.sortGroup.get('sortField')?.value, 10) - }; - } - this.filter.sortOptions.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10); - this.cdRef.markForCheck(); - }); - - this.seriesNameGroup.get('seriesNameQuery')?.valueChanges.pipe( - map(val => (val || '').trim()), - distinctUntilChanged(), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(changes => { - this.filter.seriesNameQuery = changes; // TODO: See if we can make this into observable - this.cdRef.markForCheck(); - }); - - this.releaseYearRange.valueChanges.pipe( - distinctUntilChanged(), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(changes => { - this.filter.releaseYearRange = {min: this.releaseYearRange.get('min')?.value, max: this.releaseYearRange.get('max')?.value}; - this.cdRef.markForCheck(); - }); - this.loadFromPresetsAndSetup(); } @@ -205,444 +111,80 @@ export class MetadataFilterComponent implements OnInit { this.cdRef.markForCheck(); } - getPersonsSettings(role: PersonRole) { - return this.peopleSettings[role]; + deepClone(obj: any): any { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (obj instanceof Array) { + return obj.map(item => this.deepClone(item)); + } + + const clonedObj: any = {}; + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + if (typeof obj[key] === 'object' && obj[key] !== null) { + clonedObj[key] = this.deepClone(obj[key]); + } else { + clonedObj[key] = obj[key]; + } + } + } + + return clonedObj; } + loadFromPresetsAndSetup() { this.fullyLoaded = false; - if (this.filterSettings.presets) { - this.readProgressGroup.get('read')?.patchValue(this.filterSettings.presets.readStatus.read); - this.readProgressGroup.get('notRead')?.patchValue(this.filterSettings.presets.readStatus.notRead); - this.readProgressGroup.get('inProgress')?.patchValue(this.filterSettings.presets.readStatus.inProgress); - if (this.filterSettings.presets.sortOptions) { - this.sortGroup.get('sortField')?.setValue(this.filterSettings.presets.sortOptions.sortField); - this.isAscendingSort = this.filterSettings.presets.sortOptions.isAscending; - if (this.filter.sortOptions) { - this.filter.sortOptions.isAscending = this.isAscendingSort; - this.filter.sortOptions.sortField = this.filterSettings.presets.sortOptions.sortField; - } - } + this.filterV2 = this.deepClone(this.filterSettings.presetsV2); - if (this.filterSettings.presets.rating > 0) { - this.updateRating(this.filterSettings.presets.rating); - } - - if (this.filterSettings.presets.seriesNameQuery !== '') { - this.seriesNameGroup.get('searchNameQuery')?.setValue(this.filterSettings.presets.seriesNameQuery); - } - } - - this.setupFormatTypeahead(); - this.cdRef.markForCheck(); - - forkJoin([ - this.setupLibraryTypeahead(), - this.setupCollectionTagTypeahead(), - this.setupAgeRatingSettings(), - this.setupPublicationStatusSettings(), - this.setupTagSettings(), - this.setupLanguageSettings(), - this.setupGenreTypeahead(), - this.setupPersonTypeahead(), - ]).subscribe(results => { - this.fullyLoaded = true; - this.resetTypeaheads.next(false); // Pass false to ensure we reset to the preset and not to an empty typeahead - this.cdRef.markForCheck(); - this.apply(); + this.sortGroup = new FormGroup({ + sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []), + limitTo: new FormControl(this.filterV2?.limitTo || 0, []) }); + + 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 = parseInt(this.sortGroup.get('limitTo')?.value, 10); + this.cdRef.markForCheck(); + }); + + this.fullyLoaded = true; + this.cdRef.markForCheck(); + this.apply(); } - setupFormatTypeahead() { - this.formatSettings.minCharacters = 0; - this.formatSettings.multiple = true; - this.formatSettings.id = 'format'; - this.formatSettings.unique = true; - this.formatSettings.addIfNonExisting = false; - this.formatSettings.fetchFn = (filter: string) => of(mangaFormatFilters).pipe(map(items => this.formatSettings.compareFn(items, filter))); - this.formatSettings.compareFn = (options: FilterItem[], filter: string) => { - return options.filter(m => this.utilityService.filter(m.title, filter)); - } - - this.formatSettings.selectionCompareFn = (a: FilterItem, b: FilterItem) => { - return a.title == b.title; - } - - if (this.filterSettings.presets?.formats && this.filterSettings.presets?.formats.length > 0) { - this.formatSettings.savedData = mangaFormatFilters.filter(item => this.filterSettings.presets?.formats.includes(item.value)); - this.updateFormatFilters(this.formatSettings.savedData); - } - } - - setupLibraryTypeahead() { - this.librarySettings.minCharacters = 0; - this.librarySettings.multiple = true; - this.librarySettings.id = 'libraries'; - this.librarySettings.unique = true; - this.librarySettings.addIfNonExisting = false; - this.librarySettings.fetchFn = (filter: string) => { - return this.libraryService.getLibraries() - .pipe(map(items => this.librarySettings.compareFn(items, filter))); - }; - this.librarySettings.compareFn = (options: Library[], filter: string) => { - return options.filter(m => this.utilityService.filter(m.name, filter)); - } - this.librarySettings.selectionCompareFn = (a: Library, b: Library) => { - return a.name == b.name; - } - - if (this.filterSettings.presets?.libraries && this.filterSettings.presets?.libraries.length > 0) { - return this.librarySettings.fetchFn('').pipe(map(libraries => { - this.librarySettings.savedData = libraries.filter(item => this.filterSettings.presets?.libraries.includes(item.id)); - this.updateLibraryFilters(this.librarySettings.savedData); - return of(true); - })); - } - return of(true); - } - - setupGenreTypeahead() { - this.genreSettings.minCharacters = 0; - this.genreSettings.multiple = true; - this.genreSettings.id = 'genres'; - this.genreSettings.unique = true; - this.genreSettings.addIfNonExisting = false; - this.genreSettings.fetchFn = (filter: string) => { - return this.metadataService.getAllGenres(this.filter.libraries) - .pipe(map(items => this.genreSettings.compareFn(items, filter))); - }; - this.genreSettings.compareFn = (options: Genre[], filter: string) => { - return options.filter(m => this.utilityService.filter(m.title, filter)); - } - this.genreSettings.selectionCompareFn = (a: Genre, b: Genre) => { - return a.title == b.title; - } - - if (this.filterSettings.presets?.genres && this.filterSettings.presets?.genres.length > 0) { - return this.genreSettings.fetchFn('').pipe(map(genres => { - this.genreSettings.savedData = genres.filter(item => this.filterSettings.presets?.genres.includes(item.id)); - this.updateGenreFilters(this.genreSettings.savedData); - return of(true); - })); - } - return of(true); - } - - setupAgeRatingSettings() { - this.ageRatingSettings.minCharacters = 0; - this.ageRatingSettings.multiple = true; - this.ageRatingSettings.id = 'age-rating'; - this.ageRatingSettings.unique = true; - this.ageRatingSettings.addIfNonExisting = false; - this.ageRatingSettings.fetchFn = (filter: string) => this.metadataService.getAllAgeRatings(this.filter.libraries) - .pipe(map(items => this.ageRatingSettings.compareFn(items, filter))); - - this.ageRatingSettings.compareFn = (options: AgeRatingDto[], filter: string) => { - return options.filter(m => this.utilityService.filter(m.title, filter)); - } - - - this.ageRatingSettings.selectionCompareFn = (a: AgeRatingDto, b: AgeRatingDto) => { - return a.title == b.title; - } - - if (this.filterSettings.presets?.ageRating && this.filterSettings.presets?.ageRating.length > 0) { - return this.ageRatingSettings.fetchFn('').pipe(map(rating => { - this.ageRatingSettings.savedData = rating.filter(item => this.filterSettings.presets?.ageRating.includes(item.value)); - this.updateAgeRating(this.ageRatingSettings.savedData); - return of(true); - })); - } - return of(true); - } - - setupPublicationStatusSettings() { - this.publicationStatusSettings.minCharacters = 0; - this.publicationStatusSettings.multiple = true; - this.publicationStatusSettings.id = 'publication-status'; - this.publicationStatusSettings.unique = true; - this.publicationStatusSettings.addIfNonExisting = false; - this.publicationStatusSettings.fetchFn = (filter: string) => this.metadataService.getAllPublicationStatus(this.filter.libraries) - .pipe(map(items => this.publicationStatusSettings.compareFn(items, filter))); - - this.publicationStatusSettings.compareFn = (options: PublicationStatusDto[], filter: string) => { - return options.filter(m => this.utilityService.filter(m.title, filter)); - } - - this.publicationStatusSettings.selectionCompareFn = (a: PublicationStatusDto, b: PublicationStatusDto) => { - return a.title == b.title; - } - - if (this.filterSettings.presets?.publicationStatus && this.filterSettings.presets?.publicationStatus.length > 0) { - return this.publicationStatusSettings.fetchFn('').pipe(map(statuses => { - this.publicationStatusSettings.savedData = statuses.filter(item => this.filterSettings.presets?.publicationStatus.includes(item.value)); - this.updatePublicationStatus(this.publicationStatusSettings.savedData); - return of(true); - })); - } - return of(true); - } - - setupTagSettings() { - this.tagsSettings.minCharacters = 0; - this.tagsSettings.multiple = true; - this.tagsSettings.id = 'tags'; - this.tagsSettings.unique = true; - this.tagsSettings.addIfNonExisting = false; - this.tagsSettings.compareFn = (options: Tag[], filter: string) => { - return options.filter(m => this.utilityService.filter(m.title, filter)); - } - this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags(this.filter.libraries) - .pipe(map(items => this.tagsSettings.compareFn(items, filter))); - - this.tagsSettings.selectionCompareFn = (a: Tag, b: Tag) => { - return a.id == b.id; - } - - if (this.filterSettings.presets?.tags && this.filterSettings.presets?.tags.length > 0) { - return this.tagsSettings.fetchFn('').pipe(map(tags => { - this.tagsSettings.savedData = tags.filter(item => this.filterSettings.presets?.tags.includes(item.id)); - this.updateTagFilters(this.tagsSettings.savedData); - return of(true); - })); - } - return of(true); - } - - setupLanguageSettings() { - this.languageSettings.minCharacters = 0; - this.languageSettings.multiple = true; - this.languageSettings.id = 'languages'; - this.languageSettings.unique = true; - this.languageSettings.addIfNonExisting = false; - this.languageSettings.compareFn = (options: Language[], filter: string) => { - return options.filter(m => this.utilityService.filter(m.title, filter)); - } - this.languageSettings.fetchFn = (filter: string) => this.metadataService.getAllLanguages(this.filter.libraries) - .pipe(map(items => this.languageSettings.compareFn(items, filter))); - - this.languageSettings.selectionCompareFn = (a: Language, b: Language) => { - return a.isoCode == b.isoCode; - } - - if (this.filterSettings.presets?.languages && this.filterSettings.presets?.languages.length > 0) { - return this.languageSettings.fetchFn('').pipe(map(languages => { - this.languageSettings.savedData = languages.filter(item => this.filterSettings.presets?.languages.includes(item.isoCode)); - this.updateLanguages(this.languageSettings.savedData); - return of(true); - })); - } - return of(true); - } - - setupCollectionTagTypeahead() { - this.collectionSettings.minCharacters = 0; - this.collectionSettings.multiple = true; - this.collectionSettings.id = 'collections'; - this.collectionSettings.unique = true; - this.collectionSettings.addIfNonExisting = false; - this.collectionSettings.compareFn = (options: CollectionTag[], filter: string) => { - return options.filter(m => this.utilityService.filter(m.title, filter)); - } - this.collectionSettings.fetchFn = (filter: string) => this.collectionTagService.allTags() - .pipe(map(items => this.collectionSettings.compareFn(items, filter))); - - this.collectionSettings.selectionCompareFn = (a: CollectionTag, b: CollectionTag) => { - return a.id == b.id; - } - - if (this.filterSettings.presets?.collectionTags && this.filterSettings.presets?.collectionTags.length > 0) { - return this.collectionSettings.fetchFn('').pipe(map(tags => { - this.collectionSettings.savedData = tags.filter(item => this.filterSettings.presets?.collectionTags.includes(item.id)); - this.updateCollectionFilters(this.collectionSettings.savedData); - return of(true); - })); - } - return of(true); - } - - updateFromPreset(id: string, peopleFilterField: Array, presetField: Array | undefined, role: PersonRole) { - const personSettings = this.createBlankPersonSettings(id, role) - if (presetField && presetField.length > 0) { - const fetch = personSettings.fetchFn as ((filter: string) => Observable); - return fetch('').pipe(map(people => { - personSettings.savedData = people.filter(item => presetField.includes(item.id)); - this.peopleSettings[role] = personSettings; - this.updatePersonFilters(personSettings.savedData, role); - return true; - })); - } - - this.peopleSettings[role] = personSettings; - return of(true); - - } - - setupPersonTypeahead() { - this.peopleSettings = {}; - - return forkJoin([ - this.updateFromPreset('writers', this.filter.writers, this.filterSettings.presets?.writers, PersonRole.Writer), - this.updateFromPreset('character', this.filter.character, this.filterSettings.presets?.character, PersonRole.Character), - this.updateFromPreset('colorist', this.filter.colorist, this.filterSettings.presets?.colorist, PersonRole.Colorist), - this.updateFromPreset('cover-artist', this.filter.coverArtist, this.filterSettings.presets?.coverArtist, PersonRole.CoverArtist), - this.updateFromPreset('editor', this.filter.editor, this.filterSettings.presets?.editor, PersonRole.Editor), - this.updateFromPreset('inker', this.filter.inker, this.filterSettings.presets?.inker, PersonRole.Inker), - this.updateFromPreset('letterer', this.filter.letterer, this.filterSettings.presets?.letterer, PersonRole.Letterer), - this.updateFromPreset('penciller', this.filter.penciller, this.filterSettings.presets?.penciller, PersonRole.Penciller), - this.updateFromPreset('publisher', this.filter.publisher, this.filterSettings.presets?.publisher, PersonRole.Publisher), - this.updateFromPreset('translators', this.filter.translators, this.filterSettings.presets?.translators, PersonRole.Translator) - ]).pipe(map(_ => { - return of(true); - })); - } - - fetchPeople(role: PersonRole, filter: string) { - return this.metadataService.getAllPeople(this.filter.libraries).pipe(map(people => { - return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter)); - })); - } - - createBlankPersonSettings(id: string, role: PersonRole) { - var personSettings = new TypeaheadSettings(); - personSettings.minCharacters = 0; - personSettings.multiple = true; - personSettings.unique = true; - personSettings.addIfNonExisting = false; - personSettings.id = id; - personSettings.compareFn = (options: Person[], filter: string) => { - return options.filter(m => this.utilityService.filter(m.name, filter)); - } - - personSettings.selectionCompareFn = (a: Person, b: Person) => { - return a.name == b.name && a.role == b.role; - } - personSettings.fetchFn = (filter: string) => { - return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter))); - }; - return personSettings; - } - - updateFormatFilters(formats: FilterItem[]) { - this.filter.formats = formats.map(item => item.value) || []; - this.formatSettings.savedData = formats; - } - - updateLibraryFilters(libraries: Library[]) { - this.filter.libraries = libraries.map(item => item.id) || []; - this.librarySettings.savedData = libraries; - } - - updateGenreFilters(genres: Genre[]) { - this.filter.genres = genres.map(item => item.id) || []; - this.genreSettings.savedData = genres; - } - - updateTagFilters(tags: Tag[]) { - this.filter.tags = tags.map(item => item.id) || []; - this.tagsSettings.savedData = tags; - } - - updatePersonFilters(persons: Person[], role: PersonRole) { - this.peopleSettings[role].savedData = persons; - switch (role) { - case PersonRole.CoverArtist: - this.filter.coverArtist = persons.map(p => p.id); - break; - case PersonRole.Character: - this.filter.character = persons.map(p => p.id); - break; - case PersonRole.Colorist: - this.filter.colorist = persons.map(p => p.id); - break; - case PersonRole.Editor: - this.filter.editor = persons.map(p => p.id); - break; - case PersonRole.Inker: - this.filter.inker = persons.map(p => p.id); - break; - case PersonRole.Letterer: - this.filter.letterer = persons.map(p => p.id); - break; - case PersonRole.Penciller: - this.filter.penciller = persons.map(p => p.id); - break; - case PersonRole.Publisher: - this.filter.publisher = persons.map(p => p.id); - break; - case PersonRole.Writer: - this.filter.writers = persons.map(p => p.id); - break; - case PersonRole.Translator: - this.filter.translators = persons.map(p => p.id); - - } - } - - updateCollectionFilters(tags: CollectionTag[]) { - this.filter.collectionTags = tags.map(item => item.id) || []; - this.collectionSettings.savedData = tags; - } - - updateRating(rating: any) { - if (this.filterSettings.ratingDisabled) return; - this.filter.rating = rating; - } - - updateAgeRating(ratingDtos: AgeRatingDto[]) { - this.filter.ageRating = ratingDtos.map(item => item.value) || []; - this.ageRatingSettings.savedData = ratingDtos; - } - - updatePublicationStatus(dtos: PublicationStatusDto[]) { - this.filter.publicationStatus = dtos.map(item => item.value) || []; - this.publicationStatusSettings.savedData = dtos; - } - - updateLanguages(languages: Language[]) { - this.filter.languages = languages.map(item => item.isoCode) || []; - this.languageSettings.savedData = languages; - } - - updateReadStatus(status: string) { - if (status === 'read') { - this.filter.readStatus.read = !this.filter.readStatus.read; - } else if (status === 'inProgress') { - this.filter.readStatus.inProgress = !this.filter.readStatus.inProgress; - } else if (status === 'notRead') { - this.filter.readStatus.notRead = !this.filter.readStatus.notRead; - } - } updateSortOrder() { if (this.filterSettings.sortDisabled) return; this.isAscendingSort = !this.isAscendingSort; - if (this.filter.sortOptions === null) { - this.filter.sortOptions = { + if (this.filterV2?.sortOptions === null) { + this.filterV2.sortOptions = { isAscending: this.isAscendingSort, sortField: SortField.SortName } } - this.filter.sortOptions.isAscending = this.isAscendingSort; + this.filterV2!.sortOptions!.isAscending = this.isAscendingSort; } clear() { - this.filter = this.filterUtilitySerivce.createSeriesFilter(); - this.readProgressGroup.get('read')?.setValue(true); - this.readProgressGroup.get('notRead')?.setValue(true); - this.readProgressGroup.get('inProgress')?.setValue(true); - this.sortGroup.get('sortField')?.setValue(SortField.SortName); - this.isAscendingSort = true; - this.seriesNameGroup.get('seriesNameQuery')?.setValue(''); - this.cdRef.markForCheck(); - // Apply any presets which will trigger the apply + // Apply any presets which will trigger the "apply" this.loadFromPresetsAndSetup(); } apply() { - this.applyFilter.emit({filter: this.filter, isFirst: this.updateApplied === 0}); + + this.applyFilter.emit({isFirst: this.updateApplied === 0, filterV2: this.filterV2!}); if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile && this.updateApplied !== 0) { this.toggleSelected(); diff --git a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts index d03c36144..7fa8d69d7 100644 --- a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts +++ b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts @@ -96,7 +96,6 @@ export class EventsWidgetComponent implements OnInit, OnDestroy { this.activeEvents += 1; this.cdRef.markForCheck(); } else if (event.event === EVENTS.UpdateAvailable) { - console.log('event: ', event); this.handleUpdateAvailableClick(event.payload); } }); diff --git a/UI/Web/src/app/pipe/safe-html.pipe.ts b/UI/Web/src/app/pipe/safe-html.pipe.ts index 90baad1c3..979197de4 100644 --- a/UI/Web/src/app/pipe/safe-html.pipe.ts +++ b/UI/Web/src/app/pipe/safe-html.pipe.ts @@ -1,3 +1,4 @@ +import { inject } from '@angular/core'; import { Pipe, PipeTransform, SecurityContext } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; @@ -7,8 +8,8 @@ import { DomSanitizer } from '@angular/platform-browser'; standalone: true }) export class SafeHtmlPipe implements PipeTransform { - - constructor(private dom: DomSanitizer) {} + private readonly dom: DomSanitizer = inject(DomSanitizer); + constructor() {} transform(value: string): unknown { return this.dom.sanitize(SecurityContext.HTML, value); diff --git a/UI/Web/src/app/pipe/safe-style.pipe.ts b/UI/Web/src/app/pipe/safe-style.pipe.ts index fe5940454..8228ae1e0 100644 --- a/UI/Web/src/app/pipe/safe-style.pipe.ts +++ b/UI/Web/src/app/pipe/safe-style.pipe.ts @@ -1,3 +1,4 @@ +import { inject } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; @@ -6,9 +7,8 @@ import { DomSanitizer } from '@angular/platform-browser'; standalone: true }) export class SafeStylePipe implements PipeTransform { - - constructor(private sanitizer: DomSanitizer){ - } + private readonly sanitizer: DomSanitizer = inject(DomSanitizer); + constructor(){} transform(style: string) { return this.sanitizer.bypassSecurityTrustStyle(style); diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html index 231b8de77..c9552e8c6 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html @@ -26,7 +26,7 @@
    - +
    @@ -52,7 +52,7 @@ {{t('continue')}} -
    +