Merge branch 'develop' of https://github.com/Kareadita/Kavita into develop

This commit is contained in:
majora2007 2023-08-11 21:36:42 +00:00
commit 6fcba79d8f
102 changed files with 3299 additions and 1827 deletions

View File

@ -0,0 +1,28 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.DTOs.Filtering.v2;
using API.Extensions.QueryExtensions.Filtering;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace API.Tests.Extensions;
public class SeriesFilterTests : AbstractDbTest
{
protected override Task ResetDb()
{
return Task.CompletedTask;
}
#region HasLanguage
[Fact]
public async Task HasLanguage_Works()
{
var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Contains, new List<string>() { }).ToListAsync();
}
#endregion
}

View File

@ -15,6 +15,10 @@ public static class EasyCacheProfiles
/// Cache the libraries on the server /// Cache the libraries on the server
/// </summary> /// </summary>
public const string Library = "library"; public const string Library = "library";
/// <summary>
/// Metadata filter
/// </summary>
public const string Filter = "filter";
public const string KavitaPlusReviews = "kavita+reviews"; public const string KavitaPlusReviews = "kavita+reviews";
public const string KavitaPlusRecommendations = "kavita+recommendations"; public const string KavitaPlusRecommendations = "kavita+recommendations";
public const string KavitaPlusRatings = "kavita+ratings"; public const string KavitaPlusRatings = "kavita+ratings";

View File

@ -0,0 +1,59 @@
using System;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Filtering.v2;
using EasyCaching.Core;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
/// <summary>
/// This is responsible for Filter caching
/// </summary>
public class FilterController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IEasyCachingProviderFactory _cacheFactory;
public FilterController(IUnitOfWork unitOfWork, IEasyCachingProviderFactory cacheFactory)
{
_unitOfWork = unitOfWork;
_cacheFactory = cacheFactory;
}
[HttpGet]
public async Task<ActionResult<FilterV2Dto?>> GetFilter(string name)
{
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter);
if (string.IsNullOrEmpty(name)) return Ok(null);
var filter = await provider.GetAsync<FilterV2Dto>(name);
if (filter.HasValue)
{
filter.Value.Name = name;
return Ok(filter.Value);
}
return Ok(null);
}
/// <summary>
/// Caches the filter in the backend and returns a temp string for retrieving.
/// </summary>
/// <remarks>The cache line lives for only 1 hour</remarks>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost("create-temp")]
public async Task<ActionResult<string>> CreateTempFilter(FilterV2Dto filterDto)
{
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter);
var name = filterDto.Name;
if (string.IsNullOrEmpty(filterDto.Name))
{
name = Guid.NewGuid().ToString();
}
await provider.SetAsync(name, filterDto, TimeSpan.FromHours(1));
return name;
}
}

View File

@ -138,19 +138,14 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param> /// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
/// <returns></returns> /// <returns></returns>
[HttpGet("languages")] [HttpGet("languages")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})] [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new []{"libraryIds"})]
public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds) public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds)
{ {
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids is {Count: > 0}) return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
{
return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
}
return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync());
} }
[HttpGet("all-languages")] [HttpGet("all-languages")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
public IEnumerable<LanguageDto> GetAllValidLanguages() public IEnumerable<LanguageDto> GetAllValidLanguages()
@ -163,6 +158,7 @@ public class MetadataController : BaseApiController
}).Where(l => !string.IsNullOrEmpty(l.IsoCode)); }).Where(l => !string.IsNullOrEmpty(l.IsoCode));
} }
/// <summary> /// <summary>
/// Returns summary for the chapter /// Returns summary for the chapter
/// </summary> /// </summary>

View File

@ -10,6 +10,7 @@ using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.OPDS; using API.DTOs.OPDS;
using API.DTOs.Search; using API.DTOs.Search;
using API.Entities; using API.Entities;
@ -65,6 +66,8 @@ public class OpdsController : BaseApiController
SortOptions = null, SortOptions = null,
PublicationStatus = new List<PublicationStatus>() PublicationStatus = new List<PublicationStatus>()
}; };
private readonly FilterV2Dto _filterV2Dto = new FilterV2Dto();
private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default; private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default;
private const int PageSize = 20; private const int PageSize = 20;
@ -201,6 +204,8 @@ public class OpdsController : BaseApiController
Links = new List<FeedLink>() Links = new List<FeedLink>()
{ {
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries/{library.Id}"), CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries/{library.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}")
} }
}); });
} }
@ -226,21 +231,19 @@ public class OpdsController : BaseApiController
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix); var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix);
SetFeedId(feed, "collections"); SetFeedId(feed, "collections");
foreach (var tag in tags)
feed.Entries.AddRange(tags.Select(tag => new FeedEntry()
{ {
feed.Entries.Add(new FeedEntry() Id = tag.Id.ToString(),
Title = tag.Title,
Summary = tag.Summary,
Links = new List<FeedLink>()
{ {
Id = tag.Id.ToString(), CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"),
Title = tag.Title, CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
Summary = tag.Summary, CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
Links = new List<FeedLink>() }
{ }));
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionId={tag.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionId={tag.Id}&apiKey={apiKey}")
}
});
}
return CreateXmlResult(SerializeXml(feed)); return CreateXmlResult(SerializeXml(feed));
} }
@ -315,6 +318,8 @@ public class OpdsController : BaseApiController
Links = new List<FeedLink>() Links = new List<FeedLink>()
{ {
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"), CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}")
} }
}); });
} }
@ -378,17 +383,27 @@ public class OpdsController : BaseApiController
return BadRequest(await _localizationService.Translate(userId, "no-library-access")); return BadRequest(await _localizationService.Translate(userId, "no-library-access"));
} }
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, GetUserParams(pageNumber), _filterDto); var filter = new FilterV2Dto
{
Statements = new List<FilterStatementDto>() {
new ()
{
Comparison = FilterComparison.Equal,
Field = FilterField.Libraries,
Value = libraryId + string.Empty
}
}
};
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber), filter);
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id)); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id));
var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey, prefix); var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey, prefix);
SetFeedId(feed, $"library-{library.Name}"); SetFeedId(feed, $"library-{library.Name}");
AddPagination(feed, series, $"{prefix}{apiKey}/libraries/{libraryId}"); AddPagination(feed, series, $"{prefix}{apiKey}/libraries/{libraryId}");
foreach (var seriesDto in series) feed.Entries.AddRange(series.Select(seriesDto =>
{ CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)));
feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
}
return CreateXmlResult(SerializeXml(feed)); return CreateXmlResult(SerializeXml(feed));
} }
@ -401,7 +416,7 @@ public class OpdsController : BaseApiController
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix(); var (baseUrl, prefix) = await GetPrefix();
var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, userId, GetUserParams(pageNumber), _filterDto); var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, GetUserParams(pageNumber), _filterV2Dto);
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id)); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id));
var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{prefix}{apiKey}/recently-added", apiKey, prefix); var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{prefix}{apiKey}/recently-added", apiKey, prefix);
@ -730,8 +745,10 @@ public class OpdsController : BaseApiController
return new FeedEntry() return new FeedEntry()
{ {
Id = seriesDto.Id.ToString(), Id = seriesDto.Id.ToString(),
Title = $"{seriesDto.Name} ({seriesDto.Format})", Title = $"{seriesDto.Name}",
Summary = seriesDto.Summary, Summary = $"Format: {seriesDto.Format}" + (string.IsNullOrWhiteSpace(metadata.Summary)
? string.Empty
: $" Summary: {metadata.Summary}"),
Authors = metadata.Writers.Select(p => new FeedAuthor() Authors = metadata.Writers.Select(p => new FeedAuthor()
{ {
Name = p.Name, Name = p.Name,
@ -756,7 +773,8 @@ public class OpdsController : BaseApiController
return new FeedEntry() return new FeedEntry()
{ {
Id = searchResultDto.SeriesId.ToString(), Id = searchResultDto.SeriesId.ToString(),
Title = $"{searchResultDto.Name} ({searchResultDto.Format})", Title = $"{searchResultDto.Name}",
Summary = $"Format: {searchResultDto.Format}",
Links = new List<FeedLink>() Links = new List<FeedLink>()
{ {
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/series/{searchResultDto.SeriesId}"), CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/series/{searchResultDto.SeriesId}"),

View File

@ -8,6 +8,7 @@ using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Reader; using API.DTOs.Reader;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
@ -596,7 +597,7 @@ public class ReaderController : BaseApiController
/// <param name="filterDto">Only supports SeriesNameQuery</param> /// <param name="filterDto">Only supports SeriesNameQuery</param>
/// <returns></returns> /// <returns></returns>
[HttpPost("all-bookmarks")] [HttpPost("all-bookmarks")]
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetAllBookmarks(FilterDto filterDto) public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetAllBookmarks(FilterV2Dto filterDto)
{ {
return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(User.GetUserId(), filterDto)); return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(User.GetUserId(), filterDto));
} }

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants; using API.Constants;
@ -6,6 +7,7 @@ using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.SeriesDetail; using API.DTOs.SeriesDetail;
using API.Entities; using API.Entities;
@ -53,7 +55,16 @@ public class SeriesController : BaseApiController
_recommendationCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations); _recommendationCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
} }
/// <summary>
/// Gets series with the applied Filter
/// </summary>
/// <remarks>This is considered v1 and no longer used by Kavita, but will be supported for sometime. See series/v2</remarks>
/// <param name="libraryId"></param>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost] [HttpPost]
[Obsolete("use v2")]
public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto) 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 = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
@ -70,6 +81,30 @@ public class SeriesController : BaseApiController
return Ok(series); return Ok(series);
} }
/// <summary>
/// Gets series with the applied Filter
/// </summary>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost("v2")]
public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibraryV2([FromQuery] UserParams userParams, [FromBody] FilterV2Dto filterDto)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto);
//TODO: We might want something like libraryId as source so that I don't have to muck with the groups
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest("Could not get series for library");
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary> /// <summary>
/// Fetches a Series for a given Id /// Fetches a Series for a given Id
/// </summary> /// </summary>
@ -207,7 +242,7 @@ public class SeriesController : BaseApiController
} }
/// <summary> /// <summary>
/// Gets all recently added series /// Gets all recently added series. Obsolete, use recently-added-v2
/// </summary> /// </summary>
/// <param name="filterDto"></param> /// <param name="filterDto"></param>
/// <param name="userParams"></param> /// <param name="userParams"></param>
@ -215,6 +250,7 @@ public class SeriesController : BaseApiController
/// <returns></returns> /// <returns></returns>
[ResponseCache(CacheProfileName = "Instant")] [ResponseCache(CacheProfileName = "Instant")]
[HttpPost("recently-added")] [HttpPost("recently-added")]
[Obsolete("use recently-added-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) 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 = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
@ -231,6 +267,30 @@ public class SeriesController : BaseApiController
return Ok(series); return Ok(series);
} }
/// <summary>
/// Gets all recently added series
/// </summary>
/// <param name="filterDto"></param>
/// <param name="userParams"></param>
/// <returns></returns>
[ResponseCache(CacheProfileName = "Instant")]
[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 series =
await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series"));
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary> /// <summary>
/// Returns series that were recently updated, like adding or removing a chapter /// Returns series that were recently updated, like adding or removing a chapter
/// </summary> /// </summary>
@ -251,11 +311,11 @@ public class SeriesController : BaseApiController
/// <param name="libraryId"></param> /// <param name="libraryId"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost("all")] [HttpPost("all")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeries(FilterV2Dto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{ {
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var series = var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query) // 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")); if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series"));
@ -270,16 +330,15 @@ public class SeriesController : BaseApiController
/// <summary> /// <summary>
/// Fetches series that are on deck aka have progress on them. /// Fetches series that are on deck aka have progress on them.
/// </summary> /// </summary>
/// <param name="filterDto"></param>
/// <param name="userParams"></param> /// <param name="userParams"></param>
/// <param name="libraryId">Default of 0 meaning all libraries</param> /// <param name="libraryId">Default of 0 meaning all libraries</param>
/// <returns></returns> /// <returns></returns>
[ResponseCache(CacheProfileName = "Instant")] [ResponseCache(CacheProfileName = "Instant")]
[HttpPost("on-deck")] [HttpPost("on-deck")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetOnDeck(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) public async Task<ActionResult<IEnumerable<SeriesDto>>> GetOnDeck([FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{ {
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, filterDto); var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, null);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, pagedList); await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, pagedList);
@ -288,6 +347,7 @@ public class SeriesController : BaseApiController
return Ok(pagedList); return Ok(pagedList);
} }
/// <summary> /// <summary>
/// Removes a series from displaying on deck until the next read event on that series /// Removes a series from displaying on deck until the next read event on that series
/// </summary> /// </summary>

View File

@ -1,9 +1,11 @@
using System.Linq; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.WantToRead; using API.DTOs.WantToRead;
using API.Extensions; using API.Extensions;
using API.Helpers; using API.Helpers;
@ -33,12 +35,13 @@ public class WantToReadController : BaseApiController
} }
/// <summary> /// <summary>
/// Return all Series that are in the current logged in user's Want to Read list, filtered /// Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2)
/// </summary> /// </summary>
/// <param name="userParams"></param> /// <param name="userParams"></param>
/// <param name="filterDto"></param> /// <param name="filterDto"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost] [HttpPost]
[Obsolete("use v2 instead")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetWantToRead([FromQuery] UserParams userParams, FilterDto filterDto) public async Task<ActionResult<PagedList<SeriesDto>>> GetWantToRead([FromQuery] UserParams userParams, FilterDto filterDto)
{ {
userParams ??= new UserParams(); userParams ??= new UserParams();
@ -50,6 +53,24 @@ public class WantToReadController : BaseApiController
return Ok(pagedList); return Ok(pagedList);
} }
/// <summary>
/// Return all Series that are in the current logged in user's Want to Read list, filtered
/// </summary>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost("v2")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetWantToReadV2([FromQuery] UserParams userParams, FilterV2Dto filterDto)
{
userParams ??= new UserParams();
var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserV2Async(User.GetUserId(), userParams, filterDto);
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(User.GetUserId(), pagedList);
return Ok(pagedList);
}
[HttpGet] [HttpGet]
public async Task<ActionResult<bool>> IsSeriesInWantToRead([FromQuery] int seriesId) public async Task<ActionResult<bool>> IsSeriesInWantToRead([FromQuery] int seriesId)
{ {

View File

@ -0,0 +1,7 @@
namespace API.DTOs.Filtering.v2;
public enum FilterCombination
{
Or = 0,
And = 1
}

View File

@ -0,0 +1,51 @@
using System.ComponentModel;
namespace API.DTOs.Filtering.v2;
public enum FilterComparison
{
[Description("Equal")]
Equal = 0,
GreaterThan = 1,
GreaterThanEqual = 2,
LessThan = 3,
LessThanEqual = 4,
/// <summary>
///
/// </summary>
/// <remarks>Only works with IList</remarks>
Contains = 5,
/// <summary>
/// Performs a LIKE %value%
/// </summary>
Matches = 6,
NotContains = 7,
/// <summary>
/// Not Equal to
/// </summary>
NotEqual = 9,
/// <summary>
/// String starts with
/// </summary>
BeginsWith = 10,
/// <summary>
/// String ends with
/// </summary>
EndsWith = 11,
/// <summary>
/// Is Date before X
/// </summary>
IsBefore = 12,
/// <summary>
/// Is Date after X
/// </summary>
IsAfter = 13,
/// <summary>
/// Is Date between now and X seconds ago
/// </summary>
IsInLast = 14,
/// <summary>
/// Is Date not between now and X seconds ago
/// </summary>
IsNotInLast = 15,
}

View File

@ -0,0 +1,32 @@
namespace API.DTOs.Filtering.v2;
/// <summary>
/// Represents the field which will dictate the value type and the Extension used for filtering
/// </summary>
public enum FilterField
{
Summary = 0,
SeriesName = 1,
PublicationStatus = 2,
Languages = 3,
AgeRating = 4,
UserRating = 5,
Tags = 6,
CollectionTags = 7,
Translators = 8,
Characters = 9,
Publisher = 10,
Editor = 11,
CoverArtist = 12,
Letterer = 13,
Colorist = 14,
Inker = 15,
Penciller = 16,
Writers = 17,
Genres = 18,
Libraries = 19,
ReadProgress = 20,
Formats = 21,
ReleaseYear = 22,
ReadTime = 23
}

View File

@ -0,0 +1,8 @@
namespace API.DTOs.Filtering.v2;
public class FilterStatementDto
{
public FilterComparison Comparison { get; set; }
public FilterField Field { get; set; }
public string Value { get; set; }
}

View File

@ -0,0 +1,30 @@
using System.Collections.Generic;
namespace API.DTOs.Filtering.v2;
/// <summary>
/// Metadata filtering for v2 API only
/// </summary>
public class FilterV2Dto
{
/// <summary>
/// The name of the filter
/// </summary>
public string? Name { get; set; }
public ICollection<FilterStatementDto> Statements { get; set; } = new List<FilterStatementDto>();
public FilterCombination Combination { get; set; } = FilterCombination.And;
public SortOptions SortOptions { get; set; }
/// <summary>
/// Limit the number of rows returned. Defaults to not applying a limit (aka 0)
/// </summary>
public int LimitTo { get; set; } = 0;
}

View File

@ -12,7 +12,6 @@ public class SeriesDto : IHasReadTimeEstimate
public string? OriginalName { get; init; } public string? OriginalName { get; init; }
public string? LocalizedName { get; init; } public string? LocalizedName { get; init; }
public string? SortName { get; init; } public string? SortName { get; init; }
public string? Summary { get; init; }
public int Pages { get; init; } public int Pages { get; init; }
public bool CoverImageLocked { get; set; } public bool CoverImageLocked { get; set; }
/// <summary> /// <summary>

View File

@ -11,6 +11,7 @@ using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper; using AutoMapper;
using AutoMapper.QueryableExtensions; using AutoMapper.QueryableExtensions;
using Kavita.Common.Extensions; using Kavita.Common.Extensions;
@ -45,8 +46,7 @@ public interface ILibraryRepository
Task<int> GetTotalFiles(); Task<int> GetTotalFiles();
IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId); IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId);
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds); Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds);
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds); Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int>? libraryIds);
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync();
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds); IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
Task<bool> DoAnySeriesFoldersMatch(IEnumerable<string> folders); Task<bool> DoAnySeriesFoldersMatch(IEnumerable<string> folders);
Task<string?> GetLibraryCoverImageAsync(int libraryId); Task<string?> GetLibraryCoverImageAsync(int libraryId);
@ -260,10 +260,10 @@ public class LibraryRepository : ILibraryRepository
.ToListAsync(); .ToListAsync();
} }
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds) public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int>? libraryIds)
{ {
var ret = await _context.Series 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) .Select(s => s.Metadata.Language)
.AsSplitQuery() .AsSplitQuery()
.AsNoTracking() .AsNoTracking()
@ -272,33 +272,33 @@ public class LibraryRepository : ILibraryRepository
return ret return ret
.Where(s => !string.IsNullOrEmpty(s)) .Where(s => !string.IsNullOrEmpty(s))
.Select(s => new LanguageDto() .DistinctBy(Parser.Normalize)
{ .Select(GetCulture)
Title = CultureInfo.GetCultureInfo(s).DisplayName, .Where(s => s != null)
IsoCode = s
})
.OrderBy(s => s.Title) .OrderBy(s => s.Title)
.ToList(); .ToList();
} }
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync() private static LanguageDto GetCulture(string s)
{ {
var ret = await _context.Series try
.Select(s => s.Metadata.Language) {
.AsSplitQuery() return new LanguageDto()
.AsNoTracking()
.Distinct()
.ToListAsync();
return ret
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => new LanguageDto()
{ {
Title = CultureInfo.GetCultureInfo(s).DisplayName, Title = CultureInfo.GetCultureInfo(s).DisplayName,
IsoCode = s IsoCode = s
}) };
.OrderBy(s => s.Title) }
.ToList(); catch (Exception)
{
// ignored
}
return new LanguageDto()
{
Title = s,
IsoCode = s
};;
} }
public IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds) public IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds)

View File

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -10,6 +9,7 @@ using API.Data.Scanner;
using API.DTOs; using API.DTOs;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.ReadingLists; using API.DTOs.ReadingLists;
using API.DTOs.Search; using API.DTOs.Search;
@ -20,7 +20,9 @@ using API.Entities.Enums;
using API.Entities.Metadata; using API.Entities.Metadata;
using API.Extensions; using API.Extensions;
using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions;
using API.Extensions.QueryExtensions.Filtering;
using API.Helpers; using API.Helpers;
using API.Helpers.Converters;
using API.Services; using API.Services;
using API.Services.Tasks; using API.Services.Tasks;
using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner;
@ -95,8 +97,9 @@ public interface ISeriesRepository
/// <returns></returns> /// <returns></returns>
Task AddSeriesModifiers(int userId, IList<SeriesDto> series); Task AddSeriesModifiers(int userId, IList<SeriesDto> series);
Task<string?> GetSeriesCoverImageAsync(int seriesId); 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>> 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<SeriesMetadataDto?> GetSeriesMetadata(int seriesId);
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
Task<IList<MangaFile>> GetFilesForSeries(int seriesId); Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
@ -118,6 +121,7 @@ public interface ISeriesRepository
Task<SeriesDto?> GetSeriesForMangaFile(int mangaFileId, int userId); Task<SeriesDto?> GetSeriesForMangaFile(int mangaFileId, int userId);
Task<SeriesDto?> GetSeriesForChapter(int chapterId, int userId); Task<SeriesDto?> GetSeriesForChapter(int chapterId, int userId);
Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter); 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<IList<Series>> GetWantToReadForUserAsync(int userId);
Task<bool> IsSeriesInWantToRead(int userId, int seriesId); Task<bool> IsSeriesInWantToRead(int userId, int seriesId);
Task<Series?> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None); Task<Series?> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
@ -140,6 +144,7 @@ public interface ISeriesRepository
Task<int> GetAverageUserRating(int seriesId, int userId); Task<int> GetAverageUserRating(int seriesId, int userId);
Task RemoveFromOnDeck(int seriesId, int userId); Task RemoveFromOnDeck(int seriesId, int userId);
Task ClearOnDeckRemoval(int seriesId, int userId); Task ClearOnDeckRemoval(int seriesId, int userId);
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto);
} }
public class SeriesRepository : ISeriesRepository public class SeriesRepository : ISeriesRepository
@ -300,6 +305,7 @@ public class SeriesRepository : ISeriesRepository
/// <param name="userParams"></param> /// <param name="userParams"></param>
/// <param name="filter"></param> /// <param name="filter"></param>
/// <returns></returns> /// <returns></returns>
[Obsolete("Use GetSeriesDtoForLibraryIdAsync")]
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter) public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter)
{ {
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.None); var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.None);
@ -605,6 +611,18 @@ public class SeriesRepository : ISeriesRepository
return await query.ToListAsync(); 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) public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series)
{ {
@ -644,7 +662,6 @@ public class SeriesRepository : ISeriesRepository
} }
/// <summary> /// <summary>
/// Returns a list of Series that were added, ordered by Created desc /// Returns a list of Series that were added, ordered by Created desc
/// </summary> /// </summary>
@ -653,6 +670,7 @@ public class SeriesRepository : ISeriesRepository
/// <param name="userParams">Contains pagination information</param> /// <param name="userParams">Contains pagination information</param>
/// <param name="filter">Optional filter on query</param> /// <param name="filter">Optional filter on query</param>
/// <returns></returns> /// <returns></returns>
[Obsolete("Use GetRecentlyAddedV2")]
public async Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter) public async Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter)
{ {
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.Dashboard); 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); 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, 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 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, 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="userParams">Pagination information</param>
/// <param name="filter">Optional (default null) filter on query</param> /// <param name="filter">Optional (default null) filter on query</param>
/// <returns></returns> /// <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 var settings = await _context.ServerSetting
.Select(x => x) .Select(x => x)
@ -780,11 +811,6 @@ public class SeriesRepository : ISeriesRepository
.Select(d => d.SeriesId) .Select(d => d.SeriesId)
.AsEnumerable(); .AsEnumerable();
// var onDeckRemovals = _context.AppUser.Where(u => u.Id == userId)
// .SelectMany(u => u.OnDeckRemovals.Select(d => d.Id))
// .AsEnumerable();
var query = _context.Series var query = _context.Series
.Where(s => usersSeriesIds.Contains(s.Id)) .Where(s => usersSeriesIds.Contains(s.Id))
.Where(s => !onDeckRemovals.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) 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? // 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 userLibraries = await GetUserLibrariesForFilteredQuery(libraryId, userId, queryContext);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId); var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
var onlyParentSeries = await _context.AppUserPreferences.Where(u => u.AppUserId == userId) var onlyParentSeries = await _context.AppUserPreferences.Where(u => u.AppUserId == userId)
@ -828,29 +855,47 @@ public class SeriesRepository : ISeriesRepository
var query = _context.Series var query = _context.Series
.AsNoTracking() .AsNoTracking()
.WhereIf(hasGenresFilter, s => s.Metadata.Genres.Any(g => filter.Genres.Contains(g.Id))) // This new style can handle any filterComparision coming from the user
.WhereIf(hasPeopleFilter, s => s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id))) .HasLanguage(hasLanguageFilter, FilterComparison.Contains, filter.Languages)
.WhereIf(hasCollectionTagFilter, .HasReleaseYear(hasReleaseYearMaxFilter, FilterComparison.LessThanEqual, filter.ReleaseYearRange?.Max)
s => s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id))) .HasReleaseYear(hasReleaseYearMinFilter, FilterComparison.GreaterThanEqual, filter.ReleaseYearRange?.Min)
.WhereIf(hasRatingFilter, s => s.Ratings.Any(r => r.Rating >= filter.Rating && r.AppUserId == userId)) .HasName(hasSeriesNameFilter, FilterComparison.Matches, filter.SeriesNameQuery)
.WhereIf(hasProgressFilter, s => seriesIds.Contains(s.Id)) .HasRating(hasRatingFilter, FilterComparison.GreaterThanEqual, filter.Rating, userId)
.WhereIf(hasAgeRating, s => filter.AgeRating.Contains(s.Metadata.AgeRating)) .HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating)
.WhereIf(hasTagsFilter, s => s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id))) .HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus)
.WhereIf(hasLanguageFilter, s => filter.Languages.Contains(s.Metadata.Language)) .HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags)
.WhereIf(hasReleaseYearMinFilter, s => s.Metadata.ReleaseYear >= filter.ReleaseYearRange!.Min) .HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags)
.WhereIf(hasReleaseYearMaxFilter, s => s.Metadata.ReleaseYear <= filter.ReleaseYearRange!.Max) .HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres)
.WhereIf(hasPublicationFilter, s => filter.PublicationStatus.Contains(s.Metadata.PublicationStatus)) .HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!)
.WhereIf(hasSeriesNameFilter, s => EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%") .HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0)
|| EF.Functions.Like(s.OriginalName!, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(s.LocalizedName!, $"%{filter.SeriesNameQuery}%")) // This needs different treatment
.HasPeople(hasPeopleFilter, FilterComparison.Contains, allPeopleIds)
.WhereIf(onlyParentSeries, .WhereIf(onlyParentSeries,
s => s.RelationOf.Count == 0 || s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel)) s => s.RelationOf.Count == 0 || s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel))
.Where(s => userLibraries.Contains(s.LibraryId)) .Where(s => userLibraries.Contains(s.LibraryId));
.Where(s => formats.Contains(s.Format));
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) if (userRating.AgeRating != AgeRating.NotApplicable)
{ {
// this if statement is included in the extension
query = query.RestrictAgainstAgeRestriction(userRating); 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) 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}%")) || EF.Functions.Like(s.LocalizedName!, $"%{filter.SeriesNameQuery}%"))
.Where(s => userLibraries.Contains(s.LibraryId) .Where(s => userLibraries.Contains(s.LibraryId)
&& formats.Contains(s.Format)) && formats.Contains(s.Format))
.Sort(filter.SortOptions)
.AsNoTracking(); .AsNoTracking();
// If no sort options, default to using SortName return query.AsSplitQuery();
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;
} }
public async Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId) public async Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId)
@ -1615,6 +1731,7 @@ public class SeriesRepository : ISeriesRepository
.AsEnumerable(); .AsEnumerable();
} }
[Obsolete("Use GetWantToReadForUserV2Async")]
public async Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter) public async Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter)
{ {
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); 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); 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) public async Task<IList<Series>> GetWantToReadForUserAsync(int userId)
{ {
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();

View File

@ -7,6 +7,7 @@ using API.Constants;
using API.DTOs; using API.DTOs;
using API.DTOs.Account; using API.DTOs.Account;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Reader; using API.DTOs.Reader;
using API.DTOs.Scrobbling; using API.DTOs.Scrobbling;
using API.DTOs.SeriesDetail; using API.DTOs.SeriesDetail;
@ -53,7 +54,7 @@ public interface IUserRepository
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId); Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId); Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId); 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<IEnumerable<AppUserBookmark>> GetAllBookmarksAsync();
Task<AppUserBookmark?> GetBookmarkForPage(int page, int chapterId, int userId); Task<AppUserBookmark?> GetBookmarkForPage(int page, int chapterId, int userId);
Task<AppUserBookmark?> GetBookmarkAsync(int bookmarkId); Task<AppUserBookmark?> GetBookmarkAsync(int bookmarkId);
@ -374,29 +375,71 @@ public class UserRepository : IUserRepository
/// <param name="userId"></param> /// <param name="userId"></param>
/// <param name="filter">Only supports SeriesNameQuery</param> /// <param name="filter">Only supports SeriesNameQuery</param>
/// <returns></returns> /// <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 var query = _context.AppUserBookmark
.Where(x => x.AppUserId == userId) .Where(x => x.AppUserId == userId)
.OrderBy(x => x.Created) .OrderBy(x => x.Created)
.AsNoTracking(); .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 return await query
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider) .ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .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 var filterSeriesQuery = query.Join(_context.Series, b => b.SeriesId, s => s.Id, (bookmark, series) => new
{ {
bookmark, bookmark,
series series
}) });
.Where(o => (EF.Functions.Like(o.series.Name, $"%{filter.SeriesNameQuery}%"))
|| (o.series.OriginalName != null && EF.Functions.Like(o.series.OriginalName, $"%{filter.SeriesNameQuery}%")) switch (filterStatement.Comparison)
|| (o.series.LocalizedName != null && EF.Functions.Like(o.series.LocalizedName, $"%{filter.SeriesNameQuery}%")) {
|| (EF.Functions.Like(o.series.NormalizedName, $"%{seriesNameQueryNormalized}%")) 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); query = filterSeriesQuery.Select(o => o.bookmark);

View File

@ -83,6 +83,7 @@ public static class ApplicationServiceExtensions
options.UseInMemory(EasyCacheProfiles.License); options.UseInMemory(EasyCacheProfiles.License);
options.UseInMemory(EasyCacheProfiles.Library); options.UseInMemory(EasyCacheProfiles.Library);
options.UseInMemory(EasyCacheProfiles.RevokedJwt); options.UseInMemory(EasyCacheProfiles.RevokedJwt);
options.UseInMemory(EasyCacheProfiles.Filter);
// KavitaPlus stuff // KavitaPlus stuff
options.UseInMemory(EasyCacheProfiles.KavitaPlusReviews); options.UseInMemory(EasyCacheProfiles.KavitaPlusReviews);

View File

@ -0,0 +1,515 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using API.DTOs.Filtering.v2;
using API.Entities;
using API.Entities.Enums;
using Kavita.Common;
using Microsoft.EntityFrameworkCore;
namespace API.Extensions.QueryExtensions.Filtering;
#nullable enable
public static class SeriesFilter
{
public static IQueryable<Series> HasLanguage(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<string> languages)
{
if (languages.Count == 0 || !condition) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.Language.Equals(languages.First()));
case FilterComparison.Contains:
return queryable.Where(s => languages.Contains(s.Metadata.Language));
case FilterComparison.NotContains:
return queryable.Where(s => !languages.Contains(s.Metadata.Language));
case FilterComparison.NotEqual:
return queryable.Where(s => !s.Metadata.Language.Equals(languages.First()));
case FilterComparison.Matches:
return queryable.Where(s => EF.Functions.Like(s.Metadata.Language, $"{languages.First()}%"));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasReleaseYear(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, int? releaseYear)
{
if (!condition || releaseYear == null) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.ReleaseYear == releaseYear);
case FilterComparison.GreaterThan:
case FilterComparison.IsAfter:
return queryable.Where(s => s.Metadata.ReleaseYear > releaseYear);
case FilterComparison.GreaterThanEqual:
return queryable.Where(s => s.Metadata.ReleaseYear >= releaseYear);
case FilterComparison.LessThan:
case FilterComparison.IsBefore:
return queryable.Where(s => s.Metadata.ReleaseYear < releaseYear);
case FilterComparison.LessThanEqual:
return queryable.Where(s => s.Metadata.ReleaseYear <= releaseYear);
case FilterComparison.IsInLast:
return queryable.Where(s => s.Metadata.ReleaseYear >= DateTime.Now.Year - (int) releaseYear);
case FilterComparison.IsNotInLast:
return queryable.Where(s => s.Metadata.ReleaseYear < DateTime.Now.Year - (int) releaseYear);
case FilterComparison.Matches:
case FilterComparison.Contains:
case FilterComparison.NotContains:
case FilterComparison.NotEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
throw new KavitaException($"{comparison} not applicable for Series.ReleaseYear");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasRating(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, int rating, int userId)
{
if (rating < 0 || !condition || userId <= 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Ratings.Any(r => r.Rating == rating && r.AppUserId == userId));
case FilterComparison.GreaterThan:
return queryable.Where(s => s.Ratings.Any(r => r.Rating > rating && r.AppUserId == userId));
case FilterComparison.GreaterThanEqual:
return queryable.Where(s => s.Ratings.Any(r => r.Rating >= rating && r.AppUserId == userId));
case FilterComparison.LessThan:
return queryable.Where(s => s.Ratings.Any(r => r.Rating < rating && r.AppUserId == userId));
case FilterComparison.LessThanEqual:
return queryable.Where(s => s.Ratings.Any(r => r.Rating <= rating && r.AppUserId == userId));
case FilterComparison.Contains:
case FilterComparison.Matches:
case FilterComparison.NotContains:
case FilterComparison.NotEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Rating");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasAgeRating(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<AgeRating> ratings)
{
if (!condition || ratings.Count == 0) return queryable;
var firstRating = ratings.First();
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.AgeRating == firstRating);
case FilterComparison.GreaterThan:
return queryable.Where(s => s.Metadata.AgeRating > firstRating);
case FilterComparison.GreaterThanEqual:
return queryable.Where(s => s.Metadata.AgeRating >= firstRating);
case FilterComparison.LessThan:
return queryable.Where(s => s.Metadata.AgeRating < firstRating);
case FilterComparison.LessThanEqual:
return queryable.Where(s => s.Metadata.AgeRating <= firstRating);
case FilterComparison.Contains:
return queryable.Where(s => ratings.Contains(s.Metadata.AgeRating));
case FilterComparison.NotContains:
return queryable.Where(s => !ratings.Contains(s.Metadata.AgeRating));
case FilterComparison.NotEqual:
return queryable.Where(s => s.Metadata.AgeRating != firstRating);
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.AgeRating");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasAverageReadTime(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, int avgReadTime)
{
if (!condition || avgReadTime < 0) return queryable;
switch (comparison)
{
case FilterComparison.NotEqual:
return queryable.Where(s => s.AvgHoursToRead != avgReadTime);
case FilterComparison.Equal:
return queryable.Where(s => s.AvgHoursToRead == avgReadTime);
case FilterComparison.GreaterThan:
return queryable.Where(s => s.AvgHoursToRead > avgReadTime);
case FilterComparison.GreaterThanEqual:
return queryable.Where(s => s.AvgHoursToRead >= avgReadTime);
case FilterComparison.LessThan:
return queryable.Where(s => s.AvgHoursToRead < avgReadTime);
case FilterComparison.LessThanEqual:
return queryable.Where(s => s.AvgHoursToRead <= avgReadTime);
case FilterComparison.Contains:
case FilterComparison.Matches:
case FilterComparison.NotContains:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.AverageReadTime");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasPublicationStatus(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<PublicationStatus> pubStatues)
{
if (!condition || pubStatues.Count == 0) return queryable;
var firstStatus = pubStatues.First();
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.PublicationStatus == firstStatus);
case FilterComparison.Contains:
return queryable.Where(s => pubStatues.Contains(s.Metadata.PublicationStatus));
case FilterComparison.NotContains:
return queryable.Where(s => !pubStatues.Contains(s.Metadata.PublicationStatus));
case FilterComparison.NotEqual:
return queryable.Where(s => s.Metadata.PublicationStatus != firstStatus);
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.Matches:
throw new KavitaException($"{comparison} not applicable for Series.PublicationStatus");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
/// <summary>
///
/// </summary>
/// <remarks>This is more taxing on memory as the percentage calculation must be done in Memory</remarks>
/// <exception cref="KavitaException"></exception>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public static IQueryable<Series> HasReadingProgress(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, int readProgress, int userId)
{
if (!condition) return queryable;
var subQuery = queryable
.Include(s => s.Progress)
.Where(s => s.Progress != null)
.Select(s => new
{
Series = s,
Percentage = Math.Truncate(((double) s.Progress
.Where(p => p != null && p.AppUserId == userId)
.Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0) * 100))
})
.AsEnumerable();
switch (comparison)
{
case FilterComparison.Equal:
subQuery = subQuery.Where(s => s.Percentage == readProgress);
break;
case FilterComparison.GreaterThan:
subQuery = subQuery.Where(s => s.Percentage > readProgress);
break;
case FilterComparison.GreaterThanEqual:
subQuery = subQuery.Where(s => s.Percentage >= readProgress);
break;
case FilterComparison.LessThan:
subQuery = subQuery.Where(s => s.Percentage < readProgress);
break;
case FilterComparison.LessThanEqual:
subQuery = subQuery.Where(s => s.Percentage <= readProgress);
break;
case FilterComparison.NotEqual:
subQuery = subQuery.Where(s => s.Percentage != readProgress);
break;
case FilterComparison.Matches:
case FilterComparison.Contains:
case FilterComparison.NotContains:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.ReadProgress");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
var ids = subQuery.Select(s => s.Series.Id).ToList();
return queryable.Where(s => ids.Contains(s.Id));
}
public static IQueryable<Series> HasTags(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> tags)
{
if (!condition || tags.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.Tags.Any(t => tags.Contains(t.Id)));
case FilterComparison.NotEqual:
case FilterComparison.NotContains:
return queryable.Where(s => s.Metadata.Tags.Any(t => !tags.Contains(t.Id)));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Tags");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasPeople(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> people)
{
if (!condition || people.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.Id)));
case FilterComparison.NotEqual:
case FilterComparison.NotContains:
return queryable.Where(s => s.Metadata.People.Any(t => !people.Contains(t.Id)));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.Matches:
throw new KavitaException($"{comparison} not applicable for Series.People");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasGenre(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> genres)
{
if (!condition || genres.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.Genres.Any(p => genres.Contains(p.Id)));
case FilterComparison.NotEqual:
case FilterComparison.NotContains:
return queryable.Where(s => s.Metadata.Genres.All(p => !genres.Contains(p.Id)));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Genres");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasFormat(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<MangaFormat> formats)
{
if (!condition || formats.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => formats.Contains(s.Format));
case FilterComparison.NotContains:
case FilterComparison.NotEqual:
return queryable.Where(s => !formats.Contains(s.Format));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Format");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasCollectionTags(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> collectionTags)
{
if (!condition || collectionTags.Count == 0) return queryable;
//var first = collectionTags.First();
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id)));
case FilterComparison.NotContains:
case FilterComparison.NotEqual:
return queryable.Where(s => !s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id)));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.CollectionTags");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasName(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, string queryString)
{
if (string.IsNullOrEmpty(queryString) || !condition) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Name.Equals(queryString)
|| s.OriginalName.Equals(queryString)
|| s.LocalizedName.Equals(queryString)
|| s.SortName.Equals(queryString));
case FilterComparison.BeginsWith:
return queryable.Where(s => EF.Functions.Like(s.Name, $"{queryString}%")
||EF.Functions.Like(s.OriginalName, $"{queryString}%")
|| EF.Functions.Like(s.LocalizedName, $"{queryString}%")
|| EF.Functions.Like(s.SortName, $"{queryString}%"));
case FilterComparison.EndsWith:
return queryable.Where(s => EF.Functions.Like(s.Name, $"%{queryString}")
||EF.Functions.Like(s.OriginalName, $"%{queryString}")
|| EF.Functions.Like(s.LocalizedName, $"%{queryString}")
|| EF.Functions.Like(s.SortName, $"%{queryString}"));
case FilterComparison.Matches:
return queryable.Where(s => EF.Functions.Like(s.Name, $"%{queryString}%")
||EF.Functions.Like(s.OriginalName, $"%{queryString}%")
|| EF.Functions.Like(s.LocalizedName, $"%{queryString}%")
|| EF.Functions.Like(s.SortName, $"%{queryString}%"));
case FilterComparison.NotEqual:
return queryable.Where(s => s.Name != queryString
|| s.OriginalName != queryString
|| s.LocalizedName != queryString
|| s.SortName != queryString);
case FilterComparison.NotContains:
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Contains:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Name");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
}
}
public static IQueryable<Series> HasSummary(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, string queryString)
{
if (string.IsNullOrEmpty(queryString) || !condition) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.Summary.Equals(queryString));
case FilterComparison.BeginsWith:
return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"{queryString}%"));
case FilterComparison.EndsWith:
return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"%{queryString}"));
case FilterComparison.Matches:
return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"%{queryString}%"));
case FilterComparison.NotEqual:
return queryable.Where(s => s.Metadata.Summary != queryString);
case FilterComparison.NotContains:
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Contains:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Metadata.Summary");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
}
}
}

View File

@ -0,0 +1,53 @@
using System.Linq;
using API.DTOs.Filtering;
using API.Entities;
namespace API.Extensions.QueryExtensions.Filtering;
public static class SeriesSort
{
/// <summary>
/// Applies the correct sort based on <see cref="SortOptions"/>
/// </summary>
/// <param name="query"></param>
/// <param name="sortOptions"></param>
/// <returns></returns>
public static IQueryable<Series> Sort(this IQueryable<Series> query, SortOptions? sortOptions)
{
// If no sort options, default to using SortName
sortOptions ??= new SortOptions()
{
IsAscending = true,
SortField = SortField.SortName
};
if (sortOptions.IsAscending)
{
query = sortOptions.SortField switch
{
SortField.SortName => query.OrderBy(s => s.SortName.ToLower()),
SortField.CreatedDate => query.OrderBy(s => s.Created),
SortField.LastModifiedDate => query.OrderBy(s => s.LastModified),
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
SortField.ReleaseYear => query.OrderBy(s => s.Metadata.ReleaseYear),
_ => query
};
}
else
{
query = sortOptions.SortField switch
{
SortField.SortName => query.OrderByDescending(s => s.SortName.ToLower()),
SortField.CreatedDate => query.OrderByDescending(s => s.Created),
SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified),
SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead),
SortField.ReleaseYear => query.OrderByDescending(s => s.Metadata.ReleaseYear),
_ => query
};
}
return query;
}
}

View File

@ -110,6 +110,55 @@ public static class QueryableExtensions
return condition ? queryable.Where(predicate) : queryable; return condition ? queryable.Where(predicate) : queryable;
} }
public static IQueryable<T> WhereLike<T>(this IQueryable<T> queryable, bool condition, Expression<Func<T, string>> propertySelector, string searchQuery)
where T : class
{
if (!condition || string.IsNullOrEmpty(searchQuery)) return queryable;
var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) });
var dbFunctions = typeof(EF).GetMethod(nameof(EF.Functions))?.Invoke(null, null);
var searchExpression = Expression.Constant($"%{searchQuery}%");
var likeExpression = Expression.Call(method, Expression.Constant(dbFunctions), propertySelector.Body, searchExpression);
var lambda = Expression.Lambda<Func<T, bool>>(likeExpression, propertySelector.Parameters[0]);
return queryable.Where(lambda);
}
/// <summary>
/// Performs a WhereLike that ORs multiple fields
/// </summary>
/// <param name="queryable"></param>
/// <param name="propertySelectors"></param>
/// <param name="searchQuery"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
/// <exception cref="ArgumentNullException"></exception>
public static IQueryable<T> WhereLike<T>(this IQueryable<T> queryable, bool condition, List<Expression<Func<T, string>>> propertySelectors, string searchQuery)
where T : class
{
if (!condition || string.IsNullOrEmpty(searchQuery)) return queryable;
var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) });
var dbFunctions = typeof(EF).GetMethod(nameof(EF.Functions))?.Invoke(null, null);
var searchExpression = Expression.Constant($"%{searchQuery}%");
Expression orExpression = null;
foreach (var propertySelector in propertySelectors)
{
var likeExpression = Expression.Call(method, Expression.Constant(dbFunctions), propertySelector.Body, searchExpression);
var lambda = Expression.Lambda<Func<T, bool>>(likeExpression, propertySelector.Parameters[0]);
orExpression = orExpression == null ? lambda.Body : Expression.OrElse(orExpression, lambda.Body);
}
if (orExpression == null)
{
throw new ArgumentNullException(nameof(orExpression));
}
var combinedLambda = Expression.Lambda<Func<T, bool>>(orExpression, propertySelectors[0].Parameters[0]);
return queryable.Where(combinedLambda);
}
public static IQueryable<ScrobbleEvent> SortBy(this IQueryable<ScrobbleEvent> query, ScrobbleEventSortField sort, bool isDesc = false) public static IQueryable<ScrobbleEvent> SortBy(this IQueryable<ScrobbleEvent> query, ScrobbleEventSortField sort, bool isDesc = false)
{ {
if (isDesc) if (isDesc)

View File

@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Linq;
using API.DTOs.Filtering.v2;
using API.Entities.Enums;
namespace API.Helpers.Converters;
public static class FilterFieldValueConverter
{
public static (object Value, Type Type) ConvertValue(FilterField field, string value)
{
return field switch
{
FilterField.SeriesName => (value, typeof(string)),
FilterField.ReleaseYear => (int.Parse(value), typeof(int)),
FilterField.Languages => (value.Split(',').ToList(), typeof(IList<string>)),
FilterField.PublicationStatus => (value.Split(',')
.Select(x => (PublicationStatus) Enum.Parse(typeof(PublicationStatus), x))
.ToList(), typeof(IList<PublicationStatus>)),
FilterField.Summary => (value, typeof(string)),
FilterField.AgeRating => (value.Split(',')
.Select(x => (AgeRating) Enum.Parse(typeof(AgeRating), x))
.ToList(), typeof(IList<AgeRating>)),
FilterField.UserRating => (int.Parse(value), typeof(int)),
FilterField.Tags => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.CollectionTags => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Translators => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Characters => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Publisher => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Editor => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.CoverArtist => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Letterer => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Colorist => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Inker => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Penciller => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Writers => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Genres => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Libraries => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.ReadProgress => (int.Parse(value), typeof(int)),
FilterField.Formats => (value.Split(',')
.Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x))
.ToList(), typeof(IList<MangaFormat>)),
FilterField.ReadTime => (int.Parse(value), typeof(int)),
_ => throw new ArgumentException("Invalid field type")
};
}
}

View File

@ -35,7 +35,7 @@ public class StatsService : IStatsService
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly DataContext _context; private readonly DataContext _context;
private readonly IStatisticService _statisticService; private readonly IStatisticService _statisticService;
private const string ApiUrl = "https://stats.kavitareader.com"; private const string ApiUrl = "https://stats.kavitareader.com"; // ""
public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork, DataContext context, IStatisticService statisticService) public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork, DataContext context, IStatisticService statisticService)
{ {

View File

@ -54,7 +54,6 @@ public class TokenService : ITokenService
}; };
var roles = await _userManager.GetRolesAsync(user); var roles = await _userManager.GetRolesAsync(user);
claims.AddRange(roles.Select(role => new Claim(Role, role))); claims.AddRange(roles.Select(role => new Claim(Role, role)));
var credentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature); var credentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature);

View File

@ -317,7 +317,7 @@ public class Startup
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod() .AllowAnyMethod()
.AllowCredentials() // For SignalR token query param .AllowCredentials() // For SignalR token query param
.WithOrigins("http://localhost:4200", $"http://{GetLocalIpAddress()}:4200", $"http://{GetLocalIpAddress()}:5000", "https://kavita.majora2007.duckdns.org") .WithOrigins("http://localhost:4200", $"http://{GetLocalIpAddress()}:4200", $"http://{GetLocalIpAddress()}:5000")
.WithExposedHeaders("Content-Disposition", "Pagination")); .WithExposedHeaders("Content-Disposition", "Pagination"));
} }
else else
@ -327,7 +327,6 @@ public class Startup
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod() .AllowAnyMethod()
.AllowCredentials() // For SignalR token query param .AllowCredentials() // For SignalR token query param
.WithOrigins("https://kavita.majora2007.duckdns.org")
.WithExposedHeaders("Content-Disposition", "Pagination")); .WithExposedHeaders("Content-Disposition", "Pagination"));
} }

View File

@ -1,4 +1,5 @@
export interface Language { export interface Language {
isoCode: string; isoCode: string;
title: string; title: string;
} }

View File

@ -1,4 +1,6 @@
import { MangaFormat } from "../manga-format"; import { MangaFormat } from "../manga-format";
import { SeriesFilterV2 } from "./v2/series-filter-v2";
import {FilterField} from "./v2/filter-field";
export interface FilterItem<T> { export interface FilterItem<T> {
title: string; title: string;
@ -6,38 +8,6 @@ export interface FilterItem<T> {
selected: boolean; selected: boolean;
} }
export interface Range<T> {
min: T;
max: T;
}
export interface SeriesFilter {
formats: Array<MangaFormat>;
libraries: Array<number>,
readStatus: ReadStatus;
genres: Array<number>;
writers: Array<number>;
artists: Array<number>;
penciller: Array<number>;
inker: Array<number>;
colorist: Array<number>;
letterer: Array<number>;
coverArtist: Array<number>;
editor: Array<number>;
publisher: Array<number>;
character: Array<number>;
translators: Array<number>;
collectionTags: Array<number>;
rating: number;
ageRating: Array<number>;
sortOptions: SortOptions | null;
tags: Array<number>;
languages: Array<string>;
publicationStatus: Array<number>;
seriesNameQuery: string;
releaseYearRange: Range<number> | null;
}
export interface SortOptions { export interface SortOptions {
sortField: SortField; sortField: SortField;
isAscending: boolean; isAscending: boolean;
@ -52,11 +22,9 @@ export enum SortField {
ReleaseYear = 6, ReleaseYear = 6,
} }
export interface ReadStatus { export const allSortFields = Object.keys(SortField)
notRead: boolean, .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
inProgress: boolean, .map(key => parseInt(key, 10)) as SortField[];
read: boolean,
}
export const mangaFormatFilters = [ export const mangaFormatFilters = [
{ {
@ -82,7 +50,7 @@ export const mangaFormatFilters = [
]; ];
export interface FilterEvent { export interface FilterEvent {
filter: SeriesFilter; filterV2: SeriesFilterV2;
isFirst: boolean; isFirst: boolean;
} }

View File

@ -0,0 +1,4 @@
export enum FilterCombination {
Or = 0,
And = 1
}

View File

@ -0,0 +1,45 @@
export enum FilterComparison {
Equal = 0,
GreaterThan =1,
GreaterThanEqual = 2,
LessThan = 3,
LessThanEqual = 4,
/// <summary>
///
/// </summary>
/// <remarks>Only works with IList</remarks>
Contains = 5,
/// <summary>
/// Performs a LIKE %value%
/// </summary>
Matches = 6,
NotContains = 7,
/// <summary>
/// Not Equal to
/// </summary>
NotEqual = 9,
/// <summary>
/// String starts with
/// </summary>
BeginsWith = 10,
/// <summary>
/// String ends with
/// </summary>
EndsWith = 11,
/// <summary>
/// Is Date before X
/// </summary>
IsBefore = 12,
/// <summary>
/// Is Date after X
/// </summary>
IsAfter = 13,
/// <summary>
/// Is Date between now and X seconds ago
/// </summary>
IsInLast = 14,
/// <summary>
/// Is Date not between now and X seconds ago
/// </summary>
IsNotInLast = 15,
}

View File

@ -0,0 +1,33 @@
export enum FilterField
{
None = -1,
Summary = 0,
SeriesName = 1,
PublicationStatus = 2,
Languages = 3,
AgeRating = 4,
UserRating = 5,
Tags = 6,
CollectionTags = 7,
Translators = 8,
Characters = 9,
Publisher = 10,
Editor = 11,
CoverArtist = 12,
Letterer = 13,
Colorist = 14,
Inker = 15,
Penciller = 16,
Writers = 17,
Genres = 18,
Libraries = 19,
ReadProgress = 20,
Formats = 21,
ReleaseYear = 22,
ReadTime = 23
}
export const allFields = Object.keys(FilterField)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10))
.sort((a, b) => a - b) as FilterField[];

View File

@ -0,0 +1,8 @@
import { FilterComparison } from "./filter-comparison";
import { FilterField } from "./filter-field";
export interface FilterStatement {
comparison: FilterComparison;
field: FilterField;
value: string;
}

View File

@ -0,0 +1,11 @@
import { SortOptions } from "../series-filter";
import {FilterStatement} from "./filter-statement";
import {FilterCombination} from "./filter-combination";
export interface SeriesFilterV2 {
name?: string;
statements: Array<FilterStatement>;
combination: FilterCombination;
sortOptions?: SortOptions;
limitTo: number;
}

View File

@ -55,7 +55,6 @@ export class AccountService {
private messageHub: MessageHubService, private themeService: ThemeService) { private messageHub: MessageHubService, private themeService: ThemeService) {
messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate), messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate),
map(evt => evt.payload as UserUpdateEvent), map(evt => evt.payload as UserUpdateEvent),
tap(u => console.log('user update: ', u)),
filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username), filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username),
switchMap(() => this.refreshAccount())) switchMap(() => this.refreshAccount()))
.subscribe(() => {}); .subscribe(() => {});
@ -307,7 +306,6 @@ export class AccountService {
private refreshAccount() { private refreshAccount() {
console.log('Refreshing account');
if (this.currentUser === null || this.currentUser === undefined) return of(); if (this.currentUser === null || this.currentUser === undefined) return of();
return this.httpClient.get<User>(this.baseUrl + 'account/refresh-account').pipe(map((user: User) => { return this.httpClient.get<User>(this.baseUrl + 'account/refresh-account').pipe(map((user: User) => {
if (user) { if (user) {

View File

@ -92,6 +92,8 @@ export enum Action {
* Removes the Series from On Deck inclusion * Removes the Series from On Deck inclusion
*/ */
RemoveFromOnDeck = 19, RemoveFromOnDeck = 19,
AddRuleGroup = 20,
RemoveRuleGroup = 21
} }
export interface ActionItem<T> { export interface ActionItem<T> {
@ -178,6 +180,15 @@ export class ActionFactoryService {
return this.applyCallbackToList(this.bookmarkActions, callback); return this.applyCallbackToList(this.bookmarkActions, callback);
} }
getMetadataFilterActions(callback: (action: ActionItem<any>, data: any) => void) {
const actions = [
{title: 'add-rule-group-and', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback},
{title: 'add-rule-group-or', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback},
{title: 'remove-rule-group', action: Action.RemoveRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback},
];
return this.applyCallbackToList(actions, callback);
}
dummyCallback(action: ActionItem<any>, data: any) {} dummyCallback(action: ActionItem<any>, data: any) {}
filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) { filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) {

View File

@ -1,16 +1,23 @@
import { HttpClient } from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import { Injectable } from '@angular/core'; import {Injectable} from '@angular/core';
import { of } from 'rxjs';
import {map, tap} from 'rxjs/operators'; import {map, tap} from 'rxjs/operators';
import { environment } from 'src/environments/environment'; import {of, ReplaySubject, switchMap} from 'rxjs';
import { Genre } from '../_models/metadata/genre'; import {environment} from 'src/environments/environment';
import { AgeRating } from '../_models/metadata/age-rating'; import {Genre} from '../_models/metadata/genre';
import { AgeRatingDto } from '../_models/metadata/age-rating-dto'; import {AgeRating} from '../_models/metadata/age-rating';
import { Language } from '../_models/metadata/language'; import {AgeRatingDto} from '../_models/metadata/age-rating-dto';
import { PublicationStatusDto } from '../_models/metadata/publication-status-dto'; import {Language} from '../_models/metadata/language';
import { Person } from '../_models/metadata/person'; import {PublicationStatusDto} from '../_models/metadata/publication-status-dto';
import { Tag } from '../_models/tag'; import {Person} from '../_models/metadata/person';
import { TextResonse } from '../_types/text-response'; import {Tag} from '../_models/tag';
import {TextResonse} from '../_types/text-response';
import {FilterComparison} from '../_models/metadata/v2/filter-comparison';
import {FilterField} from '../_models/metadata/v2/filter-field';
import {Router} from "@angular/router";
import {SortField} from "../_models/metadata/series-filter";
import {FilterCombination} from "../_models/metadata/v2/filter-combination";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {FilterStatement} from "../_models/metadata/v2/filter-statement";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -19,10 +26,37 @@ export class MetadataService {
baseUrl = environment.apiUrl; baseUrl = environment.apiUrl;
private currentThemeSource = new ReplaySubject<SeriesFilterV2>(1);
private ageRatingTypes: {[key: number]: string} | undefined = undefined; private ageRatingTypes: {[key: number]: string} | undefined = undefined;
private validLanguages: Array<Language> = []; private validLanguages: Array<Language> = [];
constructor(private httpClient: HttpClient) { } 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));
// 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);
}
getAgeRating(ageRating: AgeRating) { getAgeRating(ageRating: AgeRating) {
if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) { if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {
@ -78,6 +112,7 @@ export class MetadataService {
return this.httpClient.get<Array<Language>>(this.baseUrl + method); return this.httpClient.get<Array<Language>>(this.baseUrl + method);
} }
/** /**
* All the potential language tags there can be * All the potential language tags there can be
*/ */
@ -100,4 +135,30 @@ export class MetadataService {
getChapterSummary(chapterId: number) { getChapterSummary(chapterId: number) {
return this.httpClient.get<string>(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, TextResonse); return this.httpClient.get<string>(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, TextResonse);
} }
createDefaultFilterDto(): SeriesFilterV2 {
return {
statements: [] as FilterStatement[],
combination: FilterCombination.And,
limitTo: 0,
sortOptions: {
isAscending: true,
sortField: SortField.SortName
}
};
}
createDefaultFilterStatement(field: FilterField = FilterField.SeriesName, comparison = FilterComparison.Equal, value = '') {
return {
comparison: comparison,
field: field,
value: value
};
}
updateFilter(arr: Array<FilterStatement>, index: number, filterStmt: FilterStatement) {
arr[index].comparison = filterStmt.comparison;
arr[index].field = filterStmt.field;
arr[index].value = filterStmt.value + '';
}
} }

View File

@ -10,7 +10,6 @@ import { MangaFormat } from '../_models/manga-format';
import { BookmarkInfo } from '../_models/manga-reader/bookmark-info'; import { BookmarkInfo } from '../_models/manga-reader/bookmark-info';
import { PageBookmark } from '../_models/readers/page-bookmark'; import { PageBookmark } from '../_models/readers/page-bookmark';
import { ProgressBookmark } from '../_models/readers/progress-bookmark'; import { ProgressBookmark } from '../_models/readers/progress-bookmark';
import { SeriesFilter } from '../_models/metadata/series-filter';
import { UtilityService } from '../shared/_services/utility.service'; import { UtilityService } from '../shared/_services/utility.service';
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service'; import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
import { FileDimension } from '../manga-reader/_models/file-dimension'; import { FileDimension } from '../manga-reader/_models/file-dimension';
@ -19,6 +18,7 @@ import { TextResonse } from '../_types/text-response';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {PersonalToC} from "../_models/readers/personal-toc"; import {PersonalToC} from "../_models/readers/personal-toc";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
export const CHAPTER_ID_DOESNT_EXIST = -1; export const CHAPTER_ID_DOESNT_EXIST = -1;
export const CHAPTER_ID_NOT_FETCHED = -2; export const CHAPTER_ID_NOT_FETCHED = -2;
@ -70,12 +70,8 @@ export class ReaderService {
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page}); return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page});
} }
getAllBookmarks(filter: SeriesFilter | undefined) { getAllBookmarks(filter: SeriesFilterV2 | undefined) {
let params = new HttpParams(); return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter);
params = this.utilityService.addPaginationIfExists(params, undefined, undefined);
const data = this.filterUtilityService.createSeriesFilter(filter);
return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', data);
} }
getBookmarks(chapterId: number) { getBookmarks(chapterId: number) {

View File

@ -12,12 +12,12 @@ import { PaginatedResult } from '../_models/pagination';
import { Series } from '../_models/series'; import { Series } from '../_models/series';
import { RelatedSeries } from '../_models/series-detail/related-series'; import { RelatedSeries } from '../_models/series-detail/related-series';
import { SeriesDetail } from '../_models/series-detail/series-detail'; import { SeriesDetail } from '../_models/series-detail/series-detail';
import { SeriesFilter } from '../_models/metadata/series-filter';
import { SeriesGroup } from '../_models/series-group'; import { SeriesGroup } from '../_models/series-group';
import { SeriesMetadata } from '../_models/metadata/series-metadata'; import { SeriesMetadata } from '../_models/metadata/series-metadata';
import { Volume } from '../_models/volume'; import { Volume } from '../_models/volume';
import { ImageService } from './image.service'; import { ImageService } from './image.service';
import { TextResonse } from '../_types/text-response'; import { TextResonse } from '../_types/text-response';
import { SeriesFilterV2 } from '../_models/metadata/v2/series-filter-v2';
import {UserReview} from "../_single-module/review-card/user-review"; import {UserReview} from "../_single-module/review-card/user-review";
import {Rating} from "../_models/rating"; import {Rating} from "../_models/rating";
import {Recommendation} from "../_models/series-detail/recommendation"; import {Recommendation} from "../_models/series-detail/recommendation";
@ -32,26 +32,26 @@ export class SeriesService {
paginatedSeriesForTagsResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>(); paginatedSeriesForTagsResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>();
constructor(private httpClient: HttpClient, private imageService: ImageService, constructor(private httpClient: HttpClient, private imageService: ImageService,
private utilityService: UtilityService, private filterUtilityService: FilterUtilitiesService) { } private utilityService: UtilityService) { }
getAllSeries(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
let params = new HttpParams(); let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = this.filterUtilityService.createSeriesFilter(filter); 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', data, {observe: 'response', params}).pipe(
map((response: any) => { map((response: any) => {
return this.utilityService.createPaginatedResult(response, this.paginatedResults); return this.utilityService.createPaginatedResult(response, this.paginatedResults);
}) })
); );
} }
getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
let params = new HttpParams(); let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = this.filterUtilityService.createSeriesFilter(filter); const data = filter || {};
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series/v2', data, {observe: 'response', params}).pipe(
map((response: any) => { map((response: any) => {
return this.utilityService.createPaginatedResult(response, this.paginatedResults); return this.utilityService.createPaginatedResult(response, this.paginatedResults);
}) })
@ -102,12 +102,12 @@ export class SeriesService {
return this.httpClient.post<void>(this.baseUrl + 'reader/mark-unread', {seriesId}); return this.httpClient.post<void>(this.baseUrl + 'reader/mark-unread', {seriesId});
} }
getRecentlyAdded(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
const data = this.filterUtilityService.createSeriesFilter(filter);
let params = new HttpParams(); let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<Series[]>(this.baseUrl + 'series/recently-added?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( const data = filter || {};
return this.httpClient.post<Series[]>(this.baseUrl + 'series/recently-added-v2', data, {observe: 'response', params}).pipe(
map(response => { map(response => {
return this.utilityService.createPaginatedResult(response, new PaginatedResult<Series[]>()); return this.utilityService.createPaginatedResult(response, new PaginatedResult<Series[]>());
}) })
@ -118,13 +118,12 @@ export class SeriesService {
return this.httpClient.post<SeriesGroup[]>(this.baseUrl + 'series/recently-updated-series', {}); return this.httpClient.post<SeriesGroup[]>(this.baseUrl + 'series/recently-updated-series', {});
} }
getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter): Observable<PaginatedResult<Series[]>> { getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2): Observable<PaginatedResult<Series[]>> {
const data = this.filterUtilityService.createSeriesFilter(filter);
let params = new HttpParams(); let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = filter || {};
return this.httpClient.post<Series[]>(this.baseUrl + 'want-to-read/', data, {observe: 'response', params}).pipe( return this.httpClient.post<Series[]>(this.baseUrl + 'want-to-read/v2', data, {observe: 'response', params}).pipe(
map(response => { map(response => {
return this.utilityService.createPaginatedResult(response, new PaginatedResult<Series[]>()); return this.utilityService.createPaginatedResult(response, new PaginatedResult<Series[]>());
})); }));
@ -137,11 +136,10 @@ export class SeriesService {
})); }));
} }
getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
const data = this.filterUtilityService.createSeriesFilter(filter);
let params = new HttpParams(); let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = filter || {};
return this.httpClient.post<Series[]>(this.baseUrl + 'series/on-deck?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( return this.httpClient.post<Series[]>(this.baseUrl + 'series/on-deck?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
map(response => { map(response => {

View File

@ -4,8 +4,8 @@ import { take } from 'rxjs';
import { AccountService } from 'src/app/_services/account.service'; import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionItem } from 'src/app/_services/action-factory.service'; import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import {CommonModule} from "@angular/common"; import {CommonModule} from "@angular/common";
import {DynamicListPipe} from "../../dynamic-list.pipe";
import {TranslocoDirective} from "@ngneat/transloco"; import {TranslocoDirective} from "@ngneat/transloco";
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
@Component({ @Component({
selector: 'app-card-actionables', selector: 'app-card-actionables',

View File

@ -34,7 +34,6 @@ export class SpoilerComponent implements OnInit{
ngOnInit() { ngOnInit() {
this.isCollapsed = true; this.isCollapsed = true;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
console.log('html: ', this.html)
} }

View File

@ -4,7 +4,7 @@
<div class="form-check" *ngIf="allLibraries.length > 0"> <div class="form-check" *ngIf="allLibraries.length > 0">
<input id="select-all" type="checkbox" class="form-check-input" <input id="select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected"> [ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}} All</label> <label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
</div> </div>
<ul> <ul>
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index"> <li class="list-group-item" *ngFor="let library of allLibraries; let i = index">

View File

@ -28,7 +28,7 @@
</button> </button>
<button *ngIf="member.isPending" class="btn btn-secondary btn-sm me-2" (click)="resendEmail(member)" <button *ngIf="member.isPending" class="btn btn-secondary btn-sm me-2" (click)="resendEmail(member)"
placement="top" [ngbTooltip]="t('resend-invite-tooltip')" [attr.aria-label]="t('resend-invite-alt', {user: member.username | titlecase})">{{t('resend')}}}</button> placement="top" [ngbTooltip]="t('resend-invite-tooltip')" [attr.aria-label]="t('resend-invite-alt', {user: member.username | titlecase})">{{t('resend')}}</button>
<button *ngIf="member.isPending" class="btn btn-secondary btn-sm me-2" (click)="setup(member)" <button *ngIf="member.isPending" class="btn btn-secondary btn-sm me-2" (click)="setup(member)"
placement="top" [ngbTooltip]="t('setup-user-tooltip')" [attr.aria-label]="t('setup-user-alt', {user: member.username | titlecase})">Setup</button> placement="top" [ngbTooltip]="t('setup-user-tooltip')" [attr.aria-label]="t('setup-user-alt', {user: member.username | titlecase})">Setup</button>
<button *ngIf="!member.isPending" class="btn btn-secondary btn-sm" (click)="updatePassword(member)" <button *ngIf="!member.isPending" class="btn btn-secondary btn-sm" (click)="updatePassword(member)"

View File

@ -1,5 +1,5 @@
<ng-container *transloco="let t; read: 'all-series'"> <ng-container *transloco="let t; read: 'all-series'">
<app-side-nav-companion-bar [hasFilter]="true" [filterOpenByDefault]="filterSettings.openByDefault" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive"> <app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h2 title> <h2 title>
{{title}} {{title}}
</h2> </h2>

View File

@ -17,7 +17,7 @@ import { UtilityService, KEY_CODES } from 'src/app/shared/_services/utility.serv
import { JumpKey } from 'src/app/_models/jumpbar/jump-key'; import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { Pagination } from 'src/app/_models/pagination'; import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series'; import { Series } from 'src/app/_models/series';
import { SeriesFilter, FilterEvent } from 'src/app/_models/metadata/series-filter'; import { FilterEvent } from 'src/app/_models/metadata/series-filter';
import { Action, ActionItem } from 'src/app/_services/action-factory.service'; import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service'; import { ActionService } from 'src/app/_services/action.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service'; import { JumpbarService } from 'src/app/_services/jumpbar.service';
@ -29,7 +29,9 @@ import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/car
import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component'; import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component';
import { NgIf, DecimalPipe } from '@angular/common'; import { NgIf, DecimalPipe } from '@angular/common';
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective} from "@ngneat/transloco"; import {translate, TranslocoDirective} from "@ngneat/transloco";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
@ -43,14 +45,14 @@ import {TranslocoDirective} from "@ngneat/transloco";
}) })
export class AllSeriesComponent implements OnInit { export class AllSeriesComponent implements OnInit {
title: string = 'All Series'; title: string = translate('all-series.title');
series: Series[] = []; series: Series[] = [];
loadingSeries = false; loadingSeries = false;
pagination!: Pagination; pagination!: Pagination;
filter: SeriesFilter | undefined = undefined; filter: SeriesFilterV2 | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings(); filterSettings: FilterSettings = new FilterSettings();
filterOpen: EventEmitter<boolean> = new EventEmitter(); filterOpen: EventEmitter<boolean> = new EventEmitter();
filterActiveCheck!: SeriesFilter; filterActiveCheck!: SeriesFilterV2;
filterActive: boolean = false; filterActive: boolean = false;
jumpbarKeys: Array<JumpKey> = []; jumpbarKeys: Array<JumpKey> = [];
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@ -112,12 +114,19 @@ export class AllSeriesComponent implements OnInit {
this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.title = this.route.snapshot.queryParamMap.get('title') || 'All Series'; this.title = this.route.snapshot.queryParamMap.get('title') || this.title;
this.titleService.setTitle('Kavita - ' + this.title); this.titleService.setTitle('Kavita - ' + this.title);
this.pagination = this.filterUtilityService.pagination(this.route.snapshot); this.pagination = this.filterUtilityService.pagination(this.route.snapshot);
[this.filterSettings.presets, this.filterSettings.openByDefault] = this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot);
this.filterActiveCheck = this.filterUtilityService.createSeriesFilter(); this.filter = this.filterUtilityService.filterPresetsFromUrlV2(this.route.snapshot);
if (this.filter.statements.length === 0) {
this.filter!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement());
}
this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter();
this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement());
this.filterSettings.presetsV2 = this.filter;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
@ -144,9 +153,13 @@ export class AllSeriesComponent implements OnInit {
updateFilter(data: FilterEvent) { updateFilter(data: FilterEvent) {
this.filter = data.filter; if (data.filterV2 === undefined) return;
this.filter = data.filterV2;
if (!data.isFirst) {
this.filterUtilityService.updateUrlFromFilterV2(this.pagination, this.filter);
}
if (!data.isFirst) this.filterUtilityService.updateUrlFromFilter(this.pagination, this.filter);
this.loadPage(); this.loadPage();
} }
@ -154,7 +167,7 @@ export class AllSeriesComponent implements OnInit {
this.filterActive = !this.utilityService.deepEqual(this.filter, this.filterActiveCheck); this.filterActive = !this.utilityService.deepEqual(this.filter, this.filterActiveCheck);
this.loadingSeries = true; this.loadingSeries = true;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.seriesService.getAllSeries(undefined, undefined, this.filter).pipe(take(1)).subscribe(series => { this.seriesService.getAllSeriesV2(undefined, undefined, this.filter!).pipe(take(1)).subscribe(series => {
this.series = series.result; this.series = series.result;
this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (s: Series) => s.name); this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (s: Series) => s.name);
this.pagination = series.pagination; this.pagination = series.pagination;

View File

@ -1,5 +1,5 @@
<ng-container *transloco="let t; read: 'bookmarks'"> <ng-container *transloco="let t; read: 'bookmarks'">
<app-side-nav-companion-bar [hasFilter]="true" [filterOpenByDefault]="filterSettings.openByDefault" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive"> <app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h2 title> <h2 title>
{{t('title')}} {{t('title')}}
</h2> </h2>

View File

@ -20,7 +20,7 @@ import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { PageBookmark } from 'src/app/_models/readers/page-bookmark'; import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
import { Pagination } from 'src/app/_models/pagination'; import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series'; import { Series } from 'src/app/_models/series';
import { FilterEvent, SeriesFilter } from 'src/app/_models/metadata/series-filter'; import { FilterEvent } from 'src/app/_models/metadata/series-filter';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
import { ImageService } from 'src/app/_services/image.service'; import { ImageService } from 'src/app/_services/image.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service'; import { JumpbarService } from 'src/app/_services/jumpbar.service';
@ -32,6 +32,8 @@ import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/car
import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component'; import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component';
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
@Component({ @Component({
selector: 'app-bookmarks', selector: 'app-bookmarks',
@ -53,11 +55,11 @@ export class BookmarksComponent implements OnInit {
jumpbarKeys: Array<JumpKey> = []; jumpbarKeys: Array<JumpKey> = [];
pagination!: Pagination; pagination!: Pagination;
filter: SeriesFilter | undefined = undefined; filter: SeriesFilterV2 | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings(); filterSettings: FilterSettings = new FilterSettings();
filterOpen: EventEmitter<boolean> = new EventEmitter(); filterOpen: EventEmitter<boolean> = new EventEmitter();
filterActive: boolean = false; filterActive: boolean = false;
filterActiveCheck!: SeriesFilter; filterActiveCheck!: SeriesFilterV2;
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`; trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
refresh: EventEmitter<void> = new EventEmitter(); refresh: EventEmitter<void> = new EventEmitter();
@ -71,18 +73,14 @@ export class BookmarksComponent implements OnInit {
private router: Router, private readonly cdRef: ChangeDetectorRef, private router: Router, private readonly cdRef: ChangeDetectorRef,
private filterUtilityService: FilterUtilitiesService, private route: ActivatedRoute, private filterUtilityService: FilterUtilitiesService, private route: ActivatedRoute,
private jumpbarService: JumpbarService) { private jumpbarService: JumpbarService) {
this.filterSettings.ageRatingDisabled = true; this.filter = this.filterUtilityService.filterPresetsFromUrlV2(this.route.snapshot);
this.filterSettings.collectionDisabled = true; if (this.filter.statements.length === 0) {
this.filterSettings.formatDisabled = true; this.filter!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement());
this.filterSettings.genresDisabled = true; }
this.filterSettings.languageDisabled = true; this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter();
this.filterSettings.libraryDisabled = true; this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement());
this.filterSettings.peopleDisabled = true; this.filterSettings.presetsV2 = this.filter;
this.filterSettings.publicationStatusDisabled = true;
this.filterSettings.ratingDisabled = true;
this.filterSettings.readProgressDisabled = true;
this.filterSettings.tagsDisabled = true;
this.filterSettings.sortDisabled = true;
} }
ngOnInit(): void { ngOnInit(): void {
@ -151,11 +149,6 @@ export class BookmarksComponent implements OnInit {
} }
loadBookmarks() { loadBookmarks() {
// The filter is out of sync with the presets from typeaheads on first load but syncs afterwards
if (this.filter == undefined) {
this.filter = this.filterUtilityService.createSeriesFilter();
this.cdRef.markForCheck();
}
this.loadingBookmarks = true; this.loadingBookmarks = true;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
@ -222,9 +215,13 @@ export class BookmarksComponent implements OnInit {
} }
updateFilter(data: FilterEvent) { updateFilter(data: FilterEvent) {
this.filter = data.filter; if (data.filterV2 === undefined) return;
this.filter = data.filterV2;
if (!data.isFirst) {
this.filterUtilityService.updateUrlFromFilterV2(this.pagination, this.filter);
}
if (!data.isFirst) this.filterUtilityService.updateUrlFromFilter(this.pagination, this.filter);
this.loadBookmarks(); this.loadBookmarks();
} }

View File

@ -11,9 +11,9 @@ import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/acti
import { BulkSelectionService } from '../bulk-selection.service'; import { BulkSelectionService } from '../bulk-selection.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {AsyncPipe, CommonModule} from "@angular/common"; import {AsyncPipe, CommonModule} from "@angular/common";
import {CardActionablesComponent} from "../card-item/card-actionables/card-actionables.component";
import {TranslocoModule} from "@ngneat/transloco"; import {TranslocoModule} from "@ngneat/transloco";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
@Component({ @Component({
selector: 'app-bulk-operations', selector: 'app-bulk-operations',

View File

@ -43,13 +43,13 @@ import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {EntityInfoCardsComponent} from "../entity-info-cards/entity-info-cards.component"; import {EntityInfoCardsComponent} from "../entity-info-cards/entity-info-cards.component";
import {CoverImageChooserComponent} from "../cover-image-chooser/cover-image-chooser.component"; import {CoverImageChooserComponent} from "../cover-image-chooser/cover-image-chooser.component";
import {ChapterMetadataDetailComponent} from "../chapter-metadata-detail/chapter-metadata-detail.component"; import {ChapterMetadataDetailComponent} from "../chapter-metadata-detail/chapter-metadata-detail.component";
import {CardActionablesComponent} from "../card-item/card-actionables/card-actionables.component";
import {DefaultDatePipe} from "../../pipe/default-date.pipe"; import {DefaultDatePipe} from "../../pipe/default-date.pipe";
import {BytesPipe} from "../../pipe/bytes.pipe"; import {BytesPipe} from "../../pipe/bytes.pipe";
import {BadgeExpanderComponent} from "../../shared/badge-expander/badge-expander.component"; import {BadgeExpanderComponent} from "../../shared/badge-expander/badge-expander.component";
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component"; import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component"; import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
enum TabID { enum TabID {
General = 0, General = 0,

View File

@ -3,13 +3,13 @@
<div class="row mt-2 g-0 pb-2" *ngIf="header !== undefined && header.length > 0"> <div class="row mt-2 g-0 pb-2" *ngIf="header !== undefined && header.length > 0">
<div class="col me-auto"> <div class="col me-auto">
<h2> <h2>
<span *ngIf="actions.length > 0" class=""> <span *ngIf="actions.length > 0" class="">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>&nbsp; <app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>&nbsp;
</span> </span>
<span *ngIf="header !== undefined && header.length > 0"> <span *ngIf="header !== undefined && header.length > 0">
{{header}}&nbsp; {{header}}&nbsp;
<span class="badge bg-primary rounded-pill" [attr.aria-label]="t('total-items', {count: pagination.totalItems})" *ngIf="pagination !== undefined">{{pagination.totalItems}}</span> <span class="badge bg-primary rounded-pill" [attr.aria-label]="t('total-items', {count: pagination.totalItems})" *ngIf="pagination !== undefined">{{pagination.totalItems}}</span>
</span> </span>
</h2> </h2>
</div> </div>
</div> </div>

View File

@ -5,12 +5,15 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ContentChild, ContentChild,
DestroyRef,
ElementRef, ElementRef,
EventEmitter, EventEmitter,
HostListener, inject, HostListener,
inject,
Inject, Inject,
Input, Input,
OnChanges, OnChanges,
OnDestroy,
OnInit, OnInit,
Output, Output,
TemplateRef, TemplateRef,
@ -25,16 +28,18 @@ import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.ser
import { JumpKey } from 'src/app/_models/jumpbar/jump-key'; import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { Library } from 'src/app/_models/library'; import { Library } from 'src/app/_models/library';
import { Pagination } from 'src/app/_models/pagination'; import { Pagination } from 'src/app/_models/pagination';
import { FilterEvent, FilterItem, SeriesFilter } from 'src/app/_models/metadata/series-filter'; import { FilterEvent, FilterItem } from 'src/app/_models/metadata/series-filter';
import { ActionItem } from 'src/app/_services/action-factory.service'; import { ActionItem } from 'src/app/_services/action-factory.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service'; import { JumpbarService } from 'src/app/_services/jumpbar.service';
import { ScrollService } from 'src/app/_services/scroll.service'; import { ScrollService } from 'src/app/_services/scroll.service';
import {LoadingComponent} from "../../shared/loading/loading.component"; import {LoadingComponent} from "../../shared/loading/loading.component";
import {CardActionablesComponent} from "../card-item/card-actionables/card-actionables.component";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {MetadataFilterComponent} from "../../metadata-filter/metadata-filter.component"; import {MetadataFilterComponent} from "../../metadata-filter/metadata-filter.component";
import {TranslocoDirective} from "@ngneat/transloco"; import {TranslocoDirective} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {SeriesFilterV2} from "../../_models/metadata/v2/series-filter-v2";
@Component({ @Component({
selector: 'app-card-detail-layout', selector: 'app-card-detail-layout',
@ -84,12 +89,13 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
@ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent; @ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent;
private readonly filterUtilityService = inject(FilterUtilitiesService); private readonly filterUtilityService = inject(FilterUtilitiesService);
filter: SeriesFilter = this.filterUtilityService.createSeriesFilter(); filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter();
libraries: Array<FilterItem<Library>> = []; libraries: Array<FilterItem<Library>> = [];
updateApplied: number = 0; updateApplied: number = 0;
hasResumedJumpKey: boolean = false; hasResumedJumpKey: boolean = false;
get Breakpoint() { get Breakpoint() {
return Breakpoint; return Breakpoint;
} }
@ -157,7 +163,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
} }
hasCustomSort() { hasCustomSort() {
return this.filter.sortOptions || this.filterSettings?.presets?.sortOptions; return this.filter?.sortOptions || this.filterSettings?.presetsV2?.sortOptions;
} }
performAction(action: ActionItem<any>) { performAction(action: ActionItem<any>) {
@ -169,7 +175,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
applyMetadataFilter(event: FilterEvent) { applyMetadataFilter(event: FilterEvent) {
this.applyFilter.emit(event); this.applyFilter.emit(event);
this.updateApplied++; this.updateApplied++;
this.filter = event.filter; this.filter = event.filterV2;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }

View File

@ -36,11 +36,11 @@ import {DownloadIndicatorComponent} from "../download-indicator/download-indicat
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
import {MangaFormatPipe} from "../../pipe/manga-format.pipe"; import {MangaFormatPipe} from "../../pipe/manga-format.pipe";
import {MangaFormatIconPipe} from "../../pipe/manga-format-icon.pipe"; import {MangaFormatIconPipe} from "../../pipe/manga-format-icon.pipe";
import {CardActionablesComponent} from "./card-actionables/card-actionables.component";
import {SentenceCasePipe} from "../../pipe/sentence-case.pipe"; import {SentenceCasePipe} from "../../pipe/sentence-case.pipe";
import {CommonModule} from "@angular/common"; import {CommonModule} from "@angular/common";
import {RouterLink} from "@angular/router"; import {RouterLink} from "@angular/router";
import {TranslocoModule} from "@ngneat/transloco"; import {TranslocoModule} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
@Component({ @Component({
selector: 'app-card-item', selector: 'app-card-item',

View File

@ -3,11 +3,11 @@
<div class="mt-4 mb-3"> <div class="mt-4 mb-3">
<div class="row g-0" *ngIf="chapterMetadata "> <div class="row g-0" *ngIf="chapterMetadata ">
<!-- Tags and Characters are used a lot of Hentai and Doujinshi type content, so showing in list item has value add on first glance --> <!-- Tags and Characters are used a lot of Hentai and Doujinshi type content, so showing in list item has value add on first glance -->
<app-metadata-detail [tags]="chapterMetadata.tags" [libraryId]="libraryId" [queryParam]="FilterQueryParam.Tags" heading="Tags"> <app-metadata-detail [tags]="chapterMetadata.tags" [libraryId]="libraryId" [queryParam]="FilterField.Tags" heading="Tags">
<ng-template #titleTemplate let-item>{{item.title}}</ng-template> <ng-template #titleTemplate let-item>{{item.title}}</ng-template>
</app-metadata-detail> </app-metadata-detail>
<app-metadata-detail [tags]="chapterMetadata.characters" [libraryId]="libraryId" [queryParam]="FilterQueryParam.Character" heading="Characters"> <app-metadata-detail [tags]="chapterMetadata.characters" [libraryId]="libraryId" [queryParam]="FilterField.Characters" heading="Characters">
<ng-template #titleTemplate let-item>{{item.name}}</ng-template> <ng-template #titleTemplate let-item>{{item.name}}</ng-template>
</app-metadata-detail> </app-metadata-detail>
</div> </div>

View File

@ -28,6 +28,7 @@ import {MetadataDetailComponent} from "../../series-detail/_components/metadata-
import {FilterQueryParam} from "../../shared/_services/filter-utilities.service"; import {FilterQueryParam} from "../../shared/_services/filter-utilities.service";
import {TranslocoModule} from "@ngneat/transloco"; import {TranslocoModule} from "@ngneat/transloco";
import {TranslocoLocaleModule} from "@ngneat/transloco-locale"; import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
import {FilterField} from "../../_models/metadata/v2/filter-field";
@Component({ @Component({
selector: 'app-entity-info-cards', selector: 'app-entity-info-cards',
@ -75,6 +76,8 @@ export class EntityInfoCardsComponent implements OnInit {
return AgeRating; return AgeRating;
} }
get FilterField() { return FilterField; }
get WebLinks() { get WebLinks() {
if (this.chapter.webLinks === '') return []; if (this.chapter.webLinks === '') return [];
return this.chapter.webLinks.split(','); return this.chapter.webLinks.split(',');

View File

@ -24,9 +24,9 @@ import {CommonModule} from "@angular/common";
import {ImageComponent} from "../../shared/image/image.component"; import {ImageComponent} from "../../shared/image/image.component";
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component"; import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component";
import {EntityInfoCardsComponent} from "../entity-info-cards/entity-info-cards.component"; import {EntityInfoCardsComponent} from "../entity-info-cards/entity-info-cards.component";
import {CardActionablesComponent} from "../card-item/card-actionables/card-actionables.component";
import {NgbProgressbar, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {NgbProgressbar, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
@Component({ @Component({
selector: 'app-list-item', selector: 'app-list-item',

View File

@ -12,16 +12,16 @@
<ng-container *ngIf="seriesMetadata"> <ng-container *ngIf="seriesMetadata">
<ng-container *ngIf="seriesMetadata.ageRating"> <ng-container *ngIf="seriesMetadata.ageRating">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('age-rating-title')" [clickable]="true" fontClasses="fas fa-eye" (click)="handleGoTo(FilterQueryParam.AgeRating, seriesMetadata.ageRating)" [title]="t('age-rating-title')"> <app-icon-and-title [label]="t('age-rating-title')" [clickable]="true" fontClasses="fas fa-eye" (click)="handleGoTo(FilterField.AgeRating, seriesMetadata.ageRating)" [title]="t('age-rating-title')">
{{this.seriesMetadata.ageRating | ageRating}} {{this.seriesMetadata.ageRating | ageRating}}
</app-icon-and-title> </app-icon-and-title>
</div> </div>
<div class="vr d-none d-lg-block m-2"></div> <div class="vr d-none d-lg-block m-2"></div>
</ng-container> </ng-container>
<ng-container *ngIf="seriesMetadata.language !== null"> <ng-container *ngIf="seriesMetadata.language !== null && seriesMetadata.language !== ''">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('language-title')" [clickable]="true" fontClasses="fas fa-language" (click)="handleGoTo(FilterQueryParam.Languages, seriesMetadata.language)" [title]="t('language-title')"> <app-icon-and-title [label]="t('language-title')" [clickable]="true" fontClasses="fas fa-language" (click)="handleGoTo(FilterField.Languages, seriesMetadata.language)" [title]="t('language-title')">
{{seriesMetadata.language | defaultValue:'en' | languageName | async}} {{seriesMetadata.language | defaultValue:'en' | languageName | async}}
</app-icon-and-title> </app-icon-and-title>
</div> </div>
@ -33,7 +33,7 @@
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<ng-container *ngIf="seriesMetadata.publicationStatus | publicationStatus as pubStatus"> <ng-container *ngIf="seriesMetadata.publicationStatus | publicationStatus as pubStatus">
<app-icon-and-title [label]="t('publication-status-title')" [clickable]="true" fontClasses="fa-solid fa-hourglass-{{pubStatus === t('ongoing') ? 'empty' : 'end'}}" <app-icon-and-title [label]="t('publication-status-title')" [clickable]="true" fontClasses="fa-solid fa-hourglass-{{pubStatus === t('ongoing') ? 'empty' : 'end'}}"
(click)="handleGoTo(FilterQueryParam.PublicationStatus, seriesMetadata.publicationStatus)" (click)="handleGoTo(FilterField.PublicationStatus, seriesMetadata.publicationStatus)"
[ngbTooltip]="t('publication-status-tooltip') + ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')'"> [ngbTooltip]="t('publication-status-tooltip') + ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')'">
{{pubStatus}} {{pubStatus}}
</app-icon-and-title> </app-icon-and-title>
@ -65,7 +65,7 @@
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('format-title')" [clickable]="true" <app-icon-and-title [label]="t('format-title')" [clickable]="true"
[fontClasses]="'fa ' + (series.format | mangaFormatIcon)" [fontClasses]="'fa ' + (series.format | mangaFormatIcon)"
(click)="handleGoTo(FilterQueryParam.Format, series.format)" [title]="t('format-title')"> (click)="handleGoTo(FilterField.Formats, series.format)" [title]="t('format-title')">
{{series.format | mangaFormat}} {{series.format | mangaFormat}}
</app-icon-and-title> </app-icon-and-title>
</div> </div>

View File

@ -10,7 +10,6 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import {debounceTime, filter, map} from 'rxjs'; import {debounceTime, filter, map} from 'rxjs';
import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service';
import { UtilityService } from 'src/app/shared/_services/utility.service'; import { UtilityService } from 'src/app/shared/_services/utility.service';
import { UserProgressUpdateEvent } from 'src/app/_models/events/user-progress-update-event'; import { UserProgressUpdateEvent } from 'src/app/_models/events/user-progress-update-event';
import { HourEstimateRange } from 'src/app/_models/series-detail/hour-estimate-range'; import { HourEstimateRange } from 'src/app/_models/series-detail/hour-estimate-range';
@ -20,6 +19,7 @@ import { SeriesMetadata } from 'src/app/_models/metadata/series-metadata';
import { AccountService } from 'src/app/_services/account.service'; import { AccountService } from 'src/app/_services/account.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service'; import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
import { ReaderService } from 'src/app/_services/reader.service'; import { ReaderService } from 'src/app/_services/reader.service';
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ScrobblingService} from "../../_services/scrobbling.service"; import {ScrobblingService} from "../../_services/scrobbling.service";
import {CommonModule} from "@angular/common"; import {CommonModule} from "@angular/common";
@ -53,7 +53,7 @@ export class SeriesInfoCardsComponent implements OnInit, OnChanges {
* If this should make an API call to request readingTimeLeft * If this should make an API call to request readingTimeLeft
*/ */
@Input() showReadingTimeLeft: boolean = true; @Input() showReadingTimeLeft: boolean = true;
@Output() goTo: EventEmitter<{queryParamName: FilterQueryParam, filter: any}> = new EventEmitter(); @Output() goTo: EventEmitter<{queryParamName: FilterField, filter: any}> = new EventEmitter();
readingTime: HourEstimateRange = {avgHours: 0, maxHours: 0, minHours: 0}; readingTime: HourEstimateRange = {avgHours: 0, maxHours: 0, minHours: 0};
isScrobbling: boolean = true; isScrobbling: boolean = true;
@ -64,8 +64,8 @@ export class SeriesInfoCardsComponent implements OnInit, OnChanges {
return MangaFormat; return MangaFormat;
} }
get FilterQueryParam() { get FilterField() {
return FilterQueryParam; return FilterField;
} }
constructor(public utilityService: UtilityService, private readerService: ReaderService, constructor(public utilityService: UtilityService, private readerService: ReaderService,
@ -110,7 +110,8 @@ export class SeriesInfoCardsComponent implements OnInit, OnChanges {
} }
handleGoTo(queryParamName: FilterQueryParam, filter: any) { handleGoTo(queryParamName: FilterField, filter: any) {
if (filter + '' === '') return;
this.goTo.emit({queryParamName, filter}); this.goTo.emit({queryParamName, filter});
} }

View File

@ -26,7 +26,7 @@
header="Series" header="Series"
[isLoading]="isLoading" [isLoading]="isLoading"
[items]="series" [items]="series"
[pagination]="seriesPagination" [pagination]="pagination"
[filterSettings]="filterSettings" [filterSettings]="filterSettings"
[filterOpen]="filterOpen" [filterOpen]="filterOpen"
[parentScroll]="scrollingBlock" [parentScroll]="scrollingBlock"

View File

@ -28,7 +28,7 @@ import {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added-
import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
import {Pagination} from 'src/app/_models/pagination'; import {Pagination} from 'src/app/_models/pagination';
import {Series} from 'src/app/_models/series'; import {Series} from 'src/app/_models/series';
import {FilterEvent, SeriesFilter, SortField} from 'src/app/_models/metadata/series-filter'; import {FilterEvent} from 'src/app/_models/metadata/series-filter';
import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
import {ActionService} from 'src/app/_services/action.service'; import {ActionService} from 'src/app/_services/action.service';
import {CollectionTagService} from 'src/app/_services/collection-tag.service'; import {CollectionTagService} from 'src/app/_services/collection-tag.service';
@ -42,12 +42,16 @@ import {CardDetailLayoutComponent} from '../../../cards/card-detail-layout/card-
import {BulkOperationsComponent} from '../../../cards/bulk-operations/bulk-operations.component'; import {BulkOperationsComponent} from '../../../cards/bulk-operations/bulk-operations.component';
import {ReadMoreComponent} from '../../../shared/read-more/read-more.component'; import {ReadMoreComponent} from '../../../shared/read-more/read-more.component';
import {ImageComponent} from '../../../shared/image/image.component'; import {ImageComponent} from '../../../shared/image/image.component';
import {CardActionablesComponent} from '../../../cards/card-item/card-actionables/card-actionables.component';
import { import {
SideNavCompanionBarComponent SideNavCompanionBarComponent
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
@Component({ @Component({
selector: 'app-collection-detail', selector: 'app-collection-detail',
@ -69,14 +73,14 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
tagImage: string = ''; tagImage: string = '';
isLoading: boolean = true; isLoading: boolean = true;
series: Array<Series> = []; series: Array<Series> = [];
seriesPagination!: Pagination; pagination!: Pagination;
collectionTagActions: ActionItem<CollectionTag>[] = []; collectionTagActions: ActionItem<CollectionTag>[] = [];
filter: SeriesFilter | undefined = undefined; filter: SeriesFilterV2 | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings(); filterSettings: FilterSettings = new FilterSettings();
summary: string = ''; summary: string = '';
actionInProgress: boolean = false; actionInProgress: boolean = false;
filterActiveCheck!: SeriesFilter; filterActiveCheck!: SeriesFilterV2;
filterActive: boolean = false; filterActive: boolean = false;
jumpbarKeys: Array<JumpKey> = []; jumpbarKeys: Array<JumpKey> = [];
@ -165,11 +169,16 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
} }
const tagId = parseInt(routeId, 10); const tagId = parseInt(routeId, 10);
this.seriesPagination = this.filterUtilityService.pagination(this.route.snapshot); this.pagination = this.filterUtilityService.pagination(this.route.snapshot);
[this.filterSettings.presets, this.filterSettings.openByDefault] = this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot);
this.filterSettings.presets.collectionTags = [tagId]; this.filter = this.filterUtilityService.filterPresetsFromUrlV2(this.route.snapshot);
this.filterActiveCheck = this.filterUtilityService.createSeriesFilter(); if (this.filter.statements.filter(stmt => stmt.field === FilterField.Libraries).length === 0) {
this.filterActiveCheck.collectionTags = [tagId]; this.filter!.statements.push({field: FilterField.CollectionTags, value: tagId + '', comparison: FilterComparison.Equal});
}
this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter();
this.filterActiveCheck!.statements.push({field: FilterField.CollectionTags, value: tagId + '', comparison: FilterComparison.Equal});
this.filterSettings.presetsV2 = this.filter;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.updateTag(tagId); this.updateTag(tagId);
@ -213,14 +222,17 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
const matchingTags = tags.filter(t => t.id === tagId); const matchingTags = tags.filter(t => t.id === tagId);
if (matchingTags.length === 0) { if (matchingTags.length === 0) {
this.toastr.error(this.translocoService.translate('errors.collection-invalid-access')); this.toastr.error(this.translocoService.translate('errors.collection-invalid-access'));
// TODO: Why would access need to be checked? Even if a id was guessed, the series wouldn't return
this.router.navigateByUrl('/'); this.router.navigateByUrl('/');
return; return;
} }
this.collectionTag = matchingTags[0]; this.collectionTag = matchingTags[0];
this.summary = (this.collectionTag.summary === null ? '' : this.collectionTag.summary).replace(/\n/g, '<br>'); this.summary = (this.collectionTag.summary === null ? '' : this.collectionTag.summary).replace(/\n/g, '<br>');
// TODO: This can be changed now that we have app-image and collection cover merge code
this.tagImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(this.collectionTag.id)); this.tagImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(this.collectionTag.id));
this.titleService.setTitle(this.translocoService.translate('errors.collection-invalid-access', {collectionName: this.collectionTag.title})); this.titleService.setTitle(this.translocoService.translate('errors.collection-invalid-access', {collectionName: this.collectionTag.title}));
// TODO: BUG: This title key is incorrect!
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
} }
@ -230,16 +242,9 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
this.isLoading = true; this.isLoading = true;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
if (!this.filter) { this.seriesService.getAllSeriesV2(undefined, undefined, this.filter).pipe(take(1)).subscribe(series => {
this.filter = this.filterUtilityService.createSeriesFilter(this.filter);
this.filter.sortOptions = {
isAscending: true,
sortField: SortField.SortName
}
}
this.seriesService.getAllSeries(undefined, undefined, this.filter).pipe(take(1)).subscribe(series => {
this.series = series.result; this.series = series.result;
this.seriesPagination = series.pagination; this.pagination = series.pagination;
this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (series: Series) => series.name); this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (series: Series) => series.name);
this.isLoading = false; this.isLoading = false;
window.scrollTo(0, 0); window.scrollTo(0, 0);
@ -248,9 +253,13 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
} }
updateFilter(data: FilterEvent) { updateFilter(data: FilterEvent) {
this.filter = data.filter; if (data.filterV2 === undefined) return;
this.filter = data.filterV2;
if (!data.isFirst) {
this.filterUtilityService.updateUrlFromFilterV2(this.pagination, this.filter);
}
if (!data.isFirst) this.filterUtilityService.updateUrlFromFilter(this.seriesPagination, this.filter);
this.loadPage(); this.loadPage();
} }

View File

@ -5,7 +5,6 @@ import { AllCollectionsComponent } from './_components/all-collections/all-colle
import { CollectionsRoutingModule } from './collections-routing.module'; import { CollectionsRoutingModule } from './collections-routing.module';
import {ImageComponent} from "../shared/image/image.component"; import {ImageComponent} from "../shared/image/image.component";
import {ReadMoreComponent} from "../shared/read-more/read-more.component"; import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {CardActionablesComponent} from "../cards/card-item/card-actionables/card-actionables.component";
import { import {
SideNavCompanionBarComponent SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; } from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
@ -13,6 +12,7 @@ import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component"; import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
import {SeriesCardComponent} from "../cards/series-card/series-card.component"; import {SeriesCardComponent} from "../cards/series-card/series-card.component";
import {CardItemComponent} from "../cards/card-item/card-item.component"; import {CardItemComponent} from "../cards/card-item/card-item.component";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";

View File

@ -1,36 +1,32 @@
import { import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
ChangeDetectionStrategy, import {Title} from '@angular/platform-browser';
ChangeDetectorRef, import {Router, RouterLink} from '@angular/router';
Component, import {Observable, of, ReplaySubject} from 'rxjs';
DestroyRef, import {debounceTime, map, shareReplay, take, tap} from 'rxjs/operators';
inject, import {FilterQueryParam, FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
Input, import {SeriesAddedEvent} from 'src/app/_models/events/series-added-event';
OnInit import {SeriesRemovedEvent} from 'src/app/_models/events/series-removed-event';
} from '@angular/core'; import {Library} from 'src/app/_models/library';
import { Title } from '@angular/platform-browser'; import {RecentlyAddedItem} from 'src/app/_models/recently-added-item';
import { Router, RouterLink } from '@angular/router'; import {Series} from 'src/app/_models/series';
import { Observable, of, ReplaySubject } from 'rxjs'; import {SortField} from 'src/app/_models/metadata/series-filter';
import { debounceTime, map, take, tap, shareReplay } from 'rxjs/operators'; import {SeriesGroup} from 'src/app/_models/series-group';
import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service'; import {AccountService} from 'src/app/_services/account.service';
import { SeriesAddedEvent } from 'src/app/_models/events/series-added-event'; import {ImageService} from 'src/app/_services/image.service';
import { SeriesRemovedEvent } from 'src/app/_models/events/series-removed-event'; import {LibraryService} from 'src/app/_services/library.service';
import { Library } from 'src/app/_models/library'; import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
import { RecentlyAddedItem } from 'src/app/_models/recently-added-item'; import {SeriesService} from 'src/app/_services/series.service';
import { Series } from 'src/app/_models/series';
import { SortField } from 'src/app/_models/metadata/series-filter';
import { SeriesGroup } from 'src/app/_models/series-group';
import { AccountService } from 'src/app/_services/account.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { MessageHubService, EVENTS } from 'src/app/_services/message-hub.service';
import { SeriesService } from 'src/app/_services/series.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { CardItemComponent } from '../../cards/card-item/card-item.component'; import {CardItemComponent} from '../../cards/card-item/card-item.component';
import { SeriesCardComponent } from '../../cards/series-card/series-card.component'; import {SeriesCardComponent} from '../../cards/series-card/series-card.component';
import { CarouselReelComponent } from '../../carousel/_components/carousel-reel/carousel-reel.component'; import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.component';
import { NgIf, AsyncPipe } from '@angular/common'; import {AsyncPipe, NgIf} from '@angular/common';
import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import {
SideNavCompanionBarComponent
} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective} from "@ngneat/transloco"; import {TranslocoDirective} from "@ngneat/transloco";
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
@ -61,6 +57,7 @@ export class DashboardComponent implements OnInit {
*/ */
private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>(); private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly filterUtilityService = inject(FilterUtilitiesService);
constructor(public accountService: AccountService, private libraryService: LibraryService, constructor(public accountService: AccountService, private libraryService: LibraryService,
private seriesService: SeriesService, private router: Router, private seriesService: SeriesService, private router: Router,
@ -138,9 +135,11 @@ export class DashboardComponent implements OnInit {
} }
loadRecentlyAddedSeries() { loadRecentlyAddedSeries() {
let api = this.seriesService.getRecentlyAdded(0, 1, 30); let api = this.seriesService.getRecentlyAdded(1, 30);
if (this.libraryId > 0) { if (this.libraryId > 0) {
api = this.seriesService.getRecentlyAdded(this.libraryId, 1, 30); const filter = this.filterUtilityService.createSeriesV2Filter();
filter.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal});
api = this.seriesService.getRecentlyAdded(1, 30, filter);
} }
api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((updatedSeries) => { api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((updatedSeries) => {
this.recentlyAddedSeries = updatedSeries.result; this.recentlyAddedSeries = updatedSeries.result;

View File

@ -1,5 +1,5 @@
<ng-container *transloco="let t"> <ng-container *transloco="let t">
<app-side-nav-companion-bar [hasFilter]="true" [filterOpenByDefault]="filterSettings.openByDefault" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive"> <app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h2 title> <h2 title>
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables> <app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
<span>{{libraryName}}</span> <span>{{libraryName}}</span>

View File

@ -1,43 +1,50 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component,
DestroyRef,
EventEmitter, EventEmitter,
HostListener, HostListener,
inject, inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { Title } from '@angular/platform-browser'; import {Title} from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import {ActivatedRoute, Router} from '@angular/router';
import { take } from 'rxjs/operators'; import {take} from 'rxjs/operators';
import { BulkSelectionService } from '../cards/bulk-selection.service'; import {BulkSelectionService} from '../cards/bulk-selection.service';
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service'; import {KEY_CODES, UtilityService} from '../shared/_services/utility.service';
import { SeriesAddedEvent } from '../_models/events/series-added-event'; import {SeriesAddedEvent} from '../_models/events/series-added-event';
import { Library } from '../_models/library'; import {Library} from '../_models/library';
import { Pagination } from '../_models/pagination'; import {Pagination} from '../_models/pagination';
import { Series } from '../_models/series'; import {Series} from '../_models/series';
import { FilterEvent, SeriesFilter } from '../_models/metadata/series-filter'; import {FilterEvent} from '../_models/metadata/series-filter';
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service'; import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service';
import { ActionService } from '../_services/action.service'; import {ActionService} from '../_services/action.service';
import { LibraryService } from '../_services/library.service'; import {LibraryService} from '../_services/library.service';
import { EVENTS, MessageHubService } from '../_services/message-hub.service'; import {EVENTS, MessageHubService} from '../_services/message-hub.service';
import { SeriesService } from '../_services/series.service'; import {SeriesService} from '../_services/series.service';
import { NavService } from '../_services/nav.service'; import {NavService} from '../_services/nav.service';
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service'; import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service';
import { FilterSettings } from '../metadata-filter/filter-settings'; import {FilterSettings} from '../metadata-filter/filter-settings';
import { JumpKey } from '../_models/jumpbar/jump-key'; import {JumpKey} from '../_models/jumpbar/jump-key';
import { SeriesRemovedEvent } from '../_models/events/series-removed-event'; import {SeriesRemovedEvent} from '../_models/events/series-removed-event';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SentenceCasePipe } from '../pipe/sentence-case.pipe'; import {SentenceCasePipe} from '../pipe/sentence-case.pipe';
import { BulkOperationsComponent } from '../cards/bulk-operations/bulk-operations.component'; import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component';
import { SeriesCardComponent } from '../cards/series-card/series-card.component'; import {SeriesCardComponent} from '../cards/series-card/series-card.component';
import { CardDetailLayoutComponent } from '../cards/card-detail-layout/card-detail-layout.component'; import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.component';
import { LibraryRecommendedComponent } from './library-recommended/library-recommended.component'; import {LibraryRecommendedComponent} from './library-recommended/library-recommended.component';
import { NgFor, NgIf, DecimalPipe } from '@angular/common'; import {DecimalPipe, NgFor, NgIf} from '@angular/common';
import { NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'; import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
import { CardActionablesComponent } from '../cards/card-item/card-actionables/card-actionables.component'; import {
import { SideNavCompanionBarComponent } from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; SideNavCompanionBarComponent
} from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {MetadataService} from "../_services/metadata.service";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
import {FilterField} from "../_models/metadata/v2/filter-field";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
@Component({ @Component({
selector: 'app-library-detail', selector: 'app-library-detail',
@ -55,11 +62,11 @@ export class LibraryDetailComponent implements OnInit {
loadingSeries = false; loadingSeries = false;
pagination!: Pagination; pagination!: Pagination;
actions: ActionItem<Library>[] = []; actions: ActionItem<Library>[] = [];
filter: SeriesFilter | undefined = undefined; filterV2: SeriesFilterV2 | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings(); filterSettings: FilterSettings = new FilterSettings();
filterOpen: EventEmitter<boolean> = new EventEmitter(); filterOpen: EventEmitter<boolean> = new EventEmitter();
filterActive: boolean = false; filterActive: boolean = false;
filterActiveCheck!: SeriesFilter; filterActiveCheck!: SeriesFilterV2;
refresh: EventEmitter<void> = new EventEmitter(); refresh: EventEmitter<void> = new EventEmitter();
jumpKeys: Array<JumpKey> = []; jumpKeys: Array<JumpKey> = [];
@ -72,6 +79,9 @@ export class LibraryDetailComponent implements OnInit {
]; ];
active = this.tabs[0]; active = this.tabs[0];
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly metadataService = inject(MetadataService);
private readonly cdRef = inject(ChangeDetectorRef);
bulkActionCallback = (action: ActionItem<any>, data: any) => { bulkActionCallback = (action: ActionItem<any>, data: any) => {
const selectedSeriesIndices = this.bulkSelectionService.getSelectedCardsForSource('series'); const selectedSeriesIndices = this.bulkSelectionService.getSelectedCardsForSource('series');
@ -128,15 +138,14 @@ export class LibraryDetailComponent implements OnInit {
constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService, constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService,
private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService, private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService,
private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService, private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
private utilityService: UtilityService, public navService: NavService, private filterUtilityService: FilterUtilitiesService, private utilityService: UtilityService, public navService: NavService, private filterUtilityService: FilterUtilitiesService) {
private readonly cdRef: ChangeDetectorRef) {
const routeId = this.route.snapshot.paramMap.get('libraryId'); const routeId = this.route.snapshot.paramMap.get('libraryId');
if (routeId === null) { if (routeId === null) {
this.router.navigateByUrl('/libraries'); this.router.navigateByUrl('/libraries');
return; return;
} }
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.libraryId = parseInt(routeId, 10); this.libraryId = parseInt(routeId, 10);
this.libraryService.getLibraryNames().pipe(take(1)).subscribe(names => { this.libraryService.getLibraryNames().pipe(take(1)).subscribe(names => {
@ -153,22 +162,27 @@ export class LibraryDetailComponent implements OnInit {
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this)); this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
this.pagination = this.filterUtilityService.pagination(this.route.snapshot); this.pagination = this.filterUtilityService.pagination(this.route.snapshot);
[this.filterSettings.presets, this.filterSettings.openByDefault] = this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot); this.filterV2 = this.filterUtilityService.filterPresetsFromUrlV2(this.route.snapshot);
if (this.filterSettings.presets) this.filterSettings.presets.libraries = [this.libraryId];
// Setup filterActiveCheck to check filter against if (this.filterV2.statements.filter(stmt => stmt.field === FilterField.Libraries).length === 0) {
this.filterActiveCheck = this.filterUtilityService.createSeriesFilter(); this.filterV2!.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal});
this.filterActiveCheck.libraries = [this.libraryId]; }
this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter();
this.filterActiveCheck.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal});
this.filterSettings.presetsV2 = this.filterV2;
this.filterSettings.libraryDisabled = true;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
ngOnInit(): void { ngOnInit(): void {
this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
if (event.event === EVENTS.SeriesAdded) { if (event.event === EVENTS.SeriesAdded) {
const seriesAdded = event.payload as SeriesAddedEvent; const seriesAdded = event.payload as SeriesAddedEvent;
if (seriesAdded.libraryId !== this.libraryId) return; if (seriesAdded.libraryId !== this.libraryId) return;
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) { if (!this.utilityService.deepEqual(this.filterV2, this.filterActiveCheck)) {
this.loadPage(); this.loadPage();
return; return;
} }
@ -188,7 +202,7 @@ export class LibraryDetailComponent implements OnInit {
} else if (event.event === EVENTS.SeriesRemoved) { } else if (event.event === EVENTS.SeriesRemoved) {
const seriesRemoved = event.payload as SeriesRemovedEvent; const seriesRemoved = event.payload as SeriesRemovedEvent;
if (seriesRemoved.libraryId !== this.libraryId) return; if (seriesRemoved.libraryId !== this.libraryId) return;
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) { if (!this.utilityService.deepEqual(this.filterV2, this.filterActiveCheck)) {
this.loadPage(); this.loadPage();
return; return;
} }
@ -216,17 +230,17 @@ export class LibraryDetailComponent implements OnInit {
} }
} }
handleAction(action: ActionItem<Library>, library: Library) { async handleAction(action: ActionItem<Library>, library: Library) {
let lib: Partial<Library> = library; let lib: Partial<Library> = library;
if (library === undefined) { if (library === undefined) {
lib = {id: this.libraryId, name: this.libraryName}; lib = {id: this.libraryId, name: this.libraryName};
} }
switch (action.action) { switch (action.action) {
case(Action.Scan): case(Action.Scan):
this.actionService.scanLibrary(lib); await this.actionService.scanLibrary(lib);
break; break;
case(Action.RefreshMetadata): case(Action.RefreshMetadata):
this.actionService.refreshMetadata(lib); await this.actionService.refreshMetadata(lib);
break; break;
case(Action.Edit): case(Action.Edit):
this.actionService.editLibrary(lib); this.actionService.editLibrary(lib);
@ -245,25 +259,23 @@ export class LibraryDetailComponent implements OnInit {
} }
updateFilter(data: FilterEvent) { updateFilter(data: FilterEvent) {
this.filter = data.filter; if (data.filterV2 === undefined) return;
this.filterV2 = data.filterV2;
if (!data.isFirst) {
this.filterUtilityService.updateUrlFromFilterV2(this.pagination, this.filterV2);
}
if (!data.isFirst) this.filterUtilityService.updateUrlFromFilter(this.pagination, this.filter);
this.loadPage(); this.loadPage();
} }
loadPage() { loadPage() {
// The filter is out of sync with the presets from typeaheads on first load but syncs afterwards
if (this.filter == undefined) {
this.filter = this.filterUtilityService.createSeriesFilter();
this.filter.libraries.push(this.libraryId);
this.cdRef.markForCheck();
}
this.loadingSeries = true; this.loadingSeries = true;
this.filterActive = !this.utilityService.deepEqual(this.filter, this.filterActiveCheck); this.filterActive = !this.utilityService.deepEqual(this.filterV2, this.filterActiveCheck);
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.seriesService.getSeriesForLibrary(0, undefined, undefined, this.filter).pipe(take(1)).subscribe(series => { this.seriesService.getSeriesForLibraryV2(undefined, undefined, this.filterV2)
.subscribe(series => {
this.series = series.result; this.series = series.result;
this.pagination = series.pagination; this.pagination = series.pagination;
this.loadingSeries = false; this.loadingSeries = false;

View File

@ -5,7 +5,6 @@ import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { LibraryDetailRoutingModule } from './library-detail-routing.module'; import { LibraryDetailRoutingModule } from './library-detail-routing.module';
import { LibraryRecommendedComponent } from './library-recommended/library-recommended.component'; import { LibraryRecommendedComponent } from './library-recommended/library-recommended.component';
import {CardActionablesComponent} from "../cards/card-item/card-actionables/card-actionables.component";
import {SentenceCasePipe} from "../pipe/sentence-case.pipe"; import {SentenceCasePipe} from "../pipe/sentence-case.pipe";
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component"; import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
import {SeriesCardComponent} from "../cards/series-card/series-card.component"; import {SeriesCardComponent} from "../cards/series-card/series-card.component";
@ -13,6 +12,7 @@ import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.
import { import {
SideNavCompanionBarComponent SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; } from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";

View File

@ -150,7 +150,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
/** /**
* Debug mode. Will show extra information. Use bitwise (|) operators between different modes to enable different output * Debug mode. Will show extra information. Use bitwise (|) operators between different modes to enable different output
*/ */
debugMode: DEBUG_MODES = DEBUG_MODES.Logs; debugMode: DEBUG_MODES = DEBUG_MODES.None;
/** /**
* Debug mode. Will filter out any messages in here so they don't hit the log * Debug mode. Will filter out any messages in here so they don't hit the log
*/ */
@ -393,7 +393,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
/** /**
* Is any part of the element visible in the scrollport. Does not take into account * Is any part of the element visible in the scrollport. Does not take into account
* style properites, just scroll port visibility. * style properties, just scroll port visibility.
* @param elem * @param elem
* @returns * @returns
*/ */

View File

@ -0,0 +1,74 @@
<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">
<div class="row mb-2">
<div class="col-md-2">
<select class="form-select" formControlName="comparison">
<option *ngFor="let opt of groupOptions" [value]="opt.value">{{opt.title}}</option>
</select>
</div>
<div class="col-md-2">
<button class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')">
<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>
</div>
</div>
</ng-container>
<ng-template #mobileView>
<!-- TODO: Robbie please help me style this drawer only view -->
<div class="container-fluid">
<form [formGroup]="formGroup">
<div class="row mb-3">
<div class="col-md-2 col-10">
<select class="form-select" formControlName="comparison">
<option *ngFor="let opt of groupOptions" [value]="opt.value">{{opt.title}}</option>
</select>
</div>
<div class="col-md-2 col-1">
<button class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')">
<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-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', {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>
</ng-template>
</ng-container>
</ng-container>

View File

@ -0,0 +1,102 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
EventEmitter,
inject,
Input,
OnInit,
Output
} from '@angular/core';
import {MetadataService} from 'src/app/_services/metadata.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {SeriesFilterV2} from 'src/app/_models/metadata/v2/series-filter-v2';
import {NgForOf, NgIf, UpperCasePipe} from "@angular/common";
import {MetadataFilterRowComponent} from "../metadata-filter-row/metadata-filter-row.component";
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {FilterCombination} from "../../../_models/metadata/v2/filter-combination";
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {allFields, FilterField} from "../../../_models/metadata/v2/filter-field";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {tap} from "rxjs/operators";
import {translate, TranslocoDirective} from "@ngneat/transloco";
@Component({
selector: 'app-metadata-builder',
templateUrl: './metadata-builder.component.html',
styleUrls: ['./metadata-builder.component.scss'],
standalone: true,
imports: [
NgIf,
MetadataFilterRowComponent,
NgForOf,
CardActionablesComponent,
FormsModule,
NgbTooltip,
UpperCasePipe,
ReactiveFormsModule,
TranslocoDirective
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MetadataBuilderComponent implements OnInit {
@Input({required: true}) filter!: SeriesFilterV2;
@Input() availableFilterFields = allFields;
@Output() update: EventEmitter<SeriesFilterV2> = new EventEmitter<SeriesFilterV2>();
private readonly cdRef = inject(ChangeDetectorRef);
private readonly metadataService = inject(MetadataService);
protected readonly utilityService = inject(UtilityService);
protected readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly destroyRef = inject(DestroyRef);
formGroup: FormGroup = new FormGroup({});
groupOptions: Array<{value: FilterCombination, title: string}> = [
{value: FilterCombination.Or, title: translate('metadata-builder.or')},
{value: FilterCombination.And, title: translate('metadata-builder.and')},
];
get Breakpoint() { return Breakpoint; }
ngOnInit() {
if (this.filter === undefined) {
// I've left this in to see if it ever happens or not
console.error('No filter, creating one in metadata-builder')
// If there is no default preset, let's open with series name
this.filter = this.filterUtilityService.createSeriesV2Filter();
this.filter.statements.push({
value: '',
comparison: FilterComparison.Equal,
field: FilterField.SeriesName
});
}
this.formGroup.addControl('comparison', new FormControl<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.update.emit(this.filter);
})).subscribe()
}
addFilter() {
this.filter.statements = [this.metadataService.createDefaultFilterStatement(), ...this.filter.statements];
}
removeFilter(index: number) {
this.filter.statements = this.filter.statements.slice(0, index).concat(this.filter.statements.slice(index + 1))
this.cdRef.markForCheck();
}
updateFilter(index: number, filterStmt: FilterStatement) {
this.metadataService.updateFilter(this.filter.statements, index, filterStmt);
this.update.emit(this.filter);
}
}

View File

@ -0,0 +1,38 @@
<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>
</div>
<div class="col-md-2 me-2 col-10 mb-2">
<select class="col-auto form-select" formControlName="comparison">
<option *ngFor="let comparison of validComparisons$ | async" [value]="comparison">{{comparison | filterComparison}}</option>
</select>
</div>
<div class="col-md-4 col-10 mb-2">
<ng-container *ngIf="predicateType$ | async as predicateType">
<ng-container [ngSwitch]="predicateType">
<ng-container *ngSwitchCase="PredicateType.Text">
<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">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Dropdown">
<select class="col-auto form-select me-2" formControlName="filterValue">
<option *ngFor="let option of dropdownOptions$ | async" [value]="option.value">{{option.title}}</option>
</select>
</ng-container>
</ng-container>
</ng-container>
</div>
<ng-content #removeBtn></ng-content>
</div>
</form>

View File

@ -0,0 +1,246 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
inject,
Input,
OnInit,
Output
} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {FilterStatement} from '../../../_models/metadata/v2/filter-statement';
import {BehaviorSubject, distinctUntilChanged, map, Observable, of, startWith, switchMap, tap} from 'rxjs';
import {MetadataService} from 'src/app/_services/metadata.service';
import {mangaFormatFilters} from 'src/app/_models/metadata/series-filter';
import {PersonRole} from 'src/app/_models/metadata/person';
import {LibraryService} from 'src/app/_services/library.service';
import {CollectionTagService} from 'src/app/_services/collection-tag.service';
import {FilterComparison} from 'src/app/_models/metadata/v2/filter-comparison';
import {allFields, FilterField} from 'src/app/_models/metadata/v2/filter-field';
import {AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase} from "@angular/common";
import {FilterFieldPipe} from "../../_pipes/filter-field.pipe";
import {FilterComparisonPipe} from "../../_pipes/filter-comparison.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
enum PredicateType {
Text = 1,
Number = 2,
Dropdown = 3,
}
const StringFields = [FilterField.SeriesName, FilterField.Summary];
const NumberFields = [FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, FilterField.UserRating];
const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating,
FilterField.Translators, FilterField.Characters, FilterField.Publisher,
FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer,
FilterField.Colorist, FilterField.Inker, FilterField.Penciller,
FilterField.Writers, FilterField.Genres, FilterField.Libraries,
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags
];
const StringComparisons = [FilterComparison.Equal,
FilterComparison.NotEqual,
FilterComparison.BeginsWith,
FilterComparison.EndsWith,
FilterComparison.Matches];
const DateComparisons = [FilterComparison.IsBefore, FilterComparison.IsAfter, FilterComparison.IsInLast, FilterComparison.IsNotInLast];
const NumberComparisons = [FilterComparison.Equal,
FilterComparison.NotEqual,
FilterComparison.LessThan,
FilterComparison.LessThanEqual,
FilterComparison.GreaterThan,
FilterComparison.GreaterThanEqual];
const DropdownComparisons = [FilterComparison.Equal,
FilterComparison.NotEqual,
FilterComparison.Contains,
FilterComparison.NotContains];
@Component({
selector: 'app-metadata-row-filter',
templateUrl: './metadata-filter-row.component.html',
styleUrls: ['./metadata-filter-row.component.scss'],
standalone: true,
imports: [
ReactiveFormsModule,
AsyncPipe,
FilterFieldPipe,
FilterComparisonPipe,
NgSwitch,
NgSwitchCase,
NgForOf,
NgIf
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MetadataFilterRowComponent implements OnInit {
@Input() preset!: FilterStatement;
@Input() availableFields: Array<FilterField> = allFields;
@Output() filterStatement = new EventEmitter<FilterStatement>();
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
formGroup: FormGroup = new FormGroup({
'comparison': new FormControl<FilterComparison>(FilterComparison.Equal, []),
'filterValue': new FormControl<string | number>('', []),
});
validComparisons$: BehaviorSubject<FilterComparison[]> = new BehaviorSubject([FilterComparison.Equal] as FilterComparison[]);
predicateType$: BehaviorSubject<PredicateType> = new BehaviorSubject(PredicateType.Text as PredicateType);
dropdownOptions$ = of<{value: number, title: string}[]>([]);
loaded: boolean = false;
get PredicateType() { return PredicateType };
constructor(private readonly metadataService: MetadataService, private readonly libraryService: LibraryService,
private readonly collectionTagService: CollectionTagService) {}
ngOnInit() {
this.formGroup.addControl('input', new FormControl<FilterField>(FilterField.SeriesName, []));
this.formGroup.get('input')?.valueChanges.subscribe((val: string) => this.handleFieldChange(val));
this.populateFromPreset();
this.buildDisabledList();
// Dropdown dynamic option selection
this.dropdownOptions$ = this.formGroup.get('input')!.valueChanges.pipe(
startWith(this.preset.value),
switchMap((_) => this.getDropdownObservable()),
tap((opts) => {
const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField;
const filterComparison = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison;
if (this.preset.field === filterField && this.preset.comparison === filterComparison) {
//console.log('using preset value for dropdown option')
return;
}
this.formGroup.get('filterValue')?.setValue(opts[0].value);
}),
takeUntilDestroyed(this.destroyRef)
);
this.formGroup.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe(_ => {
this.filterStatement.emit({
comparison: parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison,
field: parseInt(this.formGroup.get('input')?.value, 10) as FilterField,
value: this.formGroup.get('filterValue')?.value!
});
});
this.loaded = true;
this.cdRef.markForCheck();
}
buildDisabledList() {
}
populateFromPreset() {
if (StringFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(this.preset.value);
} else {
this.formGroup.get('filterValue')?.patchValue(parseInt(this.preset.value, 10));
}
this.formGroup.get('comparison')?.patchValue(this.preset.comparison);
this.formGroup.get('input')?.setValue(this.preset.field);
this.cdRef.markForCheck();
}
getDropdownObservable(): Observable<{value: any, title: string}[]> {
const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField;
switch (filterField) {
case FilterField.PublicationStatus:
return this.metadataService.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => {
return {value: pub.value, title: pub.title}
})));
case FilterField.AgeRating:
return this.metadataService.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => {
return {value: rating.value, title: rating.title}
})));
case FilterField.Genres:
return this.metadataService.getAllGenres().pipe(map(genres => genres.map(genre => {
return {value: genre.id, title: genre.title}
})));
case FilterField.Languages:
return this.metadataService.getAllLanguages().pipe(map(statuses => statuses.map(status => {
return {value: status.isoCode, title: status.title + `(${status.isoCode})`}
})));
case FilterField.Formats:
return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => {
return {value: status.value, title: status.title}
})));
case FilterField.Libraries:
return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => {
return {value: lib.id, title: lib.name}
})));
case FilterField.Tags:
return this.metadataService.getAllTags().pipe(map(statuses => statuses.map(status => {
return {value: status.id, title: status.title}
})));
case FilterField.CollectionTags:
return this.collectionTagService.allTags().pipe(map(statuses => statuses.map(status => {
return {value: status.id, title: status.title}
})));
case FilterField.Characters: return this.getPersonOptions(PersonRole.Character);
case FilterField.Colorist: return this.getPersonOptions(PersonRole.Colorist);
case FilterField.CoverArtist: return this.getPersonOptions(PersonRole.CoverArtist);
case FilterField.Editor: return this.getPersonOptions(PersonRole.Editor);
case FilterField.Inker: return this.getPersonOptions(PersonRole.Inker);
case FilterField.Letterer: return this.getPersonOptions(PersonRole.Letterer);
case FilterField.Penciller: return this.getPersonOptions(PersonRole.Penciller);
case FilterField.Publisher: return this.getPersonOptions(PersonRole.Publisher);
case FilterField.Translators: return this.getPersonOptions(PersonRole.Translator);
case FilterField.Writers: return this.getPersonOptions(PersonRole.Writer);
}
return of([]);
}
getPersonOptions(role: PersonRole) {
return this.metadataService.getAllPeople().pipe(map(people => people.filter(p2 => p2.role === role).map(person => {
return {value: person.id, title: person.name}
})))
}
handleFieldChange(val: string) {
const inputVal = parseInt(val, 10) as FilterField;
if (StringFields.includes(inputVal)) {
this.validComparisons$.next(StringComparisons);
this.predicateType$.next(PredicateType.Text);
if (this.loaded) this.formGroup.get('filterValue')?.setValue('');
return;
}
if (NumberFields.includes(inputVal)) {
let comps = [...NumberComparisons];
if (inputVal === FilterField.ReleaseYear) {
comps.push(...DateComparisons);
}
this.validComparisons$.next(comps);
this.predicateType$.next(PredicateType.Number);
if (this.loaded) this.formGroup.get('filterValue')?.setValue('');
return;
}
if (DropdownFields.includes(inputVal)) {
let comps = [...DropdownComparisons];
if (inputVal === FilterField.AgeRating) {
comps.push(...NumberComparisons);
}
this.validComparisons$.next(comps);
this.predicateType$.next(PredicateType.Dropdown);
}
}
}

View File

@ -0,0 +1,48 @@
import { Pipe, PipeTransform } from '@angular/core';
import { FilterComparison } from 'src/app/_models/metadata/v2/filter-comparison';
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'filterComparison',
standalone: true
})
export class FilterComparisonPipe implements PipeTransform {
transform(value: FilterComparison): string {
switch (value) {
case FilterComparison.BeginsWith:
return translate('filter-comparison-pipe.begins-with');
case FilterComparison.Contains:
return translate('filter-comparison-pipe.contains');
case FilterComparison.Equal:
return translate('filter-comparison-pipe.equal');
case FilterComparison.GreaterThan:
return translate('filter-comparison-pipe.greater-than');
case FilterComparison.GreaterThanEqual:
return translate('filter-comparison-pipe.greater-than-or-equal');
case FilterComparison.LessThan:
return translate('filter-comparison-pipe.less-than');
case FilterComparison.LessThanEqual:
return translate('filter-comparison-pipe.less-than-or-equal');
case FilterComparison.Matches:
return translate('filter-comparison-pipe.matches');
case FilterComparison.NotContains:
return translate('filter-comparison-pipe.does-not-contain');
case FilterComparison.NotEqual:
return translate('filter-comparison-pipe.not-equal');
case FilterComparison.EndsWith:
return translate('filter-comparison-pipe.ends-with');
case FilterComparison.IsBefore:
return translate('filter-comparison-pipe.is-before');
case FilterComparison.IsAfter:
return translate('filter-comparison-pipe.is-after');
case FilterComparison.IsInLast:
return translate('filter-comparison-pipe.is-in-last');
case FilterComparison.IsNotInLast:
return translate('filter-comparison-pipe.is-not-in-last');
default:
throw new Error(`Invalid FilterComparison value: ${value}`);
}
}
}

View File

@ -0,0 +1,66 @@
import { Pipe, PipeTransform } from '@angular/core';
import { FilterField } from 'src/app/_models/metadata/v2/filter-field';
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'filterField',
standalone: true
})
export class FilterFieldPipe implements PipeTransform {
transform(value: FilterField): string {
switch (value) {
case FilterField.AgeRating:
return translate('filter-field-pipe.age-rating');
case FilterField.Characters:
return translate('filter-field-pipe.characters');
case FilterField.CollectionTags:
return translate('filter-field-pipe.collection-tags');
case FilterField.Colorist:
return translate('filter-field-pipe.colorist');
case FilterField.CoverArtist:
return translate('filter-field-pipe.cover-artist');
case FilterField.Editor:
return translate('filter-field-pipe.editor');
case FilterField.Formats:
return translate('filter-field-pipe.formats');
case FilterField.Genres:
return translate('filter-field-pipe.genres');
case FilterField.Inker:
return translate('filter-field-pipe.inker');
case FilterField.Languages:
return translate('filter-field-pipe.languages');
case FilterField.Libraries:
return translate('filter-field-pipe.libraries');
case FilterField.Letterer:
return translate('filter-field-pipe.letterer');
case FilterField.PublicationStatus:
return translate('filter-field-pipe.publication-status');
case FilterField.Penciller:
return translate('filter-field-pipe.penciller');
case FilterField.Publisher:
return translate('filter-field-pipe.publisher');
case FilterField.ReadProgress:
return translate('filter-field-pipe.read-progress');
case FilterField.ReadTime:
return translate('filter-field-pipe.read-time');
case FilterField.ReleaseYear:
return translate('filter-field-pipe.release-year');
case FilterField.SeriesName:
return translate('filter-field-pipe.series-name');
case FilterField.Summary:
return translate('filter-field-pipe.summary');
case FilterField.Tags:
return translate('filter-field-pipe.tags');
case FilterField.Translators:
return translate('filter-field-pipe.translators');
case FilterField.UserRating:
return translate('filter-field-pipe.user-rating');
case FilterField.Writers:
return translate('filter-field-pipe.writers');
default:
throw new Error(`Invalid FilterField value: ${value}`);
}
}
}

View File

@ -1,24 +1,6 @@
import { SeriesFilter } from "../_models/metadata/series-filter"; import { SeriesFilterV2 } from "../_models/metadata/v2/series-filter-v2";
export class FilterSettings { export class FilterSettings {
libraryDisabled = false;
formatDisabled = false;
collectionDisabled = false;
genresDisabled = false;
peopleDisabled = false;
readProgressDisabled = false;
ratingDisabled = false;
sortDisabled = false; sortDisabled = false;
ageRatingDisabled = false; presetsV2: SeriesFilterV2 | undefined;
tagsDisabled = false; }
languageDisabled = false;
publicationStatusDisabled = false;
searchNameDisabled = false;
releaseYearDisabled = false;
presets: SeriesFilter | undefined;
/**
* Should the filter section be open by default
* @deprecated This is deprecated UX pattern. New style is to show highlight on filter button.
*/
openByDefault = false;
}

View File

@ -19,357 +19,39 @@
</ng-container> </ng-container>
<ng-template #filterSection> <ng-template #filterSection>
<ng-template #globalFilterTooltip>{{t('format-tooltip')}}</ng-template>
<div class="filter-section mx-auto pb-3" *ngIf="fullyLoaded"> <div class="filter-section mx-auto pb-3" *ngIf="fullyLoaded">
<div class="row justify-content-center g-0"> <div class="row justify-content-center g-0">
<div class="col-md-2 me-3"> <app-metadata-builder [filter]="filterV2!" [availableFilterFields]="allFilterFields" (update)="handleFilters($event)"></app-metadata-builder>
<div class="mb-3">
<label for="format" class="form-label">{{t('format-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="globalFilterTooltip" role="button" tabindex="0"></i>
<span class="visually-hidden" id="filter-global-format-help"><ng-container [ngTemplateOutlet]="globalFilterTooltip"></ng-container></span>
<app-typeahead (selectedData)="updateFormatFilters($event)" [settings]="formatSettings" [reset]="resetTypeaheads" [disabled]="filterSettings.formatDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-2 me-3">
<div class="mb-3">
<label for="libraries" class="form-label">{{t('libraries-label')}}</label>
<app-typeahead (selectedData)="updateLibraryFilters($event)" [settings]="librarySettings" [reset]="resetTypeaheads" [disabled]="filterSettings.libraryDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-2 me-3">
<div class="mb-3">
<label for="collections" class="form-label">{{t('collections-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="globalFilterTooltip" role="button" tabindex="0"></i>
<span class="visually-hidden" id="filter-global-collections-help"><ng-container [ngTemplateOutlet]="globalFilterTooltip"></ng-container></span>
<app-typeahead (selectedData)="updateCollectionFilters($event)" [settings]="collectionSettings" [reset]="resetTypeaheads" [disabled]="filterSettings.collectionDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-2 me-3">
<div class="mb-3">
<label for="genres" class="form-label">{{t('genres-label')}}</label>
<app-typeahead (selectedData)="updateGenreFilters($event)" [settings]="genreSettings" [reset]="resetTypeaheads" [disabled]="filterSettings.genresDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-2 me-3">
<div class="mb-3">
<label for="tags" class="form-label">{{t('tags-label')}}</label>
<app-typeahead (selectedData)="updateTagFilters($event)" [settings]="tagsSettings" [reset]="resetTypeaheads" [disabled]="filterSettings.tagsDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
</div>
</div> </div>
<div class="row justify-content-center g-0"> <form [formGroup]="sortGroup" class="container-fluid">
<!-- The People row --> <div class="row mb-3">
<div class="col-md-2 me-3"> <div class="col-md-2">
<div class="mb-3"> <div class="form-group pe-1">
<label for="cover-artist" class="form-label">{{t('cover-artist-label')}}</label> <label for="limit-to" class="form-label">{{t('limit-label')}}</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.CoverArtist)" [settings]="getPersonsSettings(PersonRole.CoverArtist)" <input id="limit-to" type="number" inputmode="numeric" class="form-control" formControlName="limitTo">
[reset]="resetTypeaheads" [disabled]="!peopleSettings.hasOwnProperty(PersonRole.CoverArtist) || filterSettings.peopleDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-2 me-3">
<div class="mb-3">
<label for="writers" class="form-label">{{t('writer-label')}}</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Writer)" [settings]="getPersonsSettings(PersonRole.Writer)"
[reset]="resetTypeaheads" [disabled]="!peopleSettings.hasOwnProperty(PersonRole.Writer) || filterSettings.peopleDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-2 me-3">
<div class="mb-3">
<label for="publisher" class="form-label">{{t('publisher-label')}}</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Publisher)" [settings]="getPersonsSettings(PersonRole.Publisher)"
[reset]="resetTypeaheads" [disabled]="!peopleSettings.hasOwnProperty(PersonRole.Publisher) || filterSettings.peopleDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-2 me-3">
<div class="mb-3">
<label for="penciller" class="form-label">{{t('penciller-label')}}</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Penciller)" [settings]="getPersonsSettings(PersonRole.Penciller)"
[reset]="resetTypeaheads" [disabled]="!peopleSettings.hasOwnProperty(PersonRole.Penciller) || filterSettings.peopleDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-2 me-3">
<div class="mb-3">
<label for="letterer" class="form-label">{{t('letterer-label')}}</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Letterer)" [settings]="getPersonsSettings(PersonRole.Letterer)"
[reset]="resetTypeaheads" [disabled]="!peopleSettings.hasOwnProperty(PersonRole.Letterer) || filterSettings.peopleDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-2 me-3">
<div class="mb-3">
<label for="inker" class="form-label">{{t('inker-label')}}</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Inker)" [settings]="getPersonsSettings(PersonRole.Inker)"
[reset]="resetTypeaheads" [disabled]="!peopleSettings.hasOwnProperty(PersonRole.Inker) || filterSettings.peopleDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-2 me-3">
<div class="mb-3">
<label for="editor" class="form-label">{{t('editor-label')}}</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Editor)" [settings]="getPersonsSettings(PersonRole.Editor)"
[reset]="resetTypeaheads" [disabled]="!peopleSettings.hasOwnProperty(PersonRole.Editor) || filterSettings.peopleDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-2 me-3">
<div class="mb-3">
<label for="colorist" class="form-label">{{t('colorist-label')}}</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Colorist)" [settings]="getPersonsSettings(PersonRole.Colorist)"
[reset]="resetTypeaheads" [disabled]="!peopleSettings.hasOwnProperty(PersonRole.Colorist) || filterSettings.peopleDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-2 me-3">
<div class="mb-3">
<label for="character" class="form-label">{{t('character-label')}}</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Character)" [settings]="getPersonsSettings(PersonRole.Character)"
[reset]="resetTypeaheads" [disabled]="!peopleSettings.hasOwnProperty(PersonRole.Character) || filterSettings.peopleDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="col-md-2 me-3">
<div class="mb-3">
<label for="translators" class="form-label">{{t('translator-label')}}</label>
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.Translator)" [settings]="getPersonsSettings(PersonRole.Translator)"
[reset]="resetTypeaheads" [disabled]="!peopleSettings.hasOwnProperty(PersonRole.Translator) || filterSettings.peopleDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
</div>
<div class="row justify-content-center g-0">
<div class="col-md-2 me-3">
<label class="form-label">{{t('read-progress-label')}}</label>
<form [formGroup]="readProgressGroup">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="notread" formControlName="notRead">
<label class="form-check-label" for="notread">{{t('unread')}}</label>
</div> </div>
<div class="form-check form-check-inline"> </div>
<input class="form-check-input" type="checkbox" id="inprogress" formControlName="inProgress"> <div class="col-md-3">
<label class="form-check-label" for="inprogress">{{t('in-progress')}}</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="read" formControlName="read">
<label class="form-check-label" for="read">{{t('read')}}</label>
</div>
</form>
</div>
<div class="col-md-2 me-3">
<label for="ratings" class="form-label">{{t('rating-label')}}</label>
<form class="form-inline">
<ngb-rating class="rating-star" [(rate)]="filter.rating" (rateChange)="updateRating($event)" [resettable]="true">
<ng-template let-fill="fill" let-index="index">
<span class="star" [class.filled]="(index >= (filter.rating - 1)) && filter.rating > 0" [ngbTooltip]="(index + 1) + ' and up'">&#9733;</span>
</ng-template>
</ngb-rating>
</form>
</div>
<div class="col-md-2 me-3">
<label for="age-rating" class="form-label">{{t('age-rating-label')}}</label>
<app-typeahead (selectedData)="updateAgeRating($event)" [settings]="ageRatingSettings" [reset]="resetTypeaheads" [disabled]="filterSettings.ageRatingDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
<div class="col-md-2 me-3">
<label for="languages" class="form-label">{{t('language-label')}}</label>
<app-typeahead (selectedData)="updateLanguages($event)" [settings]="languageSettings"
[reset]="resetTypeaheads" [disabled]="filterSettings.languageDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
<div class="col-md-2 me-3">
<label for="publication-status" class="form-label">{{t('publication-status-label')}}</label>
<app-typeahead (selectedData)="updatePublicationStatus($event)" [settings]="publicationStatusSettings"
[reset]="resetTypeaheads" [disabled]="filterSettings.publicationStatusDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
<div class="col-md-2 me-3"></div>
</div>
<div class="row justify-content-center g-0">
<div class="col-md-2 me-3">
<form [formGroup]="seriesNameGroup">
<div class="mb-3">
<label for="series-name" class="form-label me-1">{{t('series-name-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="seriesNameFilterTooltip" role="button" tabindex="0"></i>
<span class="visually-hidden" id="filter-series-name-help"><ng-container [ngTemplateOutlet]="seriesNameFilterTooltip"></ng-container></span>
<ng-template #seriesNameFilterTooltip>{{t('series-name-tooltip')}}</ng-template>
<input type="text" id="series-name" formControlName="seriesNameQuery" class="form-control" aria-describedby="filter-series-name-help" (keyup.enter)="apply()">
</div>
</form>
</div>
<div class="col-md-2 me-3">
<form [formGroup]="releaseYearRange" class="d-flex justify-content-between">
<div class="mb-3">
<label for="release-year-min" class="form-label">{{t('release-label')}}</label>
<input type="number" id="release-year-min" formControlName="min" class="form-control custom-number" style="width: 62px" [placeholder]="t('min')" (keyup.enter)="apply()">
</div>
<div style="margin-top: 37px !important;">
<i class="fa-solid fa-minus" aria-hidden="true"></i>
</div>
<div class="mb-3" style="margin-top: 0.5rem">
<label for="release-year-max" class="form-label"><span class="visually-hidden">Max</span></label>
<input type="number" id="release-year-max" formControlName="max" class="form-control custom-number" style="width: 62px" [placeholder]="t('max')" (keyup.enter)="apply()">
</div>
</form>
</div>
<div class="col-md-2 me-3">
<form [formGroup]="sortGroup">
<div class="mb-3">
<label for="sort-options" class="form-label">{{t('sort-by-label')}}</label> <label for="sort-options" class="form-label">{{t('sort-by-label')}}</label>
<button class="btn btn-sm btn-secondary-outline" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0px;" [disabled]="filterSettings.sortDisabled"> <button class="btn btn-sm btn-secondary-outline" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0;" [disabled]="filterSettings.sortDisabled">
<i class="fa fa-arrow-up" [title]="t('ascending-alt')" *ngIf="isAscendingSort; else descSort"></i> <i class="fa fa-arrow-up" [title]="t('ascending-alt')" *ngIf="isAscendingSort; else descSort"></i>
<ng-template #descSort> <ng-template #descSort>
<i class="fa fa-arrow-down" [title]="t('descending-alt')"></i> <i class="fa fa-arrow-down" [title]="t('descending-alt')"></i>
</ng-template> </ng-template>
</button> </button>
<select id="sort-options" class="form-select" formControlName="sortField" style="height: 38px;"> <select id="sort-options" class="form-select" formControlName="sortField" style="height: 38px;">
<option [value]="SortField.SortName">{{SortField.SortName | sortField}}</option> <option *ngFor="let field of allSortFields" [value]="field">{{field | sortField}}</option>
<option [value]="SortField.Created">{{SortField.Created | sortField}}</option>
<option [value]="SortField.LastModified">{{SortField.LastModified | sortField}}</option>
<option [value]="SortField.LastChapterAdded">{{SortField.LastChapterAdded | sortField}}</option>
<option [value]="SortField.TimeToRead">{{SortField.TimeToRead | sortField}}</option>
<option [value]="SortField.ReleaseYear">{{SortField.ReleaseYear | sortField}}</option>
</select> </select>
</div> </div>
</form> <!-- TODO: I might want to put a Clear button which blanks out the whole filter -->
</div> <div class="col-md-2 me-3 mt-4">
<div class="col-md-2 me-3 mt-4"> <button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button> </div>
</div> <div class="col-md-2 me-3 mt-4">
<div class="col-md-2 me-3 mt-4"> <button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button> </div>
</div>
</div> </div>
</form>
</div> </div>
</ng-template> </ng-template>
</ng-container> </ng-container>

View File

@ -2,40 +2,31 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ContentChild, DestroyRef, ContentChild,
DestroyRef,
EventEmitter, EventEmitter,
inject, inject,
Input, Input,
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import { FormControl, FormGroup, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms'; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
import { NgbCollapse, NgbTooltip, NgbRating } from '@ng-bootstrap/ng-bootstrap'; import {NgbCollapse, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import { distinctUntilChanged, forkJoin, map, Observable, of, ReplaySubject } from 'rxjs'; import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service';
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service'; import {Breakpoint, UtilityService} from '../shared/_services/utility.service';
import { Breakpoint, UtilityService } from '../shared/_services/utility.service'; import {Library} from '../_models/library';
import { TypeaheadSettings } from '../typeahead/_models/typeahead-settings'; import {allSortFields, FilterEvent, FilterItem, SortField} from '../_models/metadata/series-filter';
import { CollectionTag } from '../_models/collection-tag'; import {ToggleService} from '../_services/toggle.service';
import { Genre } from '../_models/metadata/genre'; import {FilterSettings} from './filter-settings';
import { Library } from '../_models/library'; import {SeriesFilterV2} from '../_models/metadata/v2/series-filter-v2';
import { MangaFormat } from '../_models/manga-format';
import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
import { Language } from '../_models/metadata/language';
import { PublicationStatusDto } from '../_models/metadata/publication-status-dto';
import { Person, PersonRole } from '../_models/metadata/person';
import { FilterEvent, FilterItem, mangaFormatFilters, SeriesFilter, SortField } from '../_models/metadata/series-filter';
import { Tag } from '../_models/tag';
import { CollectionTagService } from '../_services/collection-tag.service';
import { LibraryService } from '../_services/library.service';
import { MetadataService } from '../_services/metadata.service';
import { ToggleService } from '../_services/toggle.service';
import { FilterSettings } from './filter-settings';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { TypeaheadComponent } from '../typeahead/_components/typeahead.component'; import {TypeaheadComponent} from '../typeahead/_components/typeahead.component';
import { DrawerComponent } from '../shared/drawer/drawer.component'; import {DrawerComponent} from '../shared/drawer/drawer.component';
import { NgIf, NgTemplateOutlet, AsyncPipe } from '@angular/common'; import {AsyncPipe, NgForOf, NgIf, NgTemplateOutlet} from '@angular/common';
import {TranslocoModule} from "@ngneat/transloco"; import {TranslocoModule} from "@ngneat/transloco";
import {SortFieldPipe} from "../pipe/sort-field.pipe"; import {SortFieldPipe} from "../pipe/sort-field.pipe";
import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component";
import {allFields} from "../_models/metadata/v2/filter-field";
@Component({ @Component({
selector: 'app-metadata-filter', selector: 'app-metadata-filter',
@ -44,7 +35,7 @@ import {SortFieldPipe} from "../pipe/sort-field.pipe";
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [NgIf, NgbCollapse, NgTemplateOutlet, DrawerComponent, NgbTooltip, TypeaheadComponent, imports: [NgIf, NgbCollapse, NgTemplateOutlet, DrawerComponent, NgbTooltip, TypeaheadComponent,
ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe] ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe, MetadataBuilderComponent, NgForOf]
}) })
export class MetadataFilterComponent implements OnInit { export class MetadataFilterComponent implements OnInit {
@ -66,47 +57,34 @@ export class MetadataFilterComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
formatSettings: TypeaheadSettings<FilterItem<MangaFormat>> = new TypeaheadSettings();
librarySettings: TypeaheadSettings<Library> = new TypeaheadSettings();
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
collectionSettings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
ageRatingSettings: TypeaheadSettings<AgeRatingDto> = new TypeaheadSettings();
publicationStatusSettings: TypeaheadSettings<PublicationStatusDto> = new TypeaheadSettings();
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
resetTypeaheads: ReplaySubject<boolean> = new ReplaySubject(1);
/** /**
* Controls the visibility of extended controls that sit below the main header. * Controls the visibility of extended controls that sit below the main header.
*/ */
filteringCollapsed: boolean = true; filteringCollapsed: boolean = true;
filter!: SeriesFilter;
libraries: Array<FilterItem<Library>> = []; libraries: Array<FilterItem<Library>> = [];
readProgressGroup!: FormGroup;
sortGroup!: FormGroup; sortGroup!: FormGroup;
seriesNameGroup!: FormGroup;
releaseYearRange!: FormGroup;
isAscendingSort: boolean = true; isAscendingSort: boolean = true;
updateApplied: number = 0; updateApplied: number = 0;
fullyLoaded: boolean = false; fullyLoaded: boolean = false;
filterV2: SeriesFilterV2 | undefined;
allSortFields = allSortFields;
allFilterFields = allFields;
get PersonRole(): typeof PersonRole { handleFilters(filter: SeriesFilterV2) {
return PersonRole; this.filterV2 = filter;
} }
get SortField(): typeof SortField {
return SortField;
}
constructor(private libraryService: LibraryService, private metadataService: MetadataService, private utilityService: UtilityService, private readonly cdRef = inject(ChangeDetectorRef);
private collectionTagService: CollectionTagService, public toggleService: ToggleService,
private readonly cdRef: ChangeDetectorRef, private filterUtilitySerivce: FilterUtilitiesService) {
constructor(private utilityService: UtilityService,
public toggleService: ToggleService,
private filterUtilityService: FilterUtilitiesService) {
} }
ngOnInit(): void { ngOnInit(): void {
@ -123,78 +101,6 @@ export class MetadataFilterComponent implements OnInit {
}); });
} }
this.filter = this.filterUtilitySerivce.createSeriesFilter();
this.readProgressGroup = new FormGroup({
read: new FormControl({value: this.filter.readStatus.read, disabled: this.filterSettings.readProgressDisabled}, []),
notRead: new FormControl({value: this.filter.readStatus.notRead, disabled: this.filterSettings.readProgressDisabled}, []),
inProgress: new FormControl({value: this.filter.readStatus.inProgress, disabled: this.filterSettings.readProgressDisabled}, []),
});
this.sortGroup = new FormGroup({
sortField: new FormControl({value: this.filter.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []),
});
this.seriesNameGroup = new FormGroup({
seriesNameQuery: new FormControl({value: this.filter.seriesNameQuery || '', disabled: this.filterSettings.searchNameDisabled}, [])
});
this.releaseYearRange = new FormGroup({
min: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999), Validators.maxLength(4), Validators.minLength(4)]),
max: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999), Validators.maxLength(4), Validators.minLength(4)])
});
this.readProgressGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(changes => {
this.filter.readStatus.read = this.readProgressGroup.get('read')?.value;
this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value;
this.filter.readStatus.notRead = this.readProgressGroup.get('notRead')?.value;
let sum = 0;
sum += (this.filter.readStatus.read ? 1 : 0);
sum += (this.filter.readStatus.inProgress ? 1 : 0);
sum += (this.filter.readStatus.notRead ? 1 : 0);
if (sum === 1) {
if (this.filter.readStatus.read) this.readProgressGroup.get('read')?.disable({ emitEvent: false });
if (this.filter.readStatus.notRead) this.readProgressGroup.get('notRead')?.disable({ emitEvent: false });
if (this.filter.readStatus.inProgress) this.readProgressGroup.get('inProgress')?.disable({ emitEvent: false });
} else {
this.readProgressGroup.get('read')?.enable({ emitEvent: false });
this.readProgressGroup.get('notRead')?.enable({ emitEvent: false });
this.readProgressGroup.get('inProgress')?.enable({ emitEvent: false });
}
this.cdRef.markForCheck();
});
this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(changes => {
if (this.filter.sortOptions == null) {
this.filter.sortOptions = {
isAscending: this.isAscendingSort,
sortField: parseInt(this.sortGroup.get('sortField')?.value, 10)
};
}
this.filter.sortOptions.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
this.cdRef.markForCheck();
});
this.seriesNameGroup.get('seriesNameQuery')?.valueChanges.pipe(
map(val => (val || '').trim()),
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(changes => {
this.filter.seriesNameQuery = changes; // TODO: See if we can make this into observable
this.cdRef.markForCheck();
});
this.releaseYearRange.valueChanges.pipe(
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(changes => {
this.filter.releaseYearRange = {min: this.releaseYearRange.get('min')?.value, max: this.releaseYearRange.get('max')?.value};
this.cdRef.markForCheck();
});
this.loadFromPresetsAndSetup(); this.loadFromPresetsAndSetup();
} }
@ -205,444 +111,80 @@ export class MetadataFilterComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
getPersonsSettings(role: PersonRole) { deepClone(obj: any): any {
return this.peopleSettings[role]; if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj instanceof Array) {
return obj.map(item => this.deepClone(item));
}
const clonedObj: any = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
if (typeof obj[key] === 'object' && obj[key] !== null) {
clonedObj[key] = this.deepClone(obj[key]);
} else {
clonedObj[key] = obj[key];
}
}
}
return clonedObj;
} }
loadFromPresetsAndSetup() { loadFromPresetsAndSetup() {
this.fullyLoaded = false; this.fullyLoaded = false;
if (this.filterSettings.presets) {
this.readProgressGroup.get('read')?.patchValue(this.filterSettings.presets.readStatus.read);
this.readProgressGroup.get('notRead')?.patchValue(this.filterSettings.presets.readStatus.notRead);
this.readProgressGroup.get('inProgress')?.patchValue(this.filterSettings.presets.readStatus.inProgress);
if (this.filterSettings.presets.sortOptions) { this.filterV2 = this.deepClone(this.filterSettings.presetsV2);
this.sortGroup.get('sortField')?.setValue(this.filterSettings.presets.sortOptions.sortField);
this.isAscendingSort = this.filterSettings.presets.sortOptions.isAscending;
if (this.filter.sortOptions) {
this.filter.sortOptions.isAscending = this.isAscendingSort;
this.filter.sortOptions.sortField = this.filterSettings.presets.sortOptions.sortField;
}
}
if (this.filterSettings.presets.rating > 0) { this.sortGroup = new FormGroup({
this.updateRating(this.filterSettings.presets.rating); sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []),
} limitTo: new FormControl(this.filterV2?.limitTo || 0, [])
if (this.filterSettings.presets.seriesNameQuery !== '') {
this.seriesNameGroup.get('searchNameQuery')?.setValue(this.filterSettings.presets.seriesNameQuery);
}
}
this.setupFormatTypeahead();
this.cdRef.markForCheck();
forkJoin([
this.setupLibraryTypeahead(),
this.setupCollectionTagTypeahead(),
this.setupAgeRatingSettings(),
this.setupPublicationStatusSettings(),
this.setupTagSettings(),
this.setupLanguageSettings(),
this.setupGenreTypeahead(),
this.setupPersonTypeahead(),
]).subscribe(results => {
this.fullyLoaded = true;
this.resetTypeaheads.next(false); // Pass false to ensure we reset to the preset and not to an empty typeahead
this.cdRef.markForCheck();
this.apply();
}); });
this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
if (this.filterV2?.sortOptions === null) {
this.filterV2.sortOptions = {
isAscending: this.isAscendingSort,
sortField: parseInt(this.sortGroup.get('sortField')?.value, 10)
};
}
this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
this.filterV2!.limitTo = parseInt(this.sortGroup.get('limitTo')?.value, 10);
this.cdRef.markForCheck();
});
this.fullyLoaded = true;
this.cdRef.markForCheck();
this.apply();
} }
setupFormatTypeahead() {
this.formatSettings.minCharacters = 0;
this.formatSettings.multiple = true;
this.formatSettings.id = 'format';
this.formatSettings.unique = true;
this.formatSettings.addIfNonExisting = false;
this.formatSettings.fetchFn = (filter: string) => of(mangaFormatFilters).pipe(map(items => this.formatSettings.compareFn(items, filter)));
this.formatSettings.compareFn = (options: FilterItem<MangaFormat>[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.formatSettings.selectionCompareFn = (a: FilterItem<MangaFormat>, b: FilterItem<MangaFormat>) => {
return a.title == b.title;
}
if (this.filterSettings.presets?.formats && this.filterSettings.presets?.formats.length > 0) {
this.formatSettings.savedData = mangaFormatFilters.filter(item => this.filterSettings.presets?.formats.includes(item.value));
this.updateFormatFilters(this.formatSettings.savedData);
}
}
setupLibraryTypeahead() {
this.librarySettings.minCharacters = 0;
this.librarySettings.multiple = true;
this.librarySettings.id = 'libraries';
this.librarySettings.unique = true;
this.librarySettings.addIfNonExisting = false;
this.librarySettings.fetchFn = (filter: string) => {
return this.libraryService.getLibraries()
.pipe(map(items => this.librarySettings.compareFn(items, filter)));
};
this.librarySettings.compareFn = (options: Library[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.name, filter));
}
this.librarySettings.selectionCompareFn = (a: Library, b: Library) => {
return a.name == b.name;
}
if (this.filterSettings.presets?.libraries && this.filterSettings.presets?.libraries.length > 0) {
return this.librarySettings.fetchFn('').pipe(map(libraries => {
this.librarySettings.savedData = libraries.filter(item => this.filterSettings.presets?.libraries.includes(item.id));
this.updateLibraryFilters(this.librarySettings.savedData);
return of(true);
}));
}
return of(true);
}
setupGenreTypeahead() {
this.genreSettings.minCharacters = 0;
this.genreSettings.multiple = true;
this.genreSettings.id = 'genres';
this.genreSettings.unique = true;
this.genreSettings.addIfNonExisting = false;
this.genreSettings.fetchFn = (filter: string) => {
return this.metadataService.getAllGenres(this.filter.libraries)
.pipe(map(items => this.genreSettings.compareFn(items, filter)));
};
this.genreSettings.compareFn = (options: Genre[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.genreSettings.selectionCompareFn = (a: Genre, b: Genre) => {
return a.title == b.title;
}
if (this.filterSettings.presets?.genres && this.filterSettings.presets?.genres.length > 0) {
return this.genreSettings.fetchFn('').pipe(map(genres => {
this.genreSettings.savedData = genres.filter(item => this.filterSettings.presets?.genres.includes(item.id));
this.updateGenreFilters(this.genreSettings.savedData);
return of(true);
}));
}
return of(true);
}
setupAgeRatingSettings() {
this.ageRatingSettings.minCharacters = 0;
this.ageRatingSettings.multiple = true;
this.ageRatingSettings.id = 'age-rating';
this.ageRatingSettings.unique = true;
this.ageRatingSettings.addIfNonExisting = false;
this.ageRatingSettings.fetchFn = (filter: string) => this.metadataService.getAllAgeRatings(this.filter.libraries)
.pipe(map(items => this.ageRatingSettings.compareFn(items, filter)));
this.ageRatingSettings.compareFn = (options: AgeRatingDto[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.ageRatingSettings.selectionCompareFn = (a: AgeRatingDto, b: AgeRatingDto) => {
return a.title == b.title;
}
if (this.filterSettings.presets?.ageRating && this.filterSettings.presets?.ageRating.length > 0) {
return this.ageRatingSettings.fetchFn('').pipe(map(rating => {
this.ageRatingSettings.savedData = rating.filter(item => this.filterSettings.presets?.ageRating.includes(item.value));
this.updateAgeRating(this.ageRatingSettings.savedData);
return of(true);
}));
}
return of(true);
}
setupPublicationStatusSettings() {
this.publicationStatusSettings.minCharacters = 0;
this.publicationStatusSettings.multiple = true;
this.publicationStatusSettings.id = 'publication-status';
this.publicationStatusSettings.unique = true;
this.publicationStatusSettings.addIfNonExisting = false;
this.publicationStatusSettings.fetchFn = (filter: string) => this.metadataService.getAllPublicationStatus(this.filter.libraries)
.pipe(map(items => this.publicationStatusSettings.compareFn(items, filter)));
this.publicationStatusSettings.compareFn = (options: PublicationStatusDto[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.publicationStatusSettings.selectionCompareFn = (a: PublicationStatusDto, b: PublicationStatusDto) => {
return a.title == b.title;
}
if (this.filterSettings.presets?.publicationStatus && this.filterSettings.presets?.publicationStatus.length > 0) {
return this.publicationStatusSettings.fetchFn('').pipe(map(statuses => {
this.publicationStatusSettings.savedData = statuses.filter(item => this.filterSettings.presets?.publicationStatus.includes(item.value));
this.updatePublicationStatus(this.publicationStatusSettings.savedData);
return of(true);
}));
}
return of(true);
}
setupTagSettings() {
this.tagsSettings.minCharacters = 0;
this.tagsSettings.multiple = true;
this.tagsSettings.id = 'tags';
this.tagsSettings.unique = true;
this.tagsSettings.addIfNonExisting = false;
this.tagsSettings.compareFn = (options: Tag[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags(this.filter.libraries)
.pipe(map(items => this.tagsSettings.compareFn(items, filter)));
this.tagsSettings.selectionCompareFn = (a: Tag, b: Tag) => {
return a.id == b.id;
}
if (this.filterSettings.presets?.tags && this.filterSettings.presets?.tags.length > 0) {
return this.tagsSettings.fetchFn('').pipe(map(tags => {
this.tagsSettings.savedData = tags.filter(item => this.filterSettings.presets?.tags.includes(item.id));
this.updateTagFilters(this.tagsSettings.savedData);
return of(true);
}));
}
return of(true);
}
setupLanguageSettings() {
this.languageSettings.minCharacters = 0;
this.languageSettings.multiple = true;
this.languageSettings.id = 'languages';
this.languageSettings.unique = true;
this.languageSettings.addIfNonExisting = false;
this.languageSettings.compareFn = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.languageSettings.fetchFn = (filter: string) => this.metadataService.getAllLanguages(this.filter.libraries)
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
this.languageSettings.selectionCompareFn = (a: Language, b: Language) => {
return a.isoCode == b.isoCode;
}
if (this.filterSettings.presets?.languages && this.filterSettings.presets?.languages.length > 0) {
return this.languageSettings.fetchFn('').pipe(map(languages => {
this.languageSettings.savedData = languages.filter(item => this.filterSettings.presets?.languages.includes(item.isoCode));
this.updateLanguages(this.languageSettings.savedData);
return of(true);
}));
}
return of(true);
}
setupCollectionTagTypeahead() {
this.collectionSettings.minCharacters = 0;
this.collectionSettings.multiple = true;
this.collectionSettings.id = 'collections';
this.collectionSettings.unique = true;
this.collectionSettings.addIfNonExisting = false;
this.collectionSettings.compareFn = (options: CollectionTag[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.collectionSettings.fetchFn = (filter: string) => this.collectionTagService.allTags()
.pipe(map(items => this.collectionSettings.compareFn(items, filter)));
this.collectionSettings.selectionCompareFn = (a: CollectionTag, b: CollectionTag) => {
return a.id == b.id;
}
if (this.filterSettings.presets?.collectionTags && this.filterSettings.presets?.collectionTags.length > 0) {
return this.collectionSettings.fetchFn('').pipe(map(tags => {
this.collectionSettings.savedData = tags.filter(item => this.filterSettings.presets?.collectionTags.includes(item.id));
this.updateCollectionFilters(this.collectionSettings.savedData);
return of(true);
}));
}
return of(true);
}
updateFromPreset(id: string, peopleFilterField: Array<any>, presetField: Array<any> | undefined, role: PersonRole) {
const personSettings = this.createBlankPersonSettings(id, role)
if (presetField && presetField.length > 0) {
const fetch = personSettings.fetchFn as ((filter: string) => Observable<Person[]>);
return fetch('').pipe(map(people => {
personSettings.savedData = people.filter(item => presetField.includes(item.id));
this.peopleSettings[role] = personSettings;
this.updatePersonFilters(personSettings.savedData, role);
return true;
}));
}
this.peopleSettings[role] = personSettings;
return of(true);
}
setupPersonTypeahead() {
this.peopleSettings = {};
return forkJoin([
this.updateFromPreset('writers', this.filter.writers, this.filterSettings.presets?.writers, PersonRole.Writer),
this.updateFromPreset('character', this.filter.character, this.filterSettings.presets?.character, PersonRole.Character),
this.updateFromPreset('colorist', this.filter.colorist, this.filterSettings.presets?.colorist, PersonRole.Colorist),
this.updateFromPreset('cover-artist', this.filter.coverArtist, this.filterSettings.presets?.coverArtist, PersonRole.CoverArtist),
this.updateFromPreset('editor', this.filter.editor, this.filterSettings.presets?.editor, PersonRole.Editor),
this.updateFromPreset('inker', this.filter.inker, this.filterSettings.presets?.inker, PersonRole.Inker),
this.updateFromPreset('letterer', this.filter.letterer, this.filterSettings.presets?.letterer, PersonRole.Letterer),
this.updateFromPreset('penciller', this.filter.penciller, this.filterSettings.presets?.penciller, PersonRole.Penciller),
this.updateFromPreset('publisher', this.filter.publisher, this.filterSettings.presets?.publisher, PersonRole.Publisher),
this.updateFromPreset('translators', this.filter.translators, this.filterSettings.presets?.translators, PersonRole.Translator)
]).pipe(map(_ => {
return of(true);
}));
}
fetchPeople(role: PersonRole, filter: string) {
return this.metadataService.getAllPeople(this.filter.libraries).pipe(map(people => {
return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter));
}));
}
createBlankPersonSettings(id: string, role: PersonRole) {
var personSettings = new TypeaheadSettings<Person>();
personSettings.minCharacters = 0;
personSettings.multiple = true;
personSettings.unique = true;
personSettings.addIfNonExisting = false;
personSettings.id = id;
personSettings.compareFn = (options: Person[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.name, filter));
}
personSettings.selectionCompareFn = (a: Person, b: Person) => {
return a.name == b.name && a.role == b.role;
}
personSettings.fetchFn = (filter: string) => {
return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter)));
};
return personSettings;
}
updateFormatFilters(formats: FilterItem<MangaFormat>[]) {
this.filter.formats = formats.map(item => item.value) || [];
this.formatSettings.savedData = formats;
}
updateLibraryFilters(libraries: Library[]) {
this.filter.libraries = libraries.map(item => item.id) || [];
this.librarySettings.savedData = libraries;
}
updateGenreFilters(genres: Genre[]) {
this.filter.genres = genres.map(item => item.id) || [];
this.genreSettings.savedData = genres;
}
updateTagFilters(tags: Tag[]) {
this.filter.tags = tags.map(item => item.id) || [];
this.tagsSettings.savedData = tags;
}
updatePersonFilters(persons: Person[], role: PersonRole) {
this.peopleSettings[role].savedData = persons;
switch (role) {
case PersonRole.CoverArtist:
this.filter.coverArtist = persons.map(p => p.id);
break;
case PersonRole.Character:
this.filter.character = persons.map(p => p.id);
break;
case PersonRole.Colorist:
this.filter.colorist = persons.map(p => p.id);
break;
case PersonRole.Editor:
this.filter.editor = persons.map(p => p.id);
break;
case PersonRole.Inker:
this.filter.inker = persons.map(p => p.id);
break;
case PersonRole.Letterer:
this.filter.letterer = persons.map(p => p.id);
break;
case PersonRole.Penciller:
this.filter.penciller = persons.map(p => p.id);
break;
case PersonRole.Publisher:
this.filter.publisher = persons.map(p => p.id);
break;
case PersonRole.Writer:
this.filter.writers = persons.map(p => p.id);
break;
case PersonRole.Translator:
this.filter.translators = persons.map(p => p.id);
}
}
updateCollectionFilters(tags: CollectionTag[]) {
this.filter.collectionTags = tags.map(item => item.id) || [];
this.collectionSettings.savedData = tags;
}
updateRating(rating: any) {
if (this.filterSettings.ratingDisabled) return;
this.filter.rating = rating;
}
updateAgeRating(ratingDtos: AgeRatingDto[]) {
this.filter.ageRating = ratingDtos.map(item => item.value) || [];
this.ageRatingSettings.savedData = ratingDtos;
}
updatePublicationStatus(dtos: PublicationStatusDto[]) {
this.filter.publicationStatus = dtos.map(item => item.value) || [];
this.publicationStatusSettings.savedData = dtos;
}
updateLanguages(languages: Language[]) {
this.filter.languages = languages.map(item => item.isoCode) || [];
this.languageSettings.savedData = languages;
}
updateReadStatus(status: string) {
if (status === 'read') {
this.filter.readStatus.read = !this.filter.readStatus.read;
} else if (status === 'inProgress') {
this.filter.readStatus.inProgress = !this.filter.readStatus.inProgress;
} else if (status === 'notRead') {
this.filter.readStatus.notRead = !this.filter.readStatus.notRead;
}
}
updateSortOrder() { updateSortOrder() {
if (this.filterSettings.sortDisabled) return; if (this.filterSettings.sortDisabled) return;
this.isAscendingSort = !this.isAscendingSort; this.isAscendingSort = !this.isAscendingSort;
if (this.filter.sortOptions === null) { if (this.filterV2?.sortOptions === null) {
this.filter.sortOptions = { this.filterV2.sortOptions = {
isAscending: this.isAscendingSort, isAscending: this.isAscendingSort,
sortField: SortField.SortName sortField: SortField.SortName
} }
} }
this.filter.sortOptions.isAscending = this.isAscendingSort; this.filterV2!.sortOptions!.isAscending = this.isAscendingSort;
} }
clear() { clear() {
this.filter = this.filterUtilitySerivce.createSeriesFilter(); // Apply any presets which will trigger the "apply"
this.readProgressGroup.get('read')?.setValue(true);
this.readProgressGroup.get('notRead')?.setValue(true);
this.readProgressGroup.get('inProgress')?.setValue(true);
this.sortGroup.get('sortField')?.setValue(SortField.SortName);
this.isAscendingSort = true;
this.seriesNameGroup.get('seriesNameQuery')?.setValue('');
this.cdRef.markForCheck();
// Apply any presets which will trigger the apply
this.loadFromPresetsAndSetup(); this.loadFromPresetsAndSetup();
} }
apply() { apply() {
this.applyFilter.emit({filter: this.filter, isFirst: this.updateApplied === 0});
this.applyFilter.emit({isFirst: this.updateApplied === 0, filterV2: this.filterV2!});
if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile && this.updateApplied !== 0) { if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile && this.updateApplied !== 0) {
this.toggleSelected(); this.toggleSelected();

View File

@ -96,7 +96,6 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
this.activeEvents += 1; this.activeEvents += 1;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} else if (event.event === EVENTS.UpdateAvailable) { } else if (event.event === EVENTS.UpdateAvailable) {
console.log('event: ', event);
this.handleUpdateAvailableClick(event.payload); this.handleUpdateAvailableClick(event.payload);
} }
}); });

View File

@ -1,3 +1,4 @@
import { inject } from '@angular/core';
import { Pipe, PipeTransform, SecurityContext } from '@angular/core'; import { Pipe, PipeTransform, SecurityContext } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
@ -7,8 +8,8 @@ import { DomSanitizer } from '@angular/platform-browser';
standalone: true standalone: true
}) })
export class SafeHtmlPipe implements PipeTransform { export class SafeHtmlPipe implements PipeTransform {
private readonly dom: DomSanitizer = inject(DomSanitizer);
constructor(private dom: DomSanitizer) {} constructor() {}
transform(value: string): unknown { transform(value: string): unknown {
return this.dom.sanitize(SecurityContext.HTML, value); return this.dom.sanitize(SecurityContext.HTML, value);

View File

@ -1,3 +1,4 @@
import { inject } from '@angular/core';
import { Pipe, PipeTransform } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
@ -6,9 +7,8 @@ import { DomSanitizer } from '@angular/platform-browser';
standalone: true standalone: true
}) })
export class SafeStylePipe implements PipeTransform { export class SafeStylePipe implements PipeTransform {
private readonly sanitizer: DomSanitizer = inject(DomSanitizer);
constructor(private sanitizer: DomSanitizer){ constructor(){}
}
transform(style: string) { transform(style: string) {
return this.sanitizer.bypassSecurityTrustStyle(style); return this.sanitizer.bypassSecurityTrustStyle(style);

View File

@ -26,7 +26,7 @@
<div class="col-auto ms-2 mt-2" *ngIf="!(readingList?.promoted && !this.isAdmin)"> <div class="col-auto ms-2 mt-2" *ngIf="!(readingList?.promoted && !this.isAdmin)">
<div class="form-check form-check-inline"> <div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="accessibility-mode" [value]="accessibilityMode" (change)="updateAccessibilityMode()"> <input class="form-check-input" type="checkbox" id="accessibility-mode" [value]="accessibilityMode" (change)="updateAccessibilityMode()">
<label class="form-check-label" for="accessibility-mode">Order Numbers</label> <label class="form-check-label" for="accessibility-mode">{{t('order-numbers-label')}}</label>
</div> </div>
</div> </div>
</div> </div>
@ -52,7 +52,7 @@
<span class="read-btn--text">{{t('continue')}}</span> <span class="read-btn--text">{{t('continue')}}</span>
</span> </span>
</button> </button>
<div class="btn-group" ngbDropdown role="group" aria-label="Read options"> <div class="btn-group" ngbDropdown role="group" [attr.aria-label]="t('read-options-alt')">
<button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button> <button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button>
<div class="dropdown-menu" ngbDropdownMenu> <div class="dropdown-menu" ngbDropdownMenu>
<button ngbDropdownItem (click)="read()"> <button ngbDropdownItem (click)="read()">
@ -106,10 +106,10 @@
<ng-container *ngIf="characters$ | async as characters"> <ng-container *ngIf="characters$ | async as characters">
<div class="row mb-2"> <div class="row mb-2">
<div class="row" *ngIf="characters && characters.length > 0"> <div class="row" *ngIf="characters && characters.length > 0">
<h5>Characters</h5> <h5>{{t('characters-title')}}</h5>
<app-badge-expander [items]="characters"> <app-badge-expander [items]="characters">
<ng-template #badgeExpanderItem let-item let-position="idx"> <ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge> <app-person-badge a11y-click="13,32" class="col-auto" [person]="item" (click)="goToCharacter(item)"></app-person-badge>
</ng-template> </ng-template>
</app-badge-expander> </app-badge-expander>
</div> </div>

View File

@ -1,34 +1,43 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import {ActivatedRoute, Router} from '@angular/router';
import { ToastrService } from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import { take } from 'rxjs/operators'; import {take} from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service'; import {ConfirmService} from 'src/app/shared/confirm.service';
import { UtilityService } from 'src/app/shared/_services/utility.service'; import {UtilityService} from 'src/app/shared/_services/utility.service';
import { LibraryType } from 'src/app/_models/library'; import {LibraryType} from 'src/app/_models/library';
import { MangaFormat } from 'src/app/_models/manga-format'; import {MangaFormat} from 'src/app/_models/manga-format';
import { ReadingList, ReadingListItem } from 'src/app/_models/reading-list'; import {ReadingList, ReadingListItem} from 'src/app/_models/reading-list';
import { AccountService } from 'src/app/_services/account.service'; import {AccountService} from 'src/app/_services/account.service';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service'; import {ActionService} from 'src/app/_services/action.service';
import { ImageService } from 'src/app/_services/image.service'; import {ImageService} from 'src/app/_services/image.service';
import { ReadingListService } from 'src/app/_services/reading-list.service'; import {ReadingListService} from 'src/app/_services/reading-list.service';
import { IndexUpdateEvent, DraggableOrderedListComponent } from '../draggable-ordered-list/draggable-ordered-list.component'; import {
import { forkJoin, Observable } from 'rxjs'; DraggableOrderedListComponent,
import { ReaderService } from 'src/app/_services/reader.service'; IndexUpdateEvent
import { LibraryService } from 'src/app/_services/library.service'; } from '../draggable-ordered-list/draggable-ordered-list.component';
import { Person } from 'src/app/_models/metadata/person'; import {forkJoin, Observable} from 'rxjs';
import { ReadingListItemComponent } from '../reading-list-item/reading-list-item.component'; import {ReaderService} from 'src/app/_services/reader.service';
import { LoadingComponent } from '../../../shared/loading/loading.component'; import {LibraryService} from 'src/app/_services/library.service';
import { A11yClickDirective } from '../../../shared/a11y-click.directive'; import {Person} from 'src/app/_models/metadata/person';
import { PersonBadgeComponent } from '../../../shared/person-badge/person-badge.component'; import {ReadingListItemComponent} from '../reading-list-item/reading-list-item.component';
import { BadgeExpanderComponent } from '../../../shared/badge-expander/badge-expander.component'; import {LoadingComponent} from '../../../shared/loading/loading.component';
import { ReadMoreComponent } from '../../../shared/read-more/read-more.component'; import {A11yClickDirective} from '../../../shared/a11y-click.directive';
import { NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem } from '@ng-bootstrap/ng-bootstrap'; import {PersonBadgeComponent} from '../../../shared/person-badge/person-badge.component';
import { ImageComponent } from '../../../shared/image/image.component'; import {BadgeExpanderComponent} from '../../../shared/badge-expander/badge-expander.component';
import { CardActionablesComponent } from '../../../cards/card-item/card-actionables/card-actionables.component'; import {ReadMoreComponent} from '../../../shared/read-more/read-more.component';
import { NgIf, NgClass, AsyncPipe, DecimalPipe, DatePipe } from '@angular/common'; import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap';
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import {ImageComponent} from '../../../shared/image/image.component';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {AsyncPipe, DatePipe, DecimalPipe, NgClass, NgIf} from '@angular/common';
import {
SideNavCompanionBarComponent
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {MetadataDetailComponent} from "../../../series-detail/_components/metadata-detail/metadata-detail.component";
@Component({ @Component({
selector: 'app-reading-list-detail', selector: 'app-reading-list-detail',
@ -36,7 +45,7 @@ import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
styleUrls: ['./reading-list-detail.component.scss'], styleUrls: ['./reading-list-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [SideNavCompanionBarComponent, NgIf, CardActionablesComponent, ImageComponent, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, ReadMoreComponent, BadgeExpanderComponent, PersonBadgeComponent, A11yClickDirective, LoadingComponent, DraggableOrderedListComponent, ReadingListItemComponent, NgClass, AsyncPipe, DecimalPipe, DatePipe, TranslocoDirective] imports: [SideNavCompanionBarComponent, NgIf, CardActionablesComponent, ImageComponent, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, ReadMoreComponent, BadgeExpanderComponent, PersonBadgeComponent, A11yClickDirective, LoadingComponent, DraggableOrderedListComponent, ReadingListItemComponent, NgClass, AsyncPipe, DecimalPipe, DatePipe, TranslocoDirective, MetadataDetailComponent]
}) })
export class ReadingListDetailComponent implements OnInit { export class ReadingListDetailComponent implements OnInit {
items: Array<ReadingListItem> = []; items: Array<ReadingListItem> = [];
@ -67,7 +76,7 @@ export class ReadingListDetailComponent implements OnInit {
private actionService: ActionService, private actionFactoryService: ActionFactoryService, public utilityService: UtilityService, private actionService: ActionService, private actionFactoryService: ActionFactoryService, public utilityService: UtilityService,
public imageService: ImageService, private accountService: AccountService, private toastr: ToastrService, public imageService: ImageService, private accountService: AccountService, private toastr: ToastrService,
private confirmService: ConfirmService, private libraryService: LibraryService, private readerService: ReaderService, private confirmService: ConfirmService, private libraryService: LibraryService, private readerService: ReaderService,
private readonly cdRef: ChangeDetectorRef) {} private readonly cdRef: ChangeDetectorRef, private filterUtilityService: FilterUtilitiesService) {}
ngOnInit(): void { ngOnInit(): void {
const listId = this.route.snapshot.paramMap.get('id'); const listId = this.route.snapshot.paramMap.get('id');
@ -93,7 +102,7 @@ export class ReadingListDetailComponent implements OnInit {
if (readingList == null) { if (readingList == null) {
// The list doesn't exist // The list doesn't exist
this.toastr.error('This list doesn\'t exist.'); this.toastr.error(translate('toasts.list-doesnt-exist'));
this.router.navigateByUrl('library'); this.router.navigateByUrl('library');
return; return;
} }
@ -224,4 +233,8 @@ export class ReadingListDetailComponent implements OnInit {
this.accessibilityMode = !this.accessibilityMode; this.accessibilityMode = !this.accessibilityMode;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
goToCharacter(character: Person) {
this.filterUtilityService.applyFilter(['all-series'], FilterField.Characters, FilterComparison.Contains, character.id + '');
}
} }

View File

@ -16,9 +16,9 @@ import { ImportCblModalComponent } from '../../_modals/import-cbl-modal/import-c
import { CardItemComponent } from '../../../cards/card-item/card-item.component'; import { CardItemComponent } from '../../../cards/card-item/card-item.component';
import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component'; import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component';
import { NgIf, DecimalPipe } from '@angular/common'; import { NgIf, DecimalPipe } from '@angular/common';
import { CardActionablesComponent } from '../../../cards/card-item/card-actionables/card-actionables.component';
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
@Component({ @Component({
selector: 'app-reading-lists', selector: 'app-reading-lists',

View File

@ -126,7 +126,6 @@ export class EditReadingListModalComponent implements OnInit {
updateSelectedIndex(index: number) { updateSelectedIndex(index: number) {
this.coverImageIndex = index; this.coverImageIndex = index;
console.log(this.coverImageIndex)
this.cdRef.detectChanges(); this.cdRef.detectChanges();
} }

View File

@ -15,7 +15,6 @@ import {ImageComponent} from "../shared/image/image.component";
import {ReadMoreComponent} from "../shared/read-more/read-more.component"; import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {PersonBadgeComponent} from "../shared/person-badge/person-badge.component"; import {PersonBadgeComponent} from "../shared/person-badge/person-badge.component";
import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component"; import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component";
import {CardActionablesComponent} from "../cards/card-item/card-actionables/card-actionables.component";
import {MangaFormatPipe} from "../pipe/manga-format.pipe"; import {MangaFormatPipe} from "../pipe/manga-format.pipe";
import {MangaFormatIconPipe} from "../pipe/manga-format-icon.pipe"; import {MangaFormatIconPipe} from "../pipe/manga-format-icon.pipe";
import {SafeHtmlPipe} from "../pipe/safe-html.pipe"; import {SafeHtmlPipe} from "../pipe/safe-html.pipe";
@ -28,6 +27,7 @@ import {
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; } from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {LoadingComponent} from "../shared/loading/loading.component"; import {LoadingComponent} from "../shared/loading/loading.component";
import {A11yClickDirective} from "../shared/a11y-click.directive"; import {A11yClickDirective} from "../shared/a11y-click.directive";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
@NgModule({ @NgModule({
imports: [ imports: [

View File

@ -60,19 +60,18 @@ export class UserLoginComponent implements OnInit {
this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => { this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => {
this.firstTimeFlow = !adminExists; this.firstTimeFlow = !adminExists;
this.isLoaded = true;
if (this.firstTimeFlow) { if (this.firstTimeFlow) {
this.router.navigateByUrl('registration/register'); this.router.navigateByUrl('registration/register');
return; return;
} }
this.isLoaded = true;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
this.route.queryParamMap.subscribe(params => { this.route.queryParamMap.subscribe(params => {
const val = params.get('apiKey'); const val = params.get('apiKey');
console.log('key: ', val);
if (val != null && val.length > 0) { if (val != null && val.length > 0) {
this.login(val); this.login(val);
} }

View File

@ -3,8 +3,10 @@ import {CommonModule} from '@angular/common';
import {A11yClickDirective} from "../../../shared/a11y-click.directive"; import {A11yClickDirective} from "../../../shared/a11y-click.directive";
import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component"; import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component";
import {TagBadgeComponent, TagBadgeCursor} from "../../../shared/tag-badge/tag-badge.component"; import {TagBadgeComponent, TagBadgeCursor} from "../../../shared/tag-badge/tag-badge.component";
import {FilterQueryParam} from "../../../shared/_services/filter-utilities.service"; import {FilterQueryParam, FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
import {Router} from "@angular/router"; import {Router} from "@angular/router";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
@Component({ @Component({
selector: 'app-metadata-detail', selector: 'app-metadata-detail',
@ -19,19 +21,17 @@ export class MetadataDetailComponent {
@Input({required: true}) tags: Array<any> = []; @Input({required: true}) tags: Array<any> = [];
@Input({required: true}) libraryId!: number; @Input({required: true}) libraryId!: number;
@Input({required: true}) heading!: string; @Input({required: true}) heading!: string;
@Input() queryParam: FilterQueryParam = FilterQueryParam.None; @Input() queryParam: FilterField = FilterField.None;
@ContentChild('titleTemplate') titleTemplate!: TemplateRef<any>; @ContentChild('titleTemplate') titleTemplate!: TemplateRef<any>;
@ContentChild('itemTemplate') itemTemplate?: TemplateRef<any>; @ContentChild('itemTemplate') itemTemplate?: TemplateRef<any>;
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly filterUtilitiesService = inject(FilterUtilitiesService);
protected readonly TagBadgeCursor = TagBadgeCursor; protected readonly TagBadgeCursor = TagBadgeCursor;
goTo(queryParamName: FilterQueryParam, filter: any) { goTo(queryParamName: FilterField, filter: any) {
if (queryParamName === FilterQueryParam.None) return; if (queryParamName === FilterField.None) return;
let params: any = {}; this.filterUtilitiesService.applyFilter(['library', this.libraryId], queryParamName, FilterComparison.Equal, filter);
params[queryParamName] = filter;
params[FilterQueryParam.Page] = 1;
this.router.navigate(['library', this.libraryId], {queryParams: params});
} }
} }

View File

@ -68,9 +68,9 @@ import { CarouselReelComponent } from '../../../carousel/_components/carousel-re
import { SeriesMetadataDetailComponent } from '../series-metadata-detail/series-metadata-detail.component'; import { SeriesMetadataDetailComponent } from '../series-metadata-detail/series-metadata-detail.component';
import { ImageComponent } from '../../../shared/image/image.component'; import { ImageComponent } from '../../../shared/image/image.component';
import { TagBadgeComponent } from '../../../shared/tag-badge/tag-badge.component'; import { TagBadgeComponent } from '../../../shared/tag-badge/tag-badge.component';
import { CardActionablesComponent } from '../../../cards/card-item/card-actionables/card-actionables.component';
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
interface RelatedSeriesPair { interface RelatedSeriesPair {
series: Series; series: Series;

View File

@ -26,11 +26,11 @@
</ng-container> </ng-container>
<app-metadata-detail [tags]="seriesMetadata.genres" [libraryId]="series.libraryId" [queryParam]="FilterQueryParam.Genres" [heading]="t('genres-title')"> <app-metadata-detail [tags]="seriesMetadata.genres" [libraryId]="series.libraryId" [queryParam]="FilterField.Genres" [heading]="t('genres-title')">
<ng-template #titleTemplate let-item>{{item.title}}</ng-template> <ng-template #titleTemplate let-item>{{item.title}}</ng-template>
</app-metadata-detail> </app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.tags" [libraryId]="series.libraryId" [queryParam]="FilterQueryParam.Tags" [heading]="t('tags-title')"> <app-metadata-detail [tags]="seriesMetadata.tags" [libraryId]="series.libraryId" [queryParam]="FilterField.Tags" [heading]="t('tags-title')">
<ng-template #titleTemplate let-item>{{item.title}}</ng-template> <ng-template #titleTemplate let-item>{{item.title}}</ng-template>
</app-metadata-detail> </app-metadata-detail>
@ -56,7 +56,7 @@
</app-metadata-detail> </app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.writers" [libraryId]="series.libraryId" [queryParam]="FilterQueryParam.Writers" [heading]="t('writers-title')"> <app-metadata-detail [tags]="seriesMetadata.writers" [libraryId]="series.libraryId" [queryParam]="FilterField.Writers" [heading]="t('writers-title')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge> <app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template> </ng-template>
@ -64,55 +64,55 @@
<div #collapse="ngbCollapse" [(ngbCollapse)]="isCollapsed" id="extended-series-metadata"> <div #collapse="ngbCollapse" [(ngbCollapse)]="isCollapsed" id="extended-series-metadata">
<app-metadata-detail [tags]="seriesMetadata.coverArtists" [libraryId]="series.libraryId" [queryParam]="FilterQueryParam.CoverArtists" [heading]="t('cover-artists-title')"> <app-metadata-detail [tags]="seriesMetadata.coverArtists" [libraryId]="series.libraryId" [queryParam]="FilterField.CoverArtist" [heading]="t('cover-artists-title')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge> <app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template> </ng-template>
</app-metadata-detail> </app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.characters" [libraryId]="series.libraryId" [queryParam]="FilterQueryParam.Character" [heading]="t('characters-title')"> <app-metadata-detail [tags]="seriesMetadata.characters" [libraryId]="series.libraryId" [queryParam]="FilterField.Characters" [heading]="t('characters-title')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge> <app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template> </ng-template>
</app-metadata-detail> </app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.colorists" [libraryId]="series.libraryId" [queryParam]="FilterQueryParam.Colorist" [heading]="t('colorists-title')"> <app-metadata-detail [tags]="seriesMetadata.colorists" [libraryId]="series.libraryId" [queryParam]="FilterField.Colorist" [heading]="t('colorists-title')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge> <app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template> </ng-template>
</app-metadata-detail> </app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.editors" [libraryId]="series.libraryId" [queryParam]="FilterQueryParam.Editor" [heading]="t('editors-title')"> <app-metadata-detail [tags]="seriesMetadata.editors" [libraryId]="series.libraryId" [queryParam]="FilterField.Editor" [heading]="t('editors-title')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge> <app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template> </ng-template>
</app-metadata-detail> </app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.inkers" [libraryId]="series.libraryId" [queryParam]="FilterQueryParam.Inker" [heading]="t('inkers-title')"> <app-metadata-detail [tags]="seriesMetadata.inkers" [libraryId]="series.libraryId" [queryParam]="FilterField.Inker" [heading]="t('inkers-title')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge> <app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template> </ng-template>
</app-metadata-detail> </app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.letterers" [libraryId]="series.libraryId" [queryParam]="FilterQueryParam.Letterer" [heading]="t('letterers-title')"> <app-metadata-detail [tags]="seriesMetadata.letterers" [libraryId]="series.libraryId" [queryParam]="FilterField.Letterer" [heading]="t('letterers-title')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge> <app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template> </ng-template>
</app-metadata-detail> </app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.translators" [libraryId]="series.libraryId" [queryParam]="FilterQueryParam.Translator" [heading]="t('translators-title')"> <app-metadata-detail [tags]="seriesMetadata.translators" [libraryId]="series.libraryId" [queryParam]="FilterField.Translators" [heading]="t('translators-title')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge> <app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template> </ng-template>
</app-metadata-detail> </app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.pencillers" [libraryId]="series.libraryId" [queryParam]="FilterQueryParam.Penciller" [heading]="t('pencillers-title')"> <app-metadata-detail [tags]="seriesMetadata.pencillers" [libraryId]="series.libraryId" [queryParam]="FilterField.Penciller" [heading]="t('pencillers-title')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge> <app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template> </ng-template>
</app-metadata-detail> </app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.publishers" [libraryId]="series.libraryId" [queryParam]="FilterQueryParam.Publisher" [heading]="t('publishers-title')"> <app-metadata-detail [tags]="seriesMetadata.publishers" [libraryId]="series.libraryId" [queryParam]="FilterField.Publisher" [heading]="t('publishers-title')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge> <app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template> </ng-template>

View File

@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ReaderService } from 'src/app/_services/reader.service'; import { ReaderService } from 'src/app/_services/reader.service';
import {TagBadgeComponent, TagBadgeCursor} from '../../../shared/tag-badge/tag-badge.component'; import {TagBadgeComponent, TagBadgeCursor} from '../../../shared/tag-badge/tag-badge.component';
import { FilterQueryParam } from '../../../shared/_services/filter-utilities.service'; import {FilterUtilitiesService} from '../../../shared/_services/filter-utilities.service';
import { UtilityService } from '../../../shared/_services/utility.service'; import { UtilityService } from '../../../shared/_services/utility.service';
import { MangaFormat } from '../../../_models/manga-format'; import { MangaFormat } from '../../../_models/manga-format';
import { ReadingList } from '../../../_models/reading-list'; import { ReadingList } from '../../../_models/reading-list';
@ -21,6 +21,8 @@ import {SeriesInfoCardsComponent} from "../../../cards/series-info-cards/series-
import {LibraryType} from "../../../_models/library"; import {LibraryType} from "../../../_models/library";
import {MetadataDetailComponent} from "../metadata-detail/metadata-detail.component"; import {MetadataDetailComponent} from "../metadata-detail/metadata-detail.component";
import {TranslocoDirective} from "@ngneat/transloco"; import {TranslocoDirective} from "@ngneat/transloco";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
@Component({ @Component({
@ -57,7 +59,10 @@ export class SeriesMetadataDetailComponent implements OnChanges {
get LibraryType() { return LibraryType; } get LibraryType() { return LibraryType; }
get MangaFormat() { return MangaFormat; } get MangaFormat() { return MangaFormat; }
get TagBadgeCursor() { return TagBadgeCursor; } get TagBadgeCursor() { return TagBadgeCursor; }
get FilterQueryParam() { return FilterQueryParam; }
get FilterField() {
return FilterField;
}
get WebLinks() { get WebLinks() {
if (this.seriesMetadata?.webLinks === '') return []; if (this.seriesMetadata?.webLinks === '') return [];
@ -66,7 +71,7 @@ export class SeriesMetadataDetailComponent implements OnChanges {
constructor(public utilityService: UtilityService, constructor(public utilityService: UtilityService,
private router: Router, public readerService: ReaderService, private router: Router, public readerService: ReaderService,
private readonly cdRef: ChangeDetectorRef) { private readonly cdRef: ChangeDetectorRef, private filterUtilityService: FilterUtilitiesService) {
} }
@ -91,15 +96,13 @@ export class SeriesMetadataDetailComponent implements OnChanges {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
handleGoTo(event: {queryParamName: FilterQueryParam, filter: any}) { handleGoTo(event: {queryParamName: FilterField, filter: any}) {
this.goTo(event.queryParamName, event.filter); this.goTo(event.queryParamName, event.filter);
} }
goTo(queryParamName: FilterQueryParam, filter: any) { goTo(queryParamName: FilterField, filter: any) {
let params: any = {}; this.filterUtilityService.applyFilter(['library', this.series.libraryId], queryParamName,
params[queryParamName] = filter; FilterComparison.Equal, filter);
params[FilterQueryParam.Page] = 1;
this.router.navigate(['library', this.series.libraryId], {queryParams: params});
} }
navigate(basePage: string, id: number) { navigate(basePage: string, id: number) {

View File

@ -14,7 +14,6 @@ import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.co
import {ExternalSeriesCardComponent} from "../cards/external-series-card/external-series-card.component"; import {ExternalSeriesCardComponent} from "../cards/external-series-card/external-series-card.component";
import {ExternalListItemComponent} from "../cards/external-list-item/external-list-item.component"; import {ExternalListItemComponent} from "../cards/external-list-item/external-list-item.component";
import {ListItemComponent} from "../cards/list-item/list-item.component"; import {ListItemComponent} from "../cards/list-item/list-item.component";
import {CardActionablesComponent} from "../cards/card-item/card-actionables/card-actionables.component";
import {SafeHtmlPipe} from "../pipe/safe-html.pipe"; import {SafeHtmlPipe} from "../pipe/safe-html.pipe";
import {TagBadgeComponent} from "../shared/tag-badge/tag-badge.component"; import {TagBadgeComponent} from "../shared/tag-badge/tag-badge.component";
import {LoadingComponent} from "../shared/loading/loading.component"; import {LoadingComponent} from "../shared/loading/loading.component";
@ -37,6 +36,7 @@ import {
import { import {
SideNavCompanionBarComponent SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; } from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
@NgModule({ @NgModule({

View File

@ -228,7 +228,6 @@ export class DownloadService {
).pipe( ).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => { download((blob, filename) => {
console.log('saving: ', filename)
this.save(blob, decodeURIComponent(filename)); this.save(blob, decodeURIComponent(filename));
}), }),
tap((d) => this.updateDownloadState(d, downloadType, subtitle)), tap((d) => this.updateDownloadState(d, downloadType, subtitle)),

View File

@ -1,347 +1,231 @@
import { Injectable } from '@angular/core'; import {Injectable} from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router'; import {ActivatedRouteSnapshot, Router} from '@angular/router';
import { Pagination } from 'src/app/_models/pagination'; import {Pagination} from 'src/app/_models/pagination';
import { SeriesFilter, SortField } from 'src/app/_models/metadata/series-filter'; import {SortField, SortOptions} from 'src/app/_models/metadata/series-filter';
import {MetadataService} from "../../_services/metadata.service";
import {SeriesFilterV2} from "../../_models/metadata/v2/series-filter-v2";
import {FilterStatement} from "../../_models/metadata/v2/filter-statement";
import {FilterCombination} from "../../_models/metadata/v2/filter-combination";
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
/** /**
* Used to pass state between the filter and the url * Used to pass state between the filter and the url
*/ */
export enum FilterQueryParam { export enum FilterQueryParam {
Format = 'format', Format = 'format',
Genres = 'genres', Genres = 'genres',
AgeRating = 'ageRating', AgeRating = 'ageRating',
PublicationStatus = 'publicationStatus', PublicationStatus = 'publicationStatus',
Tags = 'tags', Tags = 'tags',
Languages = 'languages', Languages = 'languages',
CollectionTags = 'collectionTags', CollectionTags = 'collectionTags',
Libraries = 'libraries', Libraries = 'libraries',
Writers = 'writers', Writers = 'writers',
Artists = 'artists', Artists = 'artists',
Character = 'character', Character = 'character',
Colorist = 'colorist', Colorist = 'colorist',
CoverArtists = 'coverArtists', CoverArtists = 'coverArtists',
Editor = 'editor', Editor = 'editor',
Inker = 'inker', Inker = 'inker',
Letterer = 'letterer', Letterer = 'letterer',
Penciller = 'penciller', Penciller = 'penciller',
Publisher = 'publisher', Publisher = 'publisher',
Translator = 'translators', Translator = 'translators',
ReadStatus = 'readStatus', ReadStatus = 'readStatus',
SortBy = 'sortBy', SortBy = 'sortBy',
Rating = 'rating', Rating = 'rating',
Name = 'name', Name = 'name',
/** /**
* This is a pagination control * This is a pagination control
*/ */
Page = 'page', Page = 'page',
/** /**
* Special case for the UI. Does not trigger filtering * Special case for the UI. Does not trigger filtering
*/ */
None = 'none' None = 'none'
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class FilterUtilitiesService { export class FilterUtilitiesService {
constructor() { } constructor(private metadataService: MetadataService, private router: Router) {
/**
* Updates the window location with a custom url based on filter and pagination objects
* @param pagination
* @param filter
*/
updateUrlFromFilter(pagination: Pagination, filter: SeriesFilter | undefined) {
const params = '?page=' + pagination.currentPage;
const url = this.urlFromFilter(window.location.href.split('?')[0] + params, filter);
window.history.replaceState(window.location.href, '', this.replacePaginationOnUrl(url, pagination));
}
/**
* Patches the page query param in the window location.
* @param pagination
*/
updateUrlFromPagination(pagination: Pagination) {
window.history.replaceState(window.location.href, '', this.replacePaginationOnUrl(window.location.href, pagination));
}
private replacePaginationOnUrl(url: string, pagination: Pagination) {
return url.replace(/page=\d+/i, 'page=' + pagination.currentPage);
}
/**
* Will fetch current page from route if present
* @param ActivatedRouteSnapshot to fetch page from. Must be from component else may get stale data
* @param itemsPerPage If you want pagination, pass non-zero number
* @returns A default pagination object
*/
pagination(snapshot: ActivatedRouteSnapshot, itemsPerPage: number = 0): Pagination {
return {currentPage: parseInt(snapshot.queryParamMap.get('page') || '1', 10), itemsPerPage, totalItems: 0, totalPages: 1};
}
/**
* Returns the current url with query params for the filter
* @param currentUrl Full url, with ?page=1 as a minimum
* @param filter Filter to build url off
* @returns current url with query params added
*/
urlFromFilter(currentUrl: string, filter: SeriesFilter | undefined) {
if (filter === undefined) return currentUrl;
let params = '';
params += this.joinFilter(filter.formats, FilterQueryParam.Format);
params += this.joinFilter(filter.genres, FilterQueryParam.Genres);
params += this.joinFilter(filter.ageRating, FilterQueryParam.AgeRating);
params += this.joinFilter(filter.publicationStatus, FilterQueryParam.PublicationStatus);
params += this.joinFilter(filter.tags, FilterQueryParam.Tags);
params += this.joinFilter(filter.languages, FilterQueryParam.Languages);
params += this.joinFilter(filter.collectionTags, FilterQueryParam.CollectionTags);
params += this.joinFilter(filter.libraries, FilterQueryParam.Libraries);
params += this.joinFilter(filter.writers, FilterQueryParam.Writers);
params += this.joinFilter(filter.artists, FilterQueryParam.Artists);
params += this.joinFilter(filter.character, FilterQueryParam.Character);
params += this.joinFilter(filter.colorist, FilterQueryParam.Colorist);
params += this.joinFilter(filter.coverArtist, FilterQueryParam.CoverArtists);
params += this.joinFilter(filter.editor, FilterQueryParam.Editor);
params += this.joinFilter(filter.inker, FilterQueryParam.Inker);
params += this.joinFilter(filter.letterer, FilterQueryParam.Letterer);
params += this.joinFilter(filter.penciller, FilterQueryParam.Penciller);
params += this.joinFilter(filter.publisher, FilterQueryParam.Publisher);
params += this.joinFilter(filter.translators, FilterQueryParam.Translator);
// readStatus (we need to do an additonal check as there is a default case)
if (filter.readStatus && filter.readStatus.inProgress !== true && filter.readStatus.notRead !== true && filter.readStatus.read !== true) {
params += `&${FilterQueryParam.ReadStatus}=${filter.readStatus.inProgress},${filter.readStatus.notRead},${filter.readStatus.read}`;
} }
// sortBy (additional check to not save to url if default case) applyFilter(page: Array<any>, filter: FilterField, comparison: FilterComparison, value: string) {
if (filter.sortOptions && !(filter.sortOptions.sortField === SortField.SortName && filter.sortOptions.isAscending === true)) { const dto: SeriesFilterV2 = {
params += `&${FilterQueryParam.SortBy}=${filter.sortOptions.sortField},${filter.sortOptions.isAscending}`; statements: [this.metadataService.createDefaultFilterStatement(filter, comparison, value + '')],
combination: FilterCombination.Or,
limitTo: 0
};
console.log('applying filter: ', this.urlFromFilterV2(page.join('/') + '?', dto))
this.router.navigateByUrl(this.urlFromFilterV2(page.join('/') + '?', dto));
} }
if (filter.rating > 0) { /**
params += `&${FilterQueryParam.Rating}=${filter.rating}`; * Updates the window location with a custom url based on filter and pagination objects
* @param pagination
* @param filter
*/
updateUrlFromFilterV2(pagination: Pagination, filter: SeriesFilterV2 | undefined) {
const params = '?page=' + pagination.currentPage + '&';
const url = this.urlFromFilterV2(window.location.href.split('?')[0] + params, filter);
window.history.replaceState(window.location.href, '', this.replacePaginationOnUrl(url, pagination));
} }
if (filter.seriesNameQuery !== '') {
params += `&${FilterQueryParam.Name}=${encodeURIComponent(filter.seriesNameQuery)}`; private replacePaginationOnUrl(url: string, pagination: Pagination) {
return url.replace(/page=\d+/i, 'page=' + pagination.currentPage);
} }
return currentUrl + params; /**
} * Will fetch current page from route if present
* @param ActivatedRouteSnapshot to fetch page from. Must be from component else may get stale data
private joinFilter(filterProp: Array<any>, key: string) { * @param itemsPerPage If you want pagination, pass non-zero number
let params = ''; * @returns A default pagination object
if (filterProp.length > 0) { */
params += `&${key}=${filterProp.join(',')}`; pagination(snapshot: ActivatedRouteSnapshot, itemsPerPage: number = 0): Pagination {
} return {currentPage: parseInt(snapshot.queryParamMap.get('page') || '1', 10), itemsPerPage, totalItems: 0, totalPages: 1};
return params;
}
/**
* Returns a new instance of a filterSettings that is populated with filter presets from URL
* @param ActivatedRouteSnapshot to fetch page from. Must be from component else may get stale data
* @returns The Preset filter and if something was set within
*/
filterPresetsFromUrl(snapshot: ActivatedRouteSnapshot): [SeriesFilter, boolean] {
const filter = this.createSeriesFilter();
let anyChanged = false;
const format = snapshot.queryParamMap.get(FilterQueryParam.Format);
if (format !== undefined && format !== null) {
filter.formats = [...filter.formats, ...format.split(',').map(item => parseInt(item, 10))];
anyChanged = true;
} }
const genres = snapshot.queryParamMap.get(FilterQueryParam.Genres);
if (genres !== undefined && genres !== null) { /**
filter.genres = [...filter.genres, ...genres.split(',').map(item => parseInt(item, 10))]; * Returns the current url with query params for the filter
anyChanged = true; * @param currentUrl Full url, with ?page=1 as a minimum
* @param filter Filter to build url off
* @returns current url with query params added
*/
urlFromFilterV2(currentUrl: string, filter: SeriesFilterV2 | undefined) {
if (filter === undefined) return currentUrl;
return currentUrl + this.encodeSeriesFilter(filter);
} }
const ageRating = snapshot.queryParamMap.get(FilterQueryParam.AgeRating); encodeSeriesFilter(filter: SeriesFilterV2) {
if (ageRating !== undefined && ageRating !== null) { const encodedStatements = this.encodeFilterStatements(filter.statements);
filter.ageRating = [...filter.ageRating, ...ageRating.split(',').map(item => parseInt(item, 10))]; const encodedSortOptions = filter.sortOptions ? `sortOptions=${this.encodeSortOptions(filter.sortOptions)}` : '';
anyChanged = true; const encodedLimitTo = `limitTo=${filter.limitTo}`;
return `${this.encodeName(filter.name)}stmts=${encodedStatements}&${encodedSortOptions}&${encodedLimitTo}&combination=${filter.combination}`;
} }
const publicationStatus = snapshot.queryParamMap.get(FilterQueryParam.PublicationStatus); encodeName(name: string | undefined) {
if (publicationStatus !== undefined && publicationStatus !== null) { if (name === undefined || name === '') return '';
filter.publicationStatus = [...filter.publicationStatus, ...publicationStatus.split(',').map(item => parseInt(item, 10))]; return `name=${encodeURIComponent(name)}&`
anyChanged = true;
} }
const tags = snapshot.queryParamMap.get(FilterQueryParam.Tags);
if (tags !== undefined && tags !== null) { encodeSortOptions(sortOptions: SortOptions) {
filter.tags = [...filter.tags, ...tags.split(',').map(item => parseInt(item, 10))]; return `sortField=${sortOptions.sortField}&isAscending=${sortOptions.isAscending}`;
anyChanged = true;
} }
const languages = snapshot.queryParamMap.get(FilterQueryParam.Languages); encodeFilterStatements(statements: Array<FilterStatement>) {
if (languages !== undefined && languages !== null) { return encodeURIComponent(statements.map(statement => {
filter.languages = [...filter.languages, ...languages.split(',')]; const encodedComparison = `comparison=${statement.comparison}`;
anyChanged = true; const encodedField = `field=${statement.field}`;
const encodedValue = `value=${encodeURIComponent(statement.value)}`;
return `${encodedComparison}&${encodedField}&${encodedValue}`;
}).join(','));
} }
const writers = snapshot.queryParamMap.get(FilterQueryParam.Writers); filterPresetsFromUrlV2(snapshot: ActivatedRouteSnapshot): SeriesFilterV2 {
if (writers !== undefined && writers !== null) { const filter = this.metadataService.createDefaultFilterDto();
filter.writers = [...filter.writers, ...writers.split(',').map(item => parseInt(item, 10))]; if (!window.location.href.includes('?')) return filter;
anyChanged = true;
}
const artists = snapshot.queryParamMap.get(FilterQueryParam.Artists); const queryParams = snapshot.queryParams;
if (artists !== undefined && artists !== null) {
filter.artists = [...filter.artists, ...artists.split(',').map(item => parseInt(item, 10))];
anyChanged = true;
}
const character = snapshot.queryParamMap.get(FilterQueryParam.Character); if (queryParams.name) {
if (character !== undefined && character !== null) { filter.name = queryParams.name;
filter.character = [...filter.character, ...character.split(',').map(item => parseInt(item, 10))];
anyChanged = true;
}
const colorist = snapshot.queryParamMap.get(FilterQueryParam.Colorist);
if (colorist !== undefined && colorist !== null) {
filter.colorist = [...filter.colorist, ...colorist.split(',').map(item => parseInt(item, 10))];
anyChanged = true;
}
const coverArtists = snapshot.queryParamMap.get(FilterQueryParam.CoverArtists);
if (coverArtists !== undefined && coverArtists !== null) {
filter.coverArtist = [...filter.coverArtist, ...coverArtists.split(',').map(item => parseInt(item, 10))];
anyChanged = true;
}
const editor = snapshot.queryParamMap.get(FilterQueryParam.Editor);
if (editor !== undefined && editor !== null) {
filter.editor = [...filter.editor, ...editor.split(',').map(item => parseInt(item, 10))];
anyChanged = true;
}
const inker = snapshot.queryParamMap.get(FilterQueryParam.Inker);
if (inker !== undefined && inker !== null) {
filter.inker = [...filter.inker, ...inker.split(',').map(item => parseInt(item, 10))];
anyChanged = true;
}
const letterer = snapshot.queryParamMap.get(FilterQueryParam.Letterer);
if (letterer !== undefined && letterer !== null) {
filter.letterer = [...filter.letterer, ...letterer.split(',').map(item => parseInt(item, 10))];
anyChanged = true;
}
const penciller = snapshot.queryParamMap.get(FilterQueryParam.Penciller);
if (penciller !== undefined && penciller !== null) {
filter.penciller = [...filter.penciller, ...penciller.split(',').map(item => parseInt(item, 10))];
anyChanged = true;
}
const publisher = snapshot.queryParamMap.get(FilterQueryParam.Publisher);
if (publisher !== undefined && publisher !== null) {
filter.publisher = [...filter.publisher, ...publisher.split(',').map(item => parseInt(item, 10))];
anyChanged = true;
}
const translators = snapshot.queryParamMap.get(FilterQueryParam.Translator);
if (translators !== undefined && translators !== null) {
filter.translators = [...filter.translators, ...translators.split(',').map(item => parseInt(item, 10))];
anyChanged = true;
}
const libraries = snapshot.queryParamMap.get(FilterQueryParam.Libraries);
if (libraries !== undefined && libraries !== null) {
filter.libraries = [...filter.libraries, ...libraries.split(',').map(item => parseInt(item, 10))];
anyChanged = true;
}
const collectionTags = snapshot.queryParamMap.get(FilterQueryParam.CollectionTags);
if (collectionTags !== undefined && collectionTags !== null) {
filter.collectionTags = [...filter.collectionTags, ...collectionTags.split(',').map(item => parseInt(item, 10))];
anyChanged = true;
}
// Rating, seriesName,
const rating = snapshot.queryParamMap.get(FilterQueryParam.Rating);
if (rating !== undefined && rating !== null && parseInt(rating, 10) > 0) {
filter.rating = parseInt(rating, 10);
anyChanged = true;
}
/// Read status is encoded as true,true,true
const readStatus = snapshot.queryParamMap.get(FilterQueryParam.ReadStatus);
if (readStatus !== undefined && readStatus !== null) {
const values = readStatus.split(',').map(i => i === 'true');
if (values.length === 3) {
filter.readStatus.inProgress = values[0];
filter.readStatus.notRead = values[1];
filter.readStatus.read = values[2];
anyChanged = true;
}
}
const sortBy = snapshot.queryParamMap.get(FilterQueryParam.SortBy);
if (sortBy !== undefined && sortBy !== null) {
const values = sortBy.split(',');
if (values.length === 1) {
values.push('true');
}
if (values.length === 2) {
filter.sortOptions = {
isAscending: values[1] === 'true',
sortField: Number(values[0])
} }
anyChanged = true;
} const fullUrl = window.location.href.split('?')[1];
const stmtsStartIndex = fullUrl.indexOf('stmts=');
let endIndex = fullUrl.indexOf('&sortOptions=');
if (endIndex < 0) {
endIndex = fullUrl.indexOf('&limitTo=');
}
if (stmtsStartIndex !== -1 && endIndex !== -1) {
const stmtsEncoded = fullUrl.substring(stmtsStartIndex + 6, endIndex);
filter.statements = this.decodeFilterStatements(stmtsEncoded);
}
if (queryParams.sortOptions) {
const sortOptions = this.decodeSortOptions(queryParams.sortOptions);
if (sortOptions) {
filter.sortOptions = sortOptions;
}
}
if (queryParams.limitTo) {
filter.limitTo = parseInt(queryParams.limitTo, 10);
}
if (queryParams.combination) {
filter.combination = parseInt(queryParams.combination, 10) as FilterCombination;
}
return filter;
} }
const searchNameQuery = snapshot.queryParamMap.get(FilterQueryParam.Name); decodeSortOptions(encodedSortOptions: string): SortOptions | null {
if (searchNameQuery !== undefined && searchNameQuery !== null && searchNameQuery !== '') { const parts = encodedSortOptions.split('&');
filter.seriesNameQuery = decodeURIComponent(searchNameQuery); const sortFieldPart = parts.find(part => part.startsWith('sortField='));
anyChanged = true; const isAscendingPart = parts.find(part => part.startsWith('isAscending='));
if (sortFieldPart && isAscendingPart) {
const sortField = parseInt(sortFieldPart.split('=')[1], 10) as SortField;
const isAscending = isAscendingPart.split('=')[1] === 'true';
return {sortField, isAscending};
}
return null;
} }
decodeFilterStatements(encodedStatements: string): FilterStatement[] {
const statementStrings = decodeURIComponent(encodedStatements).split(','); // I don't think this will wrk
return statementStrings.map(statementString => {
const parts = statementString.split('&');
if (parts === null || parts.length < 3) return null;
return [filter, false]; // anyChanged. Testing out if having a filter active but keep drawer closed by default works better const comparisonStartToken = parts.find(part => part.startsWith('comparison='));
} if (!comparisonStartToken) return null;
const comparison = parseInt(comparisonStartToken.split('=')[1], 10) as FilterComparison;
createSeriesFilter(filter?: SeriesFilter) { const fieldStartToken = parts.find(part => part.startsWith('field='));
if (filter !== undefined) return filter; if (!fieldStartToken) return null;
const data: SeriesFilter = { const field = parseInt(fieldStartToken.split('=')[1], 10) as FilterField;
formats: [],
libraries: [], const valueStartToken = parts.find(part => part.startsWith('value='));
genres: [], if (!valueStartToken) return null;
writers: [], const value = decodeURIComponent(valueStartToken.split('=')[1]);
artists: [], return {comparison, field, value};
penciller: [], }).filter(o => o != null) as FilterStatement[];
inker: [], }
colorist: [],
letterer: [], createSeriesV2Filter(): SeriesFilterV2 {
coverArtist: [], return {
editor: [], combination: FilterCombination.And,
publisher: [], statements: [],
character: [], limitTo: 0,
translators: [], sortOptions: {
collectionTags: [], isAscending: true,
rating: 0, sortField: SortField.SortName
readStatus: { },
read: true, };
inProgress: true, }
notRead: true
}, createSeriesV2DefaultStatement(): FilterStatement {
sortOptions: null, return {
ageRating: [], comparison: FilterComparison.Equal,
tags: [], value: '',
languages: [], field: FilterField.SeriesName
publicationStatus: [], }
seriesNameQuery: '', }
releaseYearRange: null
};
return data;
}
} }

View File

@ -155,6 +155,7 @@ export class UtilityService {
} }
return true; return true;
} }
private isObject(object: any) { private isObject(object: any) {
return object != null && typeof object === 'object'; return object != null && typeof object === 'object';
} }

View File

@ -37,11 +37,6 @@ export class SideNavCompanionBarComponent implements OnInit {
*/ */
@Input() hasExtras: boolean = false; @Input() hasExtras: boolean = false;
/**
* Is the input open by default
*/
@Input() filterOpenByDefault: boolean = false;
/** /**
* This implies there is a filter in effect on the underlying page. Will show UI styles to imply this to the user. * This implies there is a filter in effect on the underlying page. Will show UI styles to imply this to the user.
*/ */
@ -62,8 +57,6 @@ export class SideNavCompanionBarComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.isFilterOpen = this.filterOpenByDefault;
// If user opens side nav while filter is open on mobile, then collapse filter (as it doesn't render well) TODO: Change this when we have new drawer // If user opens side nav while filter is open on mobile, then collapse filter (as it doesn't render well) TODO: Change this when we have new drawer
this.navService.sideNavCollapsed$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(sideNavCollapsed => { this.navService.sideNavCollapsed$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(sideNavCollapsed => {
if (this.isFilterOpen && sideNavCollapsed && this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet) { if (this.isFilterOpen && sideNavCollapsed && this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet) {

View File

@ -23,10 +23,10 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {switchMap} from "rxjs"; import {switchMap} from "rxjs";
import {CommonModule} from "@angular/common"; import {CommonModule} from "@angular/common";
import {SideNavItemComponent} from "../side-nav-item/side-nav-item.component"; import {SideNavItemComponent} from "../side-nav-item/side-nav-item.component";
import {CardActionablesComponent} from "../../../cards/card-item/card-actionables/card-actionables.component";
import {FilterPipe} from "../../../pipe/filter.pipe"; import {FilterPipe} from "../../../pipe/filter.pipe";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
import {TranslocoDirective} from "@ngneat/transloco"; import {TranslocoDirective} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
@Component({ @Component({
selector: 'app-side-nav', selector: 'app-side-nav',

View File

@ -1,29 +1,31 @@
import {ChangeDetectionStrategy, Component, DestroyRef, HostListener, inject} from '@angular/core'; import {ChangeDetectionStrategy, Component, DestroyRef, HostListener, inject} from '@angular/core';
import { Router } from '@angular/router'; import {Router} from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import { map, Observable, ReplaySubject, shareReplay } from 'rxjs'; import {map, Observable, ReplaySubject, shareReplay} from 'rxjs';
import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service'; import {FilterQueryParam, FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import { Series } from 'src/app/_models/series'; import {Series} from 'src/app/_models/series';
import { ImageService } from 'src/app/_services/image.service'; import {ImageService} from 'src/app/_services/image.service';
import { MetadataService } from 'src/app/_services/metadata.service'; import {MetadataService} from 'src/app/_services/metadata.service';
import { StatisticsService } from 'src/app/_services/statistics.service'; import {StatisticsService} from 'src/app/_services/statistics.service';
import { PieDataItem } from '../../_models/pie-data-item'; import {PieDataItem} from '../../_models/pie-data-item';
import { ServerStatistics } from '../../_models/server-statistics'; import {ServerStatistics} from '../../_models/server-statistics';
import { GenericListModalComponent } from '../_modals/generic-list-modal/generic-list-modal.component'; import {GenericListModalComponent} from '../_modals/generic-list-modal/generic-list-modal.component';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { BytesPipe } from '../../../pipe/bytes.pipe'; import {BytesPipe} from '../../../pipe/bytes.pipe';
import { TimeDurationPipe } from '../../../pipe/time-duration.pipe'; import {TimeDurationPipe} from '../../../pipe/time-duration.pipe';
import { CompactNumberPipe } from '../../../pipe/compact-number.pipe'; import {CompactNumberPipe} from '../../../pipe/compact-number.pipe';
import { DayBreakdownComponent } from '../day-breakdown/day-breakdown.component'; import {DayBreakdownComponent} from '../day-breakdown/day-breakdown.component';
import { ReadingActivityComponent } from '../reading-activity/reading-activity.component'; import {ReadingActivityComponent} from '../reading-activity/reading-activity.component';
import { PublicationStatusStatsComponent } from '../publication-status-stats/publication-status-stats.component'; import {PublicationStatusStatsComponent} from '../publication-status-stats/publication-status-stats.component';
import { FileBreakdownStatsComponent } from '../file-breakdown-stats/file-breakdown-stats.component'; import {FileBreakdownStatsComponent} from '../file-breakdown-stats/file-breakdown-stats.component';
import { TopReadersComponent } from '../top-readers/top-readers.component'; import {TopReadersComponent} from '../top-readers/top-readers.component';
import { StatListComponent } from '../stat-list/stat-list.component'; import {StatListComponent} from '../stat-list/stat-list.component';
import { IconAndTitleComponent } from '../../../shared/icon-and-title/icon-and-title.component'; import {IconAndTitleComponent} from '../../../shared/icon-and-title/icon-and-title.component';
import { NgIf, AsyncPipe, DecimalPipe } from '@angular/common'; import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
@Component({ @Component({
selector: 'app-server-stats', selector: 'app-server-stats',
@ -65,7 +67,8 @@ export class ServerStatsComponent {
get Breakpoint() { return Breakpoint; } get Breakpoint() { return Breakpoint; }
constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService, constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService,
private metadataService: MetadataService, private modalService: NgbModal, private utilityService: UtilityService) { private metadataService: MetadataService, private modalService: NgbModal, private utilityService: UtilityService,
private filterUtilityService: FilterUtilitiesService) {
this.seriesImage = (data: PieDataItem) => { this.seriesImage = (data: PieDataItem) => {
if (data.extra) return this.imageService.getSeriesCoverImage(data.extra.id); if (data.extra) return this.imageService.getSeriesCoverImage(data.extra.id);
return ''; return '';
@ -114,10 +117,7 @@ export class ServerStatsComponent {
ref.componentInstance.items = genres.map(t => t.title); ref.componentInstance.items = genres.map(t => t.title);
ref.componentInstance.title = this.translocoService.translate('server-stats.genres'); ref.componentInstance.title = this.translocoService.translate('server-stats.genres');
ref.componentInstance.clicked = (item: string) => { ref.componentInstance.clicked = (item: string) => {
const params: any = {}; this.filterUtilityService.applyFilter(['all-series'], FilterField.Genres, FilterComparison.Contains, genres.filter(g => g.title === item)[0].id + '');
params[FilterQueryParam.Genres] = genres.filter(g => g.title === item)[0].id;
params[FilterQueryParam.Page] = 1;
this.router.navigate(['all-series'], {queryParams: params});
}; };
}); });
} }
@ -128,10 +128,7 @@ export class ServerStatsComponent {
ref.componentInstance.items = tags.map(t => t.title); ref.componentInstance.items = tags.map(t => t.title);
ref.componentInstance.title = this.translocoService.translate('server-stats.tags'); ref.componentInstance.title = this.translocoService.translate('server-stats.tags');
ref.componentInstance.clicked = (item: string) => { ref.componentInstance.clicked = (item: string) => {
const params: any = {}; this.filterUtilityService.applyFilter(['all-series'], FilterField.Tags, FilterComparison.Contains, tags.filter(g => g.title === item)[0].id + '');
params[FilterQueryParam.Tags] = tags.filter(g => g.title === item)[0].id;
params[FilterQueryParam.Page] = 1;
this.router.navigate(['all-series'], {queryParams: params});
}; };
}); });
} }

View File

@ -1,25 +1,17 @@
import { import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
ChangeDetectionStrategy, import {map, Observable, shareReplay} from 'rxjs';
ChangeDetectorRef, import {UserReadStatistics} from 'src/app/statistics/_models/user-read-statistics';
Component, import {StatisticsService} from 'src/app/_services/statistics.service';
DestroyRef, import {ReadHistoryEvent} from '../../_models/read-history-event';
inject, import {MemberService} from 'src/app/_services/member.service';
OnInit import {AccountService} from 'src/app/_services/account.service';
} from '@angular/core'; import {PieDataItem} from '../../_models/pie-data-item';
import { map, Observable, shareReplay } from 'rxjs'; import {LibraryService} from 'src/app/_services/library.service';
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service'; import {AsyncPipe, NgIf, PercentPipe} from '@angular/common';
import { UserReadStatistics } from 'src/app/statistics/_models/user-read-statistics';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { ReadHistoryEvent } from '../../_models/read-history-event';
import { MemberService } from 'src/app/_services/member.service';
import { AccountService } from 'src/app/_services/account.service';
import { PieDataItem } from '../../_models/pie-data-item';
import { LibraryService } from 'src/app/_services/library.service';
import { PercentPipe, NgIf, AsyncPipe } from '@angular/common';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { StatListComponent } from '../stat-list/stat-list.component'; import {StatListComponent} from '../stat-list/stat-list.component';
import { ReadingActivityComponent } from '../reading-activity/reading-activity.component'; import {ReadingActivityComponent} from '../reading-activity/reading-activity.component';
import { UserStatsInfoCardsComponent } from '../user-stats-info-cards/user-stats-info-cards.component'; import {UserStatsInfoCardsComponent} from '../user-stats-info-cards/user-stats-info-cards.component';
import {TranslocoModule} from "@ngneat/transloco"; import {TranslocoModule} from "@ngneat/transloco";
@Component({ @Component({
@ -47,7 +39,7 @@ export class UserStatsComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
constructor(private readonly cdRef: ChangeDetectorRef, private statService: StatisticsService, constructor(private readonly cdRef: ChangeDetectorRef, private statService: StatisticsService,
private filterService: FilterUtilitiesService, private accountService: AccountService, private memberService: MemberService, private accountService: AccountService, private memberService: MemberService,
private libraryService: LibraryService) { private libraryService: LibraryService) {
this.isAdmin$ = this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(u => { this.isAdmin$ = this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(u => {
if (!u) return false; if (!u) return false;
@ -57,8 +49,6 @@ export class UserStatsComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
const filter = this.filterService.createSeriesFilter();
filter.readStatus = {read: true, notRead: false, inProgress: true};
this.memberService.getMember().subscribe(me => { this.memberService.getMember().subscribe(me => {
this.userId = me.id; this.userId = me.id;
this.cdRef.markForCheck(); this.cdRef.markForCheck();

View File

@ -6,7 +6,7 @@
{{t('title')}} {{t('title')}}
</h2> </h2>
</ng-container> </ng-container>
<h6 subtitle>{{t('series-count', {num: (seriesPagination.totalItems | number)})}}</h6> <h6 subtitle>{{t('series-count', {num: (pagination.totalItems | number)})}}</h6>
</app-side-nav-companion-bar> </app-side-nav-companion-bar>
</div> </div>
@ -16,7 +16,7 @@
<app-card-detail-layout <app-card-detail-layout
[isLoading]="isLoading" [isLoading]="isLoading"
[items]="series" [items]="series"
[pagination]="seriesPagination" [pagination]="pagination"
[filterSettings]="filterSettings" [filterSettings]="filterSettings"
[filterOpen]="filterOpen" [filterOpen]="filterOpen"
[jumpBarKeys]="jumpbarKeys" [jumpBarKeys]="jumpbarKeys"

View File

@ -23,7 +23,7 @@ import { SeriesRemovedEvent } from 'src/app/_models/events/series-removed-event'
import { JumpKey } from 'src/app/_models/jumpbar/jump-key'; import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { Pagination } from 'src/app/_models/pagination'; import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series'; import { Series } from 'src/app/_models/series';
import { SeriesFilter, FilterEvent } from 'src/app/_models/metadata/series-filter'; import { FilterEvent } from 'src/app/_models/metadata/series-filter';
import { Action, ActionItem } from 'src/app/_services/action-factory.service'; import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service'; import { ActionService } from 'src/app/_services/action.service';
import { ImageService } from 'src/app/_services/image.service'; import { ImageService } from 'src/app/_services/image.service';
@ -36,7 +36,8 @@ import { SeriesCardComponent } from '../../../cards/series-card/series-card.comp
import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component'; import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component';
import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component'; import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component';
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective} from "@ngneat/transloco"; import {translate, TranslocoDirective} from "@ngneat/transloco";
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
@Component({ @Component({
@ -55,12 +56,12 @@ export class WantToReadComponent implements OnInit, AfterContentChecked {
isLoading: boolean = true; isLoading: boolean = true;
series: Array<Series> = []; series: Array<Series> = [];
seriesPagination!: Pagination; pagination!: Pagination;
filter: SeriesFilter | undefined = undefined; filter: SeriesFilterV2 | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings(); filterSettings: FilterSettings = new FilterSettings();
refresh: EventEmitter<void> = new EventEmitter(); refresh: EventEmitter<void> = new EventEmitter();
filterActiveCheck!: SeriesFilter; filterActiveCheck!: SeriesFilterV2;
filterActive: boolean = false; filterActive: boolean = false;
jumpbarKeys: Array<JumpKey> = []; jumpbarKeys: Array<JumpKey> = [];
@ -84,7 +85,6 @@ export class WantToReadComponent implements OnInit, AfterContentChecked {
} }
collectionTag: any; collectionTag: any;
tagImage: any;
get ScrollingBlockHeight() { get ScrollingBlockHeight() {
if (this.scrollingBlock === undefined) return 'calc(var(--vh)*100)'; if (this.scrollingBlock === undefined) return 'calc(var(--vh)*100)';
@ -104,11 +104,18 @@ export class WantToReadComponent implements OnInit, AfterContentChecked {
private readonly cdRef: ChangeDetectorRef, private scrollService: ScrollService, private hubService: MessageHubService, private readonly cdRef: ChangeDetectorRef, private scrollService: ScrollService, private hubService: MessageHubService,
private jumpbarService: JumpbarService) { private jumpbarService: JumpbarService) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.titleService.setTitle('Want To Read'); this.titleService.setTitle(translate('want-to-read.title'));
this.pagination = this.filterUtilityService.pagination(this.route.snapshot);
this.filter = this.filterUtilityService.filterPresetsFromUrlV2(this.route.snapshot);
if (this.filter.statements.length === 0) {
this.filter!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement());
}
this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter();
this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement());
this.filterSettings.presetsV2 = this.filter;
this.seriesPagination = this.filterUtilityService.pagination(this.route.snapshot);
[this.filterSettings.presets, this.filterSettings.openByDefault] = this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot);
this.filterActiveCheck = this.filterUtilityService.createSeriesFilter();
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
@ -120,7 +127,7 @@ export class WantToReadComponent implements OnInit, AfterContentChecked {
} }
this.series = this.series.filter(s => s.id != seriesRemoved.seriesId); this.series = this.series.filter(s => s.id != seriesRemoved.seriesId);
this.seriesPagination.totalItems--; this.pagination.totalItems--;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.refresh.emit(); this.refresh.emit();
} }
@ -156,7 +163,7 @@ export class WantToReadComponent implements OnInit, AfterContentChecked {
removeSeries(seriesId: number) { removeSeries(seriesId: number) {
this.series = this.series.filter(s => s.id != seriesId); this.series = this.series.filter(s => s.id != seriesId);
this.seriesPagination.totalItems--; this.pagination.totalItems--;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.refresh.emit(); this.refresh.emit();
} }
@ -168,7 +175,7 @@ export class WantToReadComponent implements OnInit, AfterContentChecked {
this.seriesService.getWantToRead(undefined, undefined, this.filter).pipe(take(1)).subscribe(paginatedList => { this.seriesService.getWantToRead(undefined, undefined, this.filter).pipe(take(1)).subscribe(paginatedList => {
this.series = paginatedList.result; this.series = paginatedList.result;
this.seriesPagination = paginatedList.pagination; this.pagination = paginatedList.pagination;
this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (series: Series) => series.name); this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (series: Series) => series.name);
this.isLoading = false; this.isLoading = false;
window.scrollTo(0, 0); window.scrollTo(0, 0);
@ -177,28 +184,15 @@ export class WantToReadComponent implements OnInit, AfterContentChecked {
} }
updateFilter(data: FilterEvent) { updateFilter(data: FilterEvent) {
this.filter = data.filter; if (data.filterV2 === undefined) return;
this.filter = data.filterV2;
if (!data.isFirst) {
this.filterUtilityService.updateUrlFromFilterV2(this.pagination, this.filter);
}
if (!data.isFirst) this.filterUtilityService.updateUrlFromFilter(this.seriesPagination, this.filter);
this.loadPage(); this.loadPage();
} }
handleAction(action: ActionItem<Series>, series: Series) {
// let lib: Partial<Library> = library;
// if (library === undefined) {
// lib = {id: this.libraryId, name: this.libraryName};
// }
// switch (action.action) {
// case(Action.Scan):
// this.actionService.scanLibrary(lib);
// break;
// case(Action.RefreshMetadata):
// this.actionService.refreshMetadata(lib);
// break;
// default:
// break;
// }
}
} }

Some files were not shown because too many files have changed in this diff Show More