Lots of Filtering Fixes & New Fields (#2244)

* Added an id for komf userscript to help it inject into Kavita's UI without relying on strings, given localization.

* Still working the filter fields, there is a bug with selecting an input and it setting undefined like crazy. Path is coded but not tested or validated.

* Stashing changed. Really not sure what's happening. I'm seeing 2 constructor calls for one row. I'm seeing a field change trigger 400 events. Values aren't getting set correctly on default string.

I've made a ton of changes, when resuming this work, look at the diff. All of this can be reset excluding the Path work.

* Lots of comments but the double instantiation is due to the mobile drawer. Added an ngIf which seems to work.

* Fixed dropdown options triggering a ton of looped calls. Default limitTo to 0 when user empties blank or negative.

* Removed a ton of UserId db calls from a ton of apis. Added a new API to allow UI to query a specific role to lessen load on UI.

* Optimized the code on new filtering to only load people by a given role. This should speed up heavily tagged libraries.

Commented out a bunch of code that's no longer used. Will be cleaned up later.

* Fixed support so that library filter can handle multiple selections.

* Fixed a bug when hitting enter in an input, the statement would be removed.

* Fixed multi-select not resuming from url correctly.

* Restored the series/all api for Tachiyomi to continue using until I'm motivated enough to update the extension.

* Fixed some resuming of state with dropdowns, not always setting values in correct order.

* Added FilePath Filter which lets a user search on individual files (slow, may need index)

* Added a full filepath for new filtering.
This commit is contained in:
Joe Milazzo 2023-08-29 16:03:19 -07:00 committed by GitHub
parent 69b5530a93
commit cd84913fb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 576 additions and 238 deletions

View File

@ -87,8 +87,7 @@ public class DeviceController : BaseApiController
[HttpGet]
public async Task<ActionResult<IEnumerable<DeviceDto>>> GetDevices()
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.DeviceRepository.GetDevicesForUserAsync(userId));
return Ok(await _unitOfWork.DeviceRepository.GetDevicesForUserAsync(User.GetUserId()));
}
[HttpPost("send-to")]
@ -100,7 +99,7 @@ public class DeviceController : BaseApiController
if (await _emailService.IsDefaultEmailService())
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"),
"started"), userId);
@ -134,7 +133,7 @@ public class DeviceController : BaseApiController
if (await _emailService.IsDefaultEmailService())
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"),
"started"), userId);

View File

@ -161,8 +161,7 @@ public class LibraryController : BaseApiController
[HttpGet("jump-bar")]
public async Task<ActionResult<IEnumerable<JumpKeyDto>>> GetJumpBar(int libraryId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
if (!await _unitOfWork.UserRepository.HasAccessToLibrary(libraryId, userId))
if (!await _unitOfWork.UserRepository.HasAccessToLibrary(libraryId, User.GetUserId()))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-library-access"));
return Ok(_unitOfWork.LibraryRepository.GetJumpBarAsync(libraryId));

View File

@ -37,17 +37,28 @@ public class MetadataController : BaseApiController
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(string? libraryIds)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0)
{
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, userId));
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId()));
}
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync(userId));
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync(User.GetUserId()));
}
/// <summary>
/// Fetches people from the instance by role
/// </summary>
/// <param name="role">role</param>
/// <returns></returns>
[HttpGet("people-by-role")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"role"})]
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(PersonRole? role)
{
return role.HasValue ?
Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role!.Value)) :
Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
}
/// <summary>
/// Fetches people from the instance
@ -58,13 +69,12 @@ public class MetadataController : BaseApiController
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(string? libraryIds)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0)
{
return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, userId));
return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId()));
}
return Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(userId));
return Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
}
/// <summary>
@ -76,13 +86,12 @@ public class MetadataController : BaseApiController
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
public async Task<ActionResult<IList<TagDto>>> GetAllTags(string? libraryIds)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0)
{
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, userId));
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId()));
}
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosAsync(userId));
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosAsync(User.GetUserId()));
}
/// <summary>

View File

@ -39,8 +39,7 @@ public class ReadingListController : BaseApiController
[HttpGet]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetList(int readingListId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, userId));
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, User.GetUserId()));
}
/// <summary>
@ -54,8 +53,7 @@ public class ReadingListController : BaseApiController
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForUser([FromQuery] UserParams userParams,
bool includePromoted = true, bool sortByLastModified = false)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, includePromoted,
var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(User.GetUserId(), includePromoted,
userParams, sortByLastModified);
Response.AddPaginationHeader(items.CurrentPage, items.PageSize, items.TotalCount, items.TotalPages);
@ -70,10 +68,8 @@ public class ReadingListController : BaseApiController
[HttpGet("lists-for-series")]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForSeries(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForSeriesAndUserAsync(userId, seriesId, true);
return Ok(items);
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtosForSeriesAndUserAsync(User.GetUserId(),
seriesId, true));
}
/// <summary>

View File

@ -62,7 +62,7 @@ public class ReviewController : BaseApiController
}
var cacheKey = CacheKey + seriesId;
IEnumerable<UserReviewDto> externalReviews;
IList<UserReviewDto> externalReviews;
var result = await _cacheProvider.GetAsync<IEnumerable<UserReviewDto>>(cacheKey);
if (result.HasValue)
@ -74,7 +74,6 @@ public class ReviewController : BaseApiController
var reviews = (await _reviewService.GetReviewsForSeries(userId, seriesId)).ToList();
externalReviews = SelectSpectrumOfReviews(reviews);
await _cacheProvider.SetAsync(cacheKey, externalReviews, TimeSpan.FromHours(10));
_logger.LogDebug("Caching external reviews for {Key}", cacheKey);
}
@ -87,7 +86,7 @@ public class ReviewController : BaseApiController
return Ok(userRatings);
}
private static IList<UserReviewDto> SelectSpectrumOfReviews(List<UserReviewDto> reviews)
private static IList<UserReviewDto> SelectSpectrumOfReviews(IList<UserReviewDto> reviews)
{
IList<UserReviewDto> externalReviews;
var totalReviews = reviews.Count;

View File

@ -33,8 +33,7 @@ public class SearchController : BaseApiController
[HttpGet("series-for-mangafile")]
public async Task<ActionResult<SeriesDto>> GetSeriesForMangaFile(int mangaFileId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId));
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, User.GetUserId()));
}
/// <summary>
@ -46,8 +45,7 @@ public class SearchController : BaseApiController
[HttpGet("series-for-chapter")]
public async Task<ActionResult<SeriesDto>> GetSeriesForChapter(int chapterId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId));
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, User.GetUserId()));
}
[HttpGet("search")]

View File

@ -67,7 +67,7 @@ public class SeriesController : BaseApiController
[Obsolete("use v2")]
public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto);
@ -90,7 +90,7 @@ public class SeriesController : BaseApiController
[HttpPost("v2")]
public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibraryV2([FromQuery] UserParams userParams, [FromBody] FilterV2Dto filterDto)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto);
@ -114,8 +114,7 @@ public class SeriesController : BaseApiController
[HttpGet("{seriesId:int}")]
public async Task<ActionResult<SeriesDto>> GetSeries(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, User.GetUserId());
if (series == null) return NoContent();
return Ok(series);
}
@ -150,15 +149,13 @@ public class SeriesController : BaseApiController
[HttpGet("volumes")]
public async Task<ActionResult<IEnumerable<VolumeDto>>> GetVolumes(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId));
return Ok(await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, User.GetUserId()));
}
[HttpGet("volume")]
public async Task<ActionResult<VolumeDto?>> GetVolume(int volumeId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var vol = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId);
var vol = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, User.GetUserId());
if (vol == null) return NoContent();
return Ok(vol);
}
@ -253,7 +250,7 @@ public class SeriesController : BaseApiController
[Obsolete("use recently-added-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, userId, userParams, filterDto);
@ -277,7 +274,7 @@ public class SeriesController : BaseApiController
[HttpPost("recently-added-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRecentlyAddedV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, userParams, filterDto);
@ -299,8 +296,7 @@ public class SeriesController : BaseApiController
[HttpPost("recently-updated-series")]
public async Task<ActionResult<IEnumerable<RecentlyAddedItemDto>>> GetRecentlyAddedChapters()
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId, 20));
return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(User.GetUserId(), 20));
}
/// <summary>
@ -310,10 +306,10 @@ public class SeriesController : BaseApiController
/// <param name="userParams"></param>
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpPost("all")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeries(FilterV2Dto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
[HttpPost("all-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto);
@ -327,6 +323,31 @@ public class SeriesController : BaseApiController
return Ok(series);
}
/// <summary>
/// Returns all series for the library. Obsolete, use all-v2
/// </summary>
/// <param name="filterDto"></param>
/// <param name="userParams"></param>
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpPost("all")]
[Obsolete("User all-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, 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);
}
/// <summary>
/// Fetches series that are on deck aka have progress on them.
/// </summary>
@ -337,7 +358,7 @@ public class SeriesController : BaseApiController
[HttpPost("on-deck")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetOnDeck([FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, null);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, pagedList);
@ -419,25 +440,24 @@ public class SeriesController : BaseApiController
[HttpPost("metadata")]
public async Task<ActionResult> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto)
{
if (await _seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto))
if (!await _seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "update-metadata-fail"));
if (await _licenseService.HasActiveLicense())
{
if (await _licenseService.HasActiveLicense())
_logger.LogDebug("Clearing cache as series weblinks may have changed");
await _reviewCacheProvider.RemoveAsync(ReviewController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId);
await _ratingCacheProvider.RemoveAsync(RatingController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId);
var allUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(s => s.Id);
foreach (var userId in allUsers)
{
_logger.LogDebug("Clearing cache as series weblinks may have changed");
await _reviewCacheProvider.RemoveAsync(ReviewController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId);
await _ratingCacheProvider.RemoveAsync(RatingController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId);
var allUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(s => s.Id);
foreach (var userId in allUsers)
{
await _recommendationCacheProvider.RemoveAsync(RecommendedController.CacheKey + $"{updateSeriesMetadataDto.SeriesMetadata.SeriesId}-{userId}");
}
await _recommendationCacheProvider.RemoveAsync(RecommendedController.CacheKey + $"{updateSeriesMetadataDto.SeriesMetadata.SeriesId}-{userId}");
}
return Ok(await _localizationService.Translate(User.GetUserId(), "series-updated"));
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "update-metadata-fail"));
return Ok(await _localizationService.Translate(User.GetUserId(), "series-updated"));
}
/// <summary>
@ -449,7 +469,7 @@ public class SeriesController : BaseApiController
[HttpGet("series-by-collection")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetSeriesByCollectionTag(int collectionId, [FromQuery] UserParams userParams)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, userParams);
@ -472,8 +492,7 @@ public class SeriesController : BaseApiController
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeriesById(SeriesByIdsDto dto)
{
if (dto.SeriesIds == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-payload"));
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, userId));
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, User.GetUserId()));
}
/// <summary>
@ -503,10 +522,9 @@ public class SeriesController : BaseApiController
[HttpGet("series-detail")]
public async Task<ActionResult<SeriesDetailDto>> GetSeriesDetailBreakdown(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
try
{
return await _seriesService.GetSeriesDetail(seriesId, userId);
return await _seriesService.GetSeriesDetail(seriesId, User.GetUserId());
}
catch (KavitaException ex)
{
@ -525,9 +543,7 @@ public class SeriesController : BaseApiController
[HttpGet("related")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRelatedSeries(int seriesId, RelationKind relation)
{
// Send back a custom DTO with each type or maybe sorted in some way
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForRelationKind(userId, seriesId, relation));
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForRelationKind(User.GetUserId(), seriesId, relation));
}
/// <summary>
@ -538,8 +554,7 @@ public class SeriesController : BaseApiController
[HttpGet("all-related")]
public async Task<ActionResult<RelatedSeriesDto>> GetAllRelatedSeries(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _seriesService.GetRelatedSeries(userId, seriesId));
return Ok(await _seriesService.GetRelatedSeries(User.GetUserId(), seriesId));
}

View File

@ -68,10 +68,9 @@ public class UsersController : BaseApiController
[HttpGet("has-reading-progress")]
public async Task<ActionResult<bool>> HasReadingProgress(int libraryId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId);
if (library == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-doesnt-exist"));
return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, userId));
return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, User.GetUserId()));
}
[HttpGet("has-library-access")]

View File

@ -28,5 +28,13 @@ public enum FilterField
ReadProgress = 20,
Formats = 21,
ReleaseYear = 22,
ReadTime = 23
ReadTime = 23,
/// <summary>
/// Series Folder
/// </summary>
Path = 24,
/// <summary>
/// File path
/// </summary>
FilePath = 25
}

View File

@ -3,6 +3,7 @@ using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Extensions.QueryExtensions;
using AutoMapper;
@ -17,9 +18,11 @@ public interface IPersonRepository
void Remove(Person person);
Task<IList<Person>> GetAllPeople();
Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId);
Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role);
Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false);
Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(List<int> libraryIds, int userId);
Task<int> GetCountAsync();
}
public class PersonRepository : IPersonRepository
@ -94,4 +97,15 @@ public class PersonRepository : IPersonRepository
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role)
{
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.Person
.Where(p => p.Role == role)
.OrderBy(p => p.Name)
.RestrictAgainstAgeRestriction(ageRating)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
}

View File

@ -840,7 +840,6 @@ 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)
@ -869,7 +868,7 @@ public class SeriesRepository : ISeriesRepository
.HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!)
.HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0)
// This needs different treatment
// TODO: This needs different treatment
.HasPeople(hasPeopleFilter, FilterComparison.Contains, allPeopleIds)
.WhereIf(onlyParentSeries,
@ -979,11 +978,12 @@ public class SeriesRepository : ISeriesRepository
{
if (stmt.Comparison is FilterComparison.Equal or FilterComparison.Contains)
{
filterIncludeLibs.Add(int.Parse(stmt.Value));
filterIncludeLibs.AddRange(stmt.Value.Split(',').Select(int.Parse));
}
else
{
filterExcludeLibs.Add(int.Parse(stmt.Value));
filterExcludeLibs.AddRange(stmt.Value.Split(',').Select(int.Parse));
}
}
@ -1036,6 +1036,8 @@ public class SeriesRepository : ISeriesRepository
{
FilterField.Summary => query.HasSummary(true, statement.Comparison, (string) value),
FilterField.SeriesName => query.HasName(true, statement.Comparison, (string) value),
FilterField.Path => query.HasPath(true, statement.Comparison, (string) value),
FilterField.FilePath => query.HasFilePath(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),

View File

@ -6,6 +6,7 @@ using System.Linq.Expressions;
using API.DTOs.Filtering.v2;
using API.Entities;
using API.Entities.Enums;
using API.Services.Tasks.Scanner.Parser;
using Kavita.Common;
using Microsoft.EntityFrameworkCore;
@ -512,4 +513,116 @@ public static class SeriesFilter
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
}
}
public static IQueryable<Series> HasPath(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, string queryString)
{
if (!condition) return queryable;
var normalizedPath = Parser.NormalizePath(queryString);
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.FolderPath != null && s.FolderPath.Equals(normalizedPath));
case FilterComparison.BeginsWith:
return queryable.Where(s => s.FolderPath != null && EF.Functions.Like(s.FolderPath, $"{normalizedPath}%"));
case FilterComparison.EndsWith:
return queryable.Where(s => s.FolderPath != null && EF.Functions.Like(s.FolderPath, $"%{normalizedPath}"));
case FilterComparison.Matches:
return queryable.Where(s => s.FolderPath != null && EF.Functions.Like(s.FolderPath, $"%{normalizedPath}%"));
case FilterComparison.NotEqual:
return queryable.Where(s => s.FolderPath != null && s.FolderPath != normalizedPath);
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.FolderPath");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
}
}
public static IQueryable<Series> HasFilePath(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, string queryString)
{
if (!condition) return queryable;
var normalizedPath = Parser.NormalizePath(queryString);
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s =>
s.Volumes.Any(v =>
v.Chapters.Any(c =>
c.Files.Any(f =>
f.FilePath != null && f.FilePath.Equals(normalizedPath)
)
)
)
);
case FilterComparison.BeginsWith:
return queryable.Where(s =>
s.Volumes.Any(v =>
v.Chapters.Any(c =>
c.Files.Any(f =>
f.FilePath != null && EF.Functions.Like(f.FilePath, $"{normalizedPath}%")
)
)
)
);
case FilterComparison.EndsWith:
return queryable.Where(s =>
s.Volumes.Any(v =>
v.Chapters.Any(c =>
c.Files.Any(f =>
f.FilePath != null && EF.Functions.Like(f.FilePath, $"%{normalizedPath}")
)
)
)
);
case FilterComparison.Matches:
return queryable.Where(s =>
s.Volumes.Any(v =>
v.Chapters.Any(c =>
c.Files.Any(f =>
f.FilePath != null && EF.Functions.Like(f.FilePath, $"%{normalizedPath}%")
)
)
)
);
case FilterComparison.NotEqual:
return queryable.Where(s =>
s.Volumes.Any(v =>
v.Chapters.Any(c =>
c.Files.Any(f =>
f.FilePath == null || !f.FilePath.Equals(normalizedPath)
)
)
)
);
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.FolderPath");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
}
}
}

View File

@ -13,6 +13,8 @@ public static class FilterFieldValueConverter
return field switch
{
FilterField.SeriesName => (value, typeof(string)),
FilterField.Path => (value, typeof(string)),
FilterField.FilePath => (value, typeof(string)),
FilterField.ReleaseYear => (int.Parse(value), typeof(int)),
FilterField.Languages => (value.Split(',').ToList(), typeof(IList<string>)),
FilterField.PublicationStatus => (value.Split(',')

View File

@ -37,7 +37,7 @@
"file-saver": "^2.0.5",
"lazysizes": "^5.3.2",
"ng-circle-progress": "^1.7.1",
"ng-select2-component": "^13.0.2",
"ng-select2-component": "^13.0.6",
"ngx-color-picker": "^14.0.0",
"ngx-extended-pdf-viewer": "^16.2.16",
"ngx-file-drop": "^16.0.0",
@ -10558,9 +10558,9 @@
}
},
"node_modules/ng-select2-component": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-13.0.2.tgz",
"integrity": "sha512-8Tms5p0V/0J0vCWOf2Vrk6tJlwbaf3D3As3iigcjRncYlfXN130agniBcZ007C3zK2KyLXJJRkEWzlCls8/TVQ==",
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-13.0.6.tgz",
"integrity": "sha512-CiAelglSz2aeYy0BiXRi32zc49Mq27+J1eDzTrXmf2o50MvNo3asS3NRVQcnSldo/zLcJafWCMueVfjVaV1etw==",
"dependencies": {
"ngx-infinite-scroll": ">=16.0.0",
"tslib": "^2.3.0"

View File

@ -42,7 +42,7 @@
"file-saver": "^2.0.5",
"lazysizes": "^5.3.2",
"ng-circle-progress": "^1.7.1",
"ng-select2-component": "^13.0.2",
"ng-select2-component": "^13.0.6",
"ngx-color-picker": "^14.0.0",
"ngx-extended-pdf-viewer": "^16.2.16",
"ngx-file-drop": "^16.0.0",

View File

@ -24,7 +24,9 @@ export enum FilterField
ReadProgress = 20,
Formats = 21,
ReleaseYear = 22,
ReadTime = 23
ReadTime = 23,
Path = 24,
FilePath = 25
}
export const allFields = Object.keys(FilterField)

View File

@ -8,7 +8,7 @@ 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 {Person, PersonRole} from '../_models/metadata/person';
import {Tag} from '../_models/tag';
import {TextResonse} from '../_types/text-response';
import {FilterComparison} from '../_models/metadata/v2/filter-comparison';
@ -33,44 +33,44 @@ export class MetadataService {
constructor(private httpClient: HttpClient, private router: Router) { }
applyFilter(page: Array<any>, 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));
// applyFilter(page: Array<any>, 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<string>(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();
//
// }
// Creates a temp name for the filter
this.httpClient.post<string>(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<SeriesFilterV2>(this.baseUrl + 'filter?name=' + filterName);
// }
}
getFilter(filterName: string) {
return this.httpClient.get<SeriesFilterV2>(this.baseUrl + 'filter?name=' + filterName);
}
getAgeRating(ageRating: AgeRating) {
if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {
return of(this.ageRatingTypes[ageRating]);
}
return this.httpClient.get<string>(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, TextResonse).pipe(map(ratingString => {
if (this.ageRatingTypes === undefined) {
this.ageRatingTypes = {};
}
this.ageRatingTypes[ageRating] = ratingString;
return this.ageRatingTypes[ageRating];
}));
}
// getAgeRating(ageRating: AgeRating) {
// if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {
// return of(this.ageRatingTypes[ageRating]);
// }
// return this.httpClient.get<string>(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, TextResonse).pipe(map(ratingString => {
// if (this.ageRatingTypes === undefined) {
// this.ageRatingTypes = {};
// }
//
// this.ageRatingTypes[ageRating] = ratingString;
// return this.ageRatingTypes[ageRating];
// }));
// }
getAllAgeRatings(libraries?: Array<number>) {
let method = 'metadata/age-ratings'
@ -132,10 +132,14 @@ export class MetadataService {
return this.httpClient.get<Array<Person>>(this.baseUrl + method);
}
getChapterSummary(chapterId: number) {
return this.httpClient.get<string>(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, TextResonse);
getAllPeopleByRole(role: PersonRole) {
return this.httpClient.get<Array<Person>>(this.baseUrl + 'metadata/people-by-role?role=' + role);
}
// getChapterSummary(chapterId: number) {
// return this.httpClient.get<string>(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, TextResonse);
// }
createDefaultFilterDto(): SeriesFilterV2 {
return {
statements: [] as FilterStatement[],
@ -159,6 +163,6 @@ export class MetadataService {
updateFilter(arr: Array<FilterStatement>, index: number, filterStmt: FilterStatement) {
arr[index].comparison = filterStmt.comparison;
arr[index].field = filterStmt.field;
arr[index].value = filterStmt.value + '';
arr[index].value = filterStmt.value ? filterStmt.value + '' : '';
}
}

View File

@ -39,7 +39,7 @@ export class SeriesService {
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = filter || {};
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series/all', data, {observe: 'response', params}).pipe(
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series/all-v2', data, {observe: 'response', params}).pipe(
map((response: any) => {
return this.utilityService.createPaginatedResult(response, this.paginatedResults);
})

View File

@ -115,12 +115,14 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
}
ngOnInit(): void {
console.log('[card-detail-layout] ngOnInit')
if (this.trackByIdentity === undefined) {
this.trackByIdentity = (_: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`;
}
if (this.filterSettings === undefined) {
this.filterSettings = new FilterSettings();
console.log('[card-detail-layout] creating blank FilterSettings');
this.cdRef.markForCheck();
}
@ -178,6 +180,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
this.applyFilter.emit(event);
this.updateApplied++;
this.filter = event.filterV2;
console.log('[card-detail-layout] apply filter')
this.cdRef.markForCheck();
}

View File

@ -135,6 +135,11 @@ export class LibraryDetailComponent implements OnInit {
}
}
get Debug() {
console.log('rendered section ');
return 0;
}
constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService,
private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService,
private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,

View File

@ -1,9 +1,8 @@
<ng-container *transloco="let t; read: 'metadata-builder'">
<ng-container *ngIf="filter">
<ng-container *ngIf="utilityService.getActiveBreakpoint() === Breakpoint.Desktop; else mobileView">
<div class="container-fluid">
<form [formGroup]="formGroup">
<form [formGroup]="formGroup">
<ng-container *ngIf="utilityService.getActiveBreakpoint() === Breakpoint.Desktop; else mobileView">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-md-2">
<select class="form-select" formControlName="comparison">
@ -12,33 +11,30 @@
</div>
<div class="col-md-2">
<button class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')" [disabled]="statementLimit === -1 || (statementLimit > 0 && filter.statements.length >= statementLimit)">
<button type="button" class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')" [disabled]="statementLimit === -1 || (statementLimit > 0 && filter.statements.length >= statementLimit)">
<i class="fa fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden" aria-hidden="true">{{t('add-rule')}}</span>
</button>
</div>
</div>
</form>
<div class="row mb-2" *ngFor="let filterStmt of filter.statements; let i = index">
<div class="col-md-10">
<app-metadata-row-filter [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
<div class="col-md-1 ms-2">
<button class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule', {num: i})" (click)="removeFilter(i)" *ngIf="i < (filter.statements.length - 1) && filter.statements.length > 1">
<i class="fa-solid fa-minus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove-rule', {num: i})}}</span>
</button>
</div>
</app-metadata-row-filter>
<div class="row mb-2" *ngFor="let filterStmt of filter.statements; let i = index">
<div class="col-md-10">
<app-metadata-row-filter [index]="i + 100" [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
<div class="col-md-1 ms-2">
<button type="button" class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule', {num: i})" (click)="removeFilter(i)" *ngIf="i < (filter.statements.length - 1) && filter.statements.length > 1">
<i class="fa-solid fa-minus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove-rule', {num: i})}}</span>
</button>
</div>
</app-metadata-row-filter>
</div>
</div>
</div>
</div>
</ng-container>
</ng-container>
<ng-template #mobileView>
<!-- TODO: Robbie please help me style this drawer only view -->
<div class="container-fluid">
<form [formGroup]="formGroup">
<ng-template #mobileView>
<div class="container-fluid">
<div class="row mb-3">
<div class="col-md-2 col-10">
<select class="form-select" formControlName="comparison">
@ -53,22 +49,21 @@
</button>
</div>
</div>
</form>
<div class="row mb-3" *ngFor="let filterStmt of filter.statements; let i = index">
<div class="col-md-12">
<app-metadata-row-filter [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
<div class="col-md-1 ms-2 col-1">
<button class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule')" (click)="removeFilter(i)" *ngIf="i < (filter.statements.length - 1) && filter.statements.length > 1">
<i class="fa-solid fa-minus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove-rule')}}</span>
</button>
</div>
</app-metadata-row-filter>
<div class="row mb-3" *ngFor="let filterStmt of filter.statements; let i = index">
<div class="col-md-12">
<app-metadata-row-filter [index]="i" [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
<div class="col-md-1 ms-2 col-1">
<button type="button" class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule')" (click)="removeFilter(i)" *ngIf="i < (filter.statements.length - 1) && filter.statements.length > 1">
<i class="fa-solid fa-minus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('remove-rule')}}</span>
</button>
</div>
</app-metadata-row-filter>
</div>
</div>
</div>
</div>
</ng-template>
</ng-template>
</form>
</ng-container>
</ng-container>

View File

@ -19,10 +19,9 @@ import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular
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 {allFields} from "../../../_models/metadata/v2/filter-field";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {tap} from "rxjs/operators";
import {distinctUntilChanged, tap} from "rxjs/operators";
import {translate, TranslocoDirective} from "@ngneat/transloco";
@Component({
@ -52,12 +51,14 @@ export class MetadataBuilderComponent implements OnInit {
@Input() statementLimit = 0;
@Input() availableFilterFields = allFields;
@Output() update: EventEmitter<SeriesFilterV2> = new EventEmitter<SeriesFilterV2>();
@Output() apply: EventEmitter<void> = new EventEmitter<void>();
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);
protected readonly Breakpoint = Breakpoint;
formGroup: FormGroup = new FormGroup({});
@ -66,39 +67,30 @@ export class MetadataBuilderComponent implements OnInit {
{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
});
}
console.log('[builder] ngOnInit');
this.formGroup.addControl('comparison', new FormControl<FilterCombination>(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.formGroup.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef), tap(values => {
this.filter.combination = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterCombination;
console.log('[builder] emitting filter from comparison change');
this.update.emit(this.filter);
})).subscribe()
})).subscribe();
}
addFilter() {
console.log('[builder] Adding Filter')
this.filter.statements = [this.metadataService.createDefaultFilterStatement(), ...this.filter.statements];
this.cdRef.markForCheck();
}
removeFilter(index: number) {
console.log('[builder] Removing filter')
this.filter.statements = this.filter.statements.slice(0, index).concat(this.filter.statements.slice(index + 1))
this.cdRef.markForCheck();
}
updateFilter(index: number, filterStmt: FilterStatement) {
console.log('[builder] updating filter: ', this.filter.statements);
this.metadataService.updateFilter(this.filter.statements, index, filterStmt);
this.update.emit(this.filter);
}

View File

@ -2,11 +2,9 @@
<form [formGroup]="formGroup">
<div class="row g-0">
<div class="col-md-3 me-2 col-10 mb-2">
<ng-container *ngIf="formGroup.get('input') as control">
<select class="form-select me-2" formControlName="input">
<option *ngFor="let field of availableFields" [value]="field">{{field | filterField}}</option>
</select>
</ng-container>
<select class="form-select me-2" formControlName="input">
<option *ngFor="let field of availableFields" [value]="field">{{field | filterField}}</option>
</select>
</div>
<div class="col-md-2 me-2 col-10 mb-2">
@ -22,7 +20,7 @@
<input type="text" class="form-control me-2" autocomplete="true" formControlName="filterValue">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Number">
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue">
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue" min="0">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Dropdown">
<ng-container *ngIf="dropdownOptions$ | async as opts">

View File

@ -3,15 +3,23 @@ import {
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
inject,
EventEmitter, inject,
Input,
OnInit,
Output
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 {
BehaviorSubject,
distinctUntilChanged, filter,
map,
Observable,
of,
startWith,
switchMap,
tap
} from 'rxjs';
import {MetadataService} from 'src/app/_services/metadata.service';
import {mangaFormatFilters} from 'src/app/_models/metadata/series-filter';
import {PersonRole} from 'src/app/_models/metadata/person';
@ -32,7 +40,7 @@ enum PredicateType {
Dropdown = 3,
}
const StringFields = [FilterField.SeriesName, FilterField.Summary];
const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath];
const NumberFields = [FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, FilterField.UserRating];
const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating,
FilterField.Translators, FilterField.Characters, FilterField.Publisher,
@ -81,6 +89,7 @@ const DropdownComparisons = [FilterComparison.Equal,
})
export class MetadataFilterRowComponent implements OnInit {
@Input() index: number = 0; // This is only for debugging
/**
* Slightly misleading as this is the initial state and will be updated on the filterStatement event emitter
*/
@ -100,9 +109,7 @@ export class MetadataFilterRowComponent implements OnInit {
dropdownOptions$ = of<Select2Option[]>([]);
loaded: boolean = false;
get PredicateType() { return PredicateType };
protected readonly PredicateType = PredicateType;
get MultipleDropdownAllowed() {
const comp = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison;
@ -113,38 +120,37 @@ export class MetadataFilterRowComponent implements OnInit {
private readonly collectionTagService: CollectionTagService) {}
ngOnInit() {
console.log('[ngOnInit] creating stmt (' + this.index + '): ', this.preset)
this.formGroup.addControl('input', new FormControl<FilterField>(FilterField.SeriesName, []));
this.formGroup.get('input')?.valueChanges.subscribe((val: string) => this.handleFieldChange(val));
this.formGroup.get('input')?.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe((val: string) => this.handleFieldChange(val));
this.populateFromPreset();
this.formGroup.get('filterValue')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef), tap(v => console.log('filterValue: ', v))).subscribe();
// Dropdown dynamic option selection
this.dropdownOptions$ = this.formGroup.get('input')!.valueChanges.pipe(
startWith(this.preset.value),
switchMap((_) => this.getDropdownObservable()),
tap((opts) => {
if (!this.formGroup.get('filterValue')?.value) {
this. populateFromPreset();
return;
}
if (this.MultipleDropdownAllowed) {
this.formGroup.get('filterValue')?.setValue((opts[0].value + '').split(','));
} else {
this.formGroup.get('filterValue')?.setValue(opts[0].value);
}
distinctUntilChanged(),
filter(() => {
const inputVal = parseInt(this.formGroup.get('input')?.value, 10) as FilterField;
return DropdownFields.includes(inputVal);
}),
switchMap((_) => this.getDropdownObservable()),
takeUntilDestroyed(this.destroyRef)
);
this.formGroup.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe(_ => {
this.filterStatement.emit({
this.formGroup!.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe(_ => {
const stmt = {
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!
});
};
if (!stmt.value && stmt.field !== FilterField.SeriesName) return;
console.log('updating parent with new statement: ', stmt.value)
this.filterStatement.emit(stmt);
});
this.loaded = true;
@ -152,30 +158,37 @@ export class MetadataFilterRowComponent implements OnInit {
}
populateFromPreset() {
const val = this.preset.value === "undefined" || !this.preset.value ? '' : this.preset.value;
console.log('populating preset: ', val);
this.formGroup.get('comparison')?.patchValue(this.preset.comparison);
this.formGroup.get('input')?.patchValue(this.preset.field);
if (StringFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(this.preset.value);
this.formGroup.get('filterValue')?.patchValue(val);
} else if (DropdownFields.includes(this.preset.field)) {
if (this.MultipleDropdownAllowed) {
this.formGroup.get('filterValue')?.setValue(this.preset.value.split(','));
if (this.MultipleDropdownAllowed || val.includes(',')) {
console.log('setting multiple values: ', val.split(',').map(d => parseInt(d, 10)));
this.formGroup.get('filterValue')?.patchValue(val.split(',').map(d => parseInt(d, 10)));
} else {
if (this.preset.field === FilterField.Languages) {
this.formGroup.get('filterValue')?.setValue(this.preset.value);
this.formGroup.get('filterValue')?.patchValue(val);
} else {
this.formGroup.get('filterValue')?.setValue(parseInt(this.preset.value, 10));
this.formGroup.get('filterValue')?.patchValue(parseInt(val, 10));
}
}
} else {
this.formGroup.get('filterValue')?.patchValue(parseInt(this.preset.value, 10));
this.formGroup.get('filterValue')?.patchValue(parseInt(val, 10));
}
this.formGroup.get('comparison')?.patchValue(this.preset.comparison);
this.formGroup.get('input')?.setValue(this.preset.field);
this.cdRef.markForCheck();
}
getDropdownObservable(): Observable<Select2Option[]> {
const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField;
console.log('Getting dropdown observable: ', filterField);
switch (filterField) {
case FilterField.PublicationStatus:
return this.metadataService.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => {
@ -224,41 +237,46 @@ export class MetadataFilterRowComponent implements OnInit {
}
getPersonOptions(role: PersonRole) {
return this.metadataService.getAllPeople().pipe(map(people => people.filter(p2 => p2.role === role).map(person => {
return this.metadataService.getAllPeopleByRole(role).pipe(map(people => people.map(person => {
return {value: person.id, label: person.name}
})))
})));
}
handleFieldChange(val: string) {
const inputVal = parseInt(val, 10) as FilterField;
console.log('HandleFieldChange: ', val);
if (StringFields.includes(inputVal)) {
this.validComparisons$.next(StringComparisons);
this.predicateType$.next(PredicateType.Text);
if (this.loaded) this.formGroup.get('filterValue')?.setValue('');
if (this.loaded) {
this.formGroup.get('filterValue')?.patchValue('',{emitEvent: false});
console.log('setting filterValue to empty string', this.formGroup.get('filterValue')?.value)
} // BUG: undefined is getting set and the input value isn't updating and emitting to the backend
return;
}
if (NumberFields.includes(inputVal)) {
let comps = [...NumberComparisons];
const 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('');
if (this.loaded) this.formGroup.get('filterValue')?.patchValue(0);
return;
}
if (DropdownFields.includes(inputVal)) {
let comps = [...DropdownComparisons];
const comps = [...DropdownComparisons];
if (inputVal === FilterField.AgeRating) {
comps.push(...NumberComparisons);
}
this.validComparisons$.next(comps);
this.predicateType$.next(PredicateType.Dropdown);
return;
}
}

View File

@ -58,6 +58,10 @@ export class FilterFieldPipe implements PipeTransform {
return translate('filter-field-pipe.user-rating');
case FilterField.Writers:
return translate('filter-field-pipe.writers');
case FilterField.Path:
return translate('filter-field-pipe.path');
case FilterField.FilePath:
return translate('filter-field-pipe.file-path');
default:
throw new Error(`Invalid FilterField value: ${value}`);
}

View File

@ -1,17 +1,18 @@
<ng-container *transloco="let t; read: 'metadata-filter'">
<ng-container *ngIf="toggleService.toggleState$ | async as isOpen">
<div class="phone-hidden">
<div class="phone-hidden" *ngIf="utilityService.getActiveBreakpoint() > Breakpoint.Tablet">
<div #collapse="ngbCollapse" [ngbCollapse]="!isOpen" (ngbCollapseChange)="setToggle($event)">
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
</div>
</div>
<div class="not-phone-hidden">
<div class="not-phone-hidden" *ngIf="utilityService.getActiveBreakpoint() < Breakpoint.Desktop">
<app-drawer #commentDrawer="drawer" [isOpen]="isOpen" [options]="{topOffset: 56}" (drawerClosed)="toggleService.set(false)">
<h5 header>
{{t('filter-title')}}
</h5>
<div body class="drawer-body">
<!-- TODO: BUG: Filter section is instantiated twice if this isn't ngIf'd -->
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
</div>
</app-drawer>
@ -19,9 +20,13 @@
</ng-container>
<ng-template #filterSection>
<div class="filter-section mx-auto pb-3" *ngIf="fullyLoaded">
<div class="filter-section mx-auto pb-3" *ngIf="fullyLoaded && filterV2">
<div class="row justify-content-center g-0">
<app-metadata-builder [filter]="filterV2!" [availableFilterFields]="allFilterFields" (update)="handleFilters($event)" [statementLimit]="filterSettings.statementLimit"></app-metadata-builder>
<app-metadata-builder [filter]="filterV2"
[availableFilterFields]="allFilterFields"
[statementLimit]="filterSettings.statementLimit"
(update)="handleFilters($event)">
</app-metadata-builder>
</div>
<form [formGroup]="sortGroup" class="container-fluid">
<div class="row mb-3">

View File

@ -76,6 +76,7 @@ export class MetadataFilterComponent implements OnInit {
allFilterFields = allFields;
handleFilters(filter: SeriesFilterV2) {
console.log('[metadata-filter] updating filter');
this.filterV2 = filter;
}
@ -86,6 +87,7 @@ export class MetadataFilterComponent implements OnInit {
constructor(public toggleService: ToggleService) {}
ngOnInit(): void {
console.log('[metadata-filter] ngOnInit')
if (this.filterSettings === undefined) {
this.filterSettings = new FilterSettings();
this.cdRef.markForCheck();
@ -137,6 +139,7 @@ export class MetadataFilterComponent implements OnInit {
loadFromPresetsAndSetup() {
this.fullyLoaded = false;
console.log('[metadata-filter] loading from preset and setting up');
this.filterV2 = this.deepClone(this.filterSettings.presetsV2);
this.sortGroup = new FormGroup({
@ -145,6 +148,7 @@ export class MetadataFilterComponent implements OnInit {
});
this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
console.log('[metadata-filter] sortGroup value change');
if (this.filterV2?.sortOptions === null) {
this.filterV2.sortOptions = {
isAscending: this.isAscendingSort,
@ -152,12 +156,11 @@ export class MetadataFilterComponent implements OnInit {
};
}
this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
this.filterV2!.limitTo = parseInt(this.sortGroup.get('limitTo')?.value, 10);
this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0);
this.cdRef.markForCheck();
});
this.fullyLoaded = true;
this.cdRef.markForCheck();
this.apply();
}
@ -173,6 +176,7 @@ export class MetadataFilterComponent implements OnInit {
}
this.filterV2!.sortOptions!.isAscending = this.isAscendingSort;
console.log('[metadata-filter] updated filter sort order')
}
clear() {
@ -181,7 +185,6 @@ export class MetadataFilterComponent implements OnInit {
}
apply() {
this.applyFilter.emit({isFirst: this.updateApplied === 0, filterV2: this.filterV2!});
if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile && this.updateApplied !== 0) {

View File

@ -1731,7 +1731,9 @@
"tags": "Tags",
"translators": "Translators",
"user-rating": "User Rating",
"writers": "Writers"
"writers": "Writers",
"path": "Path",
"file-path": "File Path"
},
"filter-comparison-pipe": {

View File

@ -3031,6 +3031,69 @@
}
}
},
"/api/Metadata/people-by-role": {
"get": {
"tags": [
"Metadata"
],
"summary": "Fetches people from the instance by role",
"parameters": [
{
"name": "role",
"in": "query",
"description": "role",
"schema": {
"enum": [
1,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12
],
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonDto"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonDto"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonDto"
}
}
}
}
}
}
}
},
"/api/Metadata/people": {
"get": {
"tags": [
@ -8012,7 +8075,7 @@
}
}
},
"/api/Series/all": {
"/api/Series/all-v2": {
"post": {
"tags": [
"Series"
@ -8100,6 +8163,95 @@
}
}
},
"/api/Series/all": {
"post": {
"tags": [
"Series"
],
"summary": "Returns all series for the library. Obsolete, use all-v2",
"parameters": [
{
"name": "PageNumber",
"in": "query",
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "PageSize",
"in": "query",
"description": "If set to 0, will set as MaxInt",
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "libraryId",
"in": "query",
"description": "",
"schema": {
"type": "integer",
"format": "int32",
"default": 0
}
}
],
"requestBody": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FilterDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/FilterDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/FilterDto"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SeriesDto"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SeriesDto"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SeriesDto"
}
}
}
}
}
},
"deprecated": true
}
},
"/api/Series/on-deck": {
"post": {
"tags": [
@ -13610,7 +13762,9 @@
20,
21,
22,
23
23,
24,
25
],
"type": "integer",
"description": "Represents the field which will dictate the value type and the Extension used for filtering",