Filtering Overhaul (#2207)

* Implemented the first version of dynamic filtering which is all Extension based.

* Implemented basic generic property filter for expanded metadata filtering.

* Fixed up the code to allow for nested properties and fixed up the Contains to work only for IList's

* Started refactoring for the new approach

* More progress, need to rethink a few filters like read progress to be % based and people needs to be more explicit.

* Refactored most of the existing filtering operations into dedicate extensions for the appropriate comparisons. People still need to be reworked to be more dynamic.

* Fixed a bug with continue point where it fails on chapters or volumes tagged with a range

* Wired up a basic api path to start building groups. No and/or support yet.

* Started on the UI

* Made a bit of progress on the UI as I'm putting the pieces together about how to design it.

* Refactored names to make it more consistent. New thinking is we will have one row that will take a filter statement and manipulate it. It will emit said statement and a builder will turn into the higher level statement.

* Started working on updating code to use new inject() method.

* Fixed the code to switch the comparisons.

* Added dynamic input structure in and moved add/remove to the builder.

* Fixed an enum bug

* Hooked in basic dropdown support that is dynamic to the field. Only language is missing as that needs a DTO change (but don't want to break API)

* Fixed a bug where dropdown options wouldn't re-populate when switching fields that are both dropdowns

* Started adding metadata builder

* Fixed when typing on filter row the focus resetting

* Refactored to add an additional component which handles the compounding of filter rows.

* Started hooking up v2 dto in the UI to send to the backend.

* Started working on building group UI for and/or support.

* Lots of backend code fixes to ensure OR and AND statements combine correctly.

* More trying to figure out how to write the UI code

* Started debugging to remember what I was last doing.

* Lots of progress towards building out the UI recursively

* I got the dto to build and propagate up the chain

* Started hooking up to the actual api to fetch the data.

* Basic wire up to the backend is working.

* HasName is now complete

* Refactored SortOptions code into an extension and streamlined LimitTo to the correct place.

* Fixed a bug where Library Filters from the Group weren't actually being taken into account.

* Refactored a lot of code so builder will now export the full dto.

* Cleaned up the data flow from metadata filter to library detail

* Got the dropdown to load preset values on first load, but now it triggers twice.

* Changed so when you add a new filter, it does it at top and fixed remove

* Fixed the remove button being on the wrong row

* Cleaned up the arrays to make it easier to manage

* Cleaned up some of the backend to ensure it doesn't throw an incorrect exception

* I'm starting to tread water, taking a break

* Fixed a merge issue

* Cleaned up Docker checks.

* Default IpAddresses to empty string.

* Refactored IsDocker to be completely static

* Figured out the issue with the dropdown not working.

* Almost got it, but the event isn't being called.

* I think i might try something else. This doesn't seem to be working.

* On the new implementation, implemented remove group.

* Use enums to reduce copy/paste

* the new system is working pretty well, ill go with it and move on. Can alwasy refactor.

* Code is totally broken, but working the cache resume code with some hiccups.

* I need to take a break

* Stashing my broken code. I have an idea on how to serialize to the URL, but I need to rearchitect a lot.

* Reverted last commit

* remove domain

* Fixed up some hardcoded caching. I'm giving up on this implementation and going to a simpler version

* Refactored the backend to just allow flat filtering.

* Started refactoring the components to make it flat filtering only.

* Finished refactoring so that the base preset case will render.

* Implemented basic query functionality on desktop. Clear needs some work and url code.

* Some cleanup

* Working on filtering url encode/decode

* Interacting with filters now saves to url and can be reloaded from the url. Named filters is not hooked up.

* Fixed a double load on the library detail page.

* Moved the library filtering code out of the FilterBuilder as it needs to be handled differently.

* Fixed up how we handle library statements in the filter.

* Fixed up how links that perform a filter work.

* Refactored a bunch of linking to a search page.

* LimitTo works, my css however does not.

* Switched some code to use localized strings.

* Cleaned up some css

* Hooked up Languages and put some additional code in so that Languages will return invalid Language codes back.

* Removed a duplicate language signature.

* Hooked up ability to preload collection tag.

* Want To Read is converted

* Converted lots of code to new filtering system. Need to do Bookmarks.

* Fixed a potential bug with default filter creation.

* Hooked up the ability to disable certain filter fields from appearing.

* Added mobile drawer code and a hook for Robbie to take a look for some css.

* Converted the APIs for dashboard along with other safety fixes to ensure bad data doesn't break any of the filtering apis

* Added the backend code to handle summary query

* Converted Want to Read api properly now.

* Fixed the HasReadingProgress query

* Hooked back the Reading Progress for legacy APIs

* Fixed some bad localization strings

* Wrote the filtering code for all-bookmarks.

* OPDS is now using the new filter

* Fixed OPDS reading lists and covers not sending their images.

* Fixed up the OPDS feed and fixed a bug where libraries also weren't sending their images over OPDS

* All but dropdown options have been validated and tested.

* Fixed up some default cases for setting up the filter.

* Sorted filter fields and re-keyed to be better suited based on user's needs.

Fixed a bug where OPDS Series (from library view) wasn't showing the summary.

Moved the (Format) from the title to the description to make the UX much better for OPDS.

MOved

* don't send empty summaries in the new summary formatting

* Fixed up some default cases for setting up the filter.

* Fixed the reset button

* Fixed infinite scroller not having correct scope key

* Added localization to the new components and removed old debug code

* Styling fixes

* Fixed deep linking across the app. Made it so you can click Characters from Reading list and open a filtered search.

* A bit of styling for mobile

* Don't show language if it's not properly set

---------

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2023-08-11 16:30:36 -05:00 committed by GitHub
parent bc2a12a9cd
commit 9cc5953d07
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
102 changed files with 3299 additions and 1827 deletions

View file

@ -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<int> GetTotalFiles();
IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId);
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds);
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds);
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync();
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int>? libraryIds);
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
Task<bool> DoAnySeriesFoldersMatch(IEnumerable<string> folders);
Task<string?> GetLibraryCoverImageAsync(int libraryId);
@ -260,10 +260,10 @@ public class LibraryRepository : ILibraryRepository
.ToListAsync();
}
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds)
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int>? 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<IList<LanguageDto>> 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<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds)

View file

@ -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
/// <returns></returns>
Task AddSeriesModifiers(int userId, IList<SeriesDto> series);
Task<string?> GetSeriesCoverImageAsync(int seriesId);
Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter);
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter);
Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId);
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
@ -118,6 +121,7 @@ public interface ISeriesRepository
Task<SeriesDto?> GetSeriesForMangaFile(int mangaFileId, int userId);
Task<SeriesDto?> GetSeriesForChapter(int chapterId, int userId);
Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetWantToReadForUserV2Async(int userId, UserParams userParams, FilterV2Dto filter);
Task<IList<Series>> GetWantToReadForUserAsync(int userId);
Task<bool> IsSeriesInWantToRead(int userId, int seriesId);
Task<Series?> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
@ -140,6 +144,7 @@ public interface ISeriesRepository
Task<int> GetAverageUserRating(int seriesId, int userId);
Task RemoveFromOnDeck(int seriesId, int userId);
Task ClearOnDeckRemoval(int seriesId, int userId);
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto);
}
public class SeriesRepository : ISeriesRepository
@ -300,6 +305,7 @@ public class SeriesRepository : ISeriesRepository
/// <param name="userParams"></param>
/// <param name="filter"></param>
/// <returns></returns>
[Obsolete("Use GetSeriesDtoForLibraryIdAsync")]
public async Task<PagedList<SeriesDto>> 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<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto)
{
var query = await CreateFilteredSearchQueryableV2(userId, filterDto, QueryContext.None);
var retSeries = query
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series)
{
@ -644,7 +662,6 @@ public class SeriesRepository : ISeriesRepository
}
/// <summary>
/// Returns a list of Series that were added, ordered by Created desc
/// </summary>
@ -653,6 +670,7 @@ public class SeriesRepository : ISeriesRepository
/// <param name="userParams">Contains pagination information</param>
/// <param name="filter">Optional filter on query</param>
/// <returns></returns>
[Obsolete("Use GetRecentlyAddedV2")]
public async Task<PagedList<SeriesDto>> 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<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter)
{
var query = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.Dashboard);
var retSeries = query
.OrderByDescending(s => s.Created)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
private IList<MangaFormat> ExtractFilters(int libraryId, int userId, FilterDto filter, ref List<int> userLibraries,
out List<int> allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter,
out bool hasRatingFilter, out bool hasProgressFilter, out IList<int> seriesIds, out bool hasAgeRating, out bool hasTagsFilter,
@ -759,7 +790,7 @@ public class SeriesRepository : ISeriesRepository
/// <param name="userParams">Pagination information</param>
/// <param name="filter">Optional (default null) filter on query</param>
/// <returns></returns>
public async Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter)
public async Task<PagedList<SeriesDto>> 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<IQueryable<Series>> 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<IQueryable<Series>> CreateFilteredSearchQueryableV2(int userId, FilterV2Dto filter, QueryContext queryContext, IQueryable<Series>? 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<int>();
// 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<Series> BuildFilterQuery(int userId, FilterV2Dto filterDto, IQueryable<Series> 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<Series> ApplyLimit(IQueryable<Series> query, int limit)
{
return limit <= 0 ? query : query.Take(limit);
}
private static IQueryable<Series> BuildFilterGroup(int userId, FilterStatementDto statement, IQueryable<Series> 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<PublicationStatus>) value),
FilterField.Languages => query.HasLanguage(true, statement.Comparison, (IList<string>) value),
FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList<AgeRating>) value),
FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId),
FilterField.Tags => query.HasTags(true, statement.Comparison, (IList<int>) value),
FilterField.CollectionTags => query.HasCollectionTags(true, statement.Comparison, (IList<int>) value),
FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Editor => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.CoverArtist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Letterer => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) 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<MangaFormat>) 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<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, IQueryable<Series> 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<SeriesMetadataDto?> GetSeriesMetadata(int seriesId)
@ -1615,6 +1731,7 @@ public class SeriesRepository : ISeriesRepository
.AsEnumerable();
}
[Obsolete("Use GetWantToReadForUserV2Async")]
public async Task<PagedList<SeriesDto>> 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<SeriesDto>.CreateAsync(filteredQuery.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> 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<SeriesDto>.CreateAsync(filteredQuery.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize);
}
public async Task<IList<Series>> GetWantToReadForUserAsync(int userId)
{
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();

View file

@ -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<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterDto filter);
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterV2Dto filter);
Task<IEnumerable<AppUserBookmark>> GetAllBookmarksAsync();
Task<AppUserBookmark?> GetBookmarkForPage(int page, int chapterId, int userId);
Task<AppUserBookmark?> GetBookmarkAsync(int bookmarkId);
@ -374,29 +375,71 @@ public class UserRepository : IUserRepository
/// <param name="userId"></param>
/// <param name="filter">Only supports SeriesNameQuery</param>
/// <returns></returns>
public async Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterDto filter)
public async Task<IEnumerable<BookmarkDto>> 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<BookmarkDto>(_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);