mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Merge branch 'develop' of https://github.com/Kareadita/Kavita into develop
This commit is contained in:
commit
6fcba79d8f
28
API.Tests/Extensions/SeriesFilterTests.cs
Normal file
28
API.Tests/Extensions/SeriesFilterTests.cs
Normal 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
|
||||
}
|
@ -15,6 +15,10 @@ public static class EasyCacheProfiles
|
||||
/// Cache the libraries on the server
|
||||
/// </summary>
|
||||
public const string Library = "library";
|
||||
/// <summary>
|
||||
/// Metadata filter
|
||||
/// </summary>
|
||||
public const string Filter = "filter";
|
||||
public const string KavitaPlusReviews = "kavita+reviews";
|
||||
public const string KavitaPlusRecommendations = "kavita+recommendations";
|
||||
public const string KavitaPlusRatings = "kavita+ratings";
|
||||
|
59
API/Controllers/FilterController.cs
Normal file
59
API/Controllers/FilterController.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -138,19 +138,14 @@ public class MetadataController : BaseApiController
|
||||
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
|
||||
/// <returns></returns>
|
||||
[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)
|
||||
{
|
||||
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());
|
||||
}
|
||||
|
||||
[HttpGet("all-languages")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
|
||||
public IEnumerable<LanguageDto> GetAllValidLanguages()
|
||||
@ -163,6 +158,7 @@ public class MetadataController : BaseApiController
|
||||
}).Where(l => !string.IsNullOrEmpty(l.IsoCode));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns summary for the chapter
|
||||
/// </summary>
|
||||
|
@ -10,6 +10,7 @@ using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.OPDS;
|
||||
using API.DTOs.Search;
|
||||
using API.Entities;
|
||||
@ -65,6 +66,8 @@ public class OpdsController : BaseApiController
|
||||
SortOptions = null,
|
||||
PublicationStatus = new List<PublicationStatus>()
|
||||
};
|
||||
|
||||
private readonly FilterV2Dto _filterV2Dto = new FilterV2Dto();
|
||||
private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default;
|
||||
private const int PageSize = 20;
|
||||
|
||||
@ -201,6 +204,8 @@ public class OpdsController : BaseApiController
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
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,9 +231,8 @@ public class OpdsController : BaseApiController
|
||||
|
||||
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix);
|
||||
SetFeedId(feed, "collections");
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
|
||||
feed.Entries.AddRange(tags.Select(tag => new FeedEntry()
|
||||
{
|
||||
Id = tag.Id.ToString(),
|
||||
Title = tag.Title,
|
||||
@ -236,11 +240,10 @@ public class OpdsController : BaseApiController
|
||||
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}")
|
||||
}
|
||||
});
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
|
||||
}
|
||||
}));
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
@ -315,6 +318,8 @@ public class OpdsController : BaseApiController
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}")
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -378,17 +383,27 @@ public class OpdsController : BaseApiController
|
||||
return BadRequest(await _localizationService.Translate(userId, "no-library-access"));
|
||||
}
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, GetUserParams(pageNumber), _filterDto);
|
||||
var filter = new FilterV2Dto
|
||||
{
|
||||
Statements = new List<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 feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey, prefix);
|
||||
SetFeedId(feed, $"library-{library.Name}");
|
||||
AddPagination(feed, series, $"{prefix}{apiKey}/libraries/{libraryId}");
|
||||
|
||||
foreach (var seriesDto in series)
|
||||
{
|
||||
feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
|
||||
}
|
||||
feed.Entries.AddRange(series.Select(seriesDto =>
|
||||
CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)));
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
@ -401,7 +416,7 @@ public class OpdsController : BaseApiController
|
||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
||||
var (baseUrl, prefix) = await GetPrefix();
|
||||
var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, userId, GetUserParams(pageNumber), _filterDto);
|
||||
var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, GetUserParams(pageNumber), _filterV2Dto);
|
||||
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id));
|
||||
|
||||
var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{prefix}{apiKey}/recently-added", apiKey, prefix);
|
||||
@ -730,8 +745,10 @@ public class OpdsController : BaseApiController
|
||||
return new FeedEntry()
|
||||
{
|
||||
Id = seriesDto.Id.ToString(),
|
||||
Title = $"{seriesDto.Name} ({seriesDto.Format})",
|
||||
Summary = seriesDto.Summary,
|
||||
Title = $"{seriesDto.Name}",
|
||||
Summary = $"Format: {seriesDto.Format}" + (string.IsNullOrWhiteSpace(metadata.Summary)
|
||||
? string.Empty
|
||||
: $" Summary: {metadata.Summary}"),
|
||||
Authors = metadata.Writers.Select(p => new FeedAuthor()
|
||||
{
|
||||
Name = p.Name,
|
||||
@ -756,7 +773,8 @@ public class OpdsController : BaseApiController
|
||||
return new FeedEntry()
|
||||
{
|
||||
Id = searchResultDto.SeriesId.ToString(),
|
||||
Title = $"{searchResultDto.Name} ({searchResultDto.Format})",
|
||||
Title = $"{searchResultDto.Name}",
|
||||
Summary = $"Format: {searchResultDto.Format}",
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/series/{searchResultDto.SeriesId}"),
|
||||
|
@ -8,6 +8,7 @@ using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -596,7 +597,7 @@ public class ReaderController : BaseApiController
|
||||
/// <param name="filterDto">Only supports SeriesNameQuery</param>
|
||||
/// <returns></returns>
|
||||
[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));
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
@ -6,6 +7,7 @@ using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities;
|
||||
@ -53,7 +55,16 @@ public class SeriesController : BaseApiController
|
||||
_recommendationCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
|
||||
}
|
||||
|
||||
/// <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]
|
||||
[Obsolete("use v2")]
|
||||
public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
@ -70,6 +81,30 @@ public class SeriesController : BaseApiController
|
||||
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>
|
||||
/// Fetches a Series for a given Id
|
||||
/// </summary>
|
||||
@ -207,7 +242,7 @@ public class SeriesController : BaseApiController
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all recently added series
|
||||
/// Gets all recently added series. Obsolete, use recently-added-v2
|
||||
/// </summary>
|
||||
/// <param name="filterDto"></param>
|
||||
/// <param name="userParams"></param>
|
||||
@ -215,6 +250,7 @@ public class SeriesController : BaseApiController
|
||||
/// <returns></returns>
|
||||
[ResponseCache(CacheProfileName = "Instant")]
|
||||
[HttpPost("recently-added")]
|
||||
[Obsolete("use recently-added-v2")]
|
||||
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
@ -231,6 +267,30 @@ public class SeriesController : BaseApiController
|
||||
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>
|
||||
/// Returns series that were recently updated, like adding or removing a chapter
|
||||
/// </summary>
|
||||
@ -251,11 +311,11 @@ public class SeriesController : BaseApiController
|
||||
/// <param name="libraryId"></param>
|
||||
/// <returns></returns>
|
||||
[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 series =
|
||||
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto);
|
||||
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto);
|
||||
|
||||
// Apply progress/rating information (I can't work out how to do this in initial query)
|
||||
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series"));
|
||||
@ -270,16 +330,15 @@ public class SeriesController : BaseApiController
|
||||
/// <summary>
|
||||
/// Fetches series that are on deck aka have progress on them.
|
||||
/// </summary>
|
||||
/// <param name="filterDto"></param>
|
||||
/// <param name="userParams"></param>
|
||||
/// <param name="libraryId">Default of 0 meaning all libraries</param>
|
||||
/// <returns></returns>
|
||||
[ResponseCache(CacheProfileName = "Instant")]
|
||||
[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 pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, filterDto);
|
||||
var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, null);
|
||||
|
||||
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, pagedList);
|
||||
|
||||
@ -288,6 +347,7 @@ public class SeriesController : BaseApiController
|
||||
return Ok(pagedList);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Removes a series from displaying on deck until the next read event on that series
|
||||
/// </summary>
|
||||
|
@ -1,9 +1,11 @@
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.WantToRead;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
@ -33,12 +35,13 @@ public class WantToReadController : BaseApiController
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="userParams"></param>
|
||||
/// <param name="filterDto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost]
|
||||
[Obsolete("use v2 instead")]
|
||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetWantToRead([FromQuery] UserParams userParams, FilterDto filterDto)
|
||||
{
|
||||
userParams ??= new UserParams();
|
||||
@ -50,6 +53,24 @@ public class WantToReadController : BaseApiController
|
||||
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]
|
||||
public async Task<ActionResult<bool>> IsSeriesInWantToRead([FromQuery] int seriesId)
|
||||
{
|
||||
|
7
API/DTOs/Filtering/v2/FilterCombination.cs
Normal file
7
API/DTOs/Filtering/v2/FilterCombination.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace API.DTOs.Filtering.v2;
|
||||
|
||||
public enum FilterCombination
|
||||
{
|
||||
Or = 0,
|
||||
And = 1
|
||||
}
|
51
API/DTOs/Filtering/v2/FilterComparision.cs
Normal file
51
API/DTOs/Filtering/v2/FilterComparision.cs
Normal 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,
|
||||
}
|
32
API/DTOs/Filtering/v2/FilterField.cs
Normal file
32
API/DTOs/Filtering/v2/FilterField.cs
Normal 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
|
||||
}
|
8
API/DTOs/Filtering/v2/FilterStatementDto.cs
Normal file
8
API/DTOs/Filtering/v2/FilterStatementDto.cs
Normal 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; }
|
||||
}
|
30
API/DTOs/Filtering/v2/FilterV2Dto.cs
Normal file
30
API/DTOs/Filtering/v2/FilterV2Dto.cs
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -12,7 +12,6 @@ public class SeriesDto : IHasReadTimeEstimate
|
||||
public string? OriginalName { get; init; }
|
||||
public string? LocalizedName { get; init; }
|
||||
public string? SortName { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public int Pages { get; init; }
|
||||
public bool CoverImageLocked { get; set; }
|
||||
/// <summary>
|
||||
|
@ -11,6 +11,7 @@ using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Kavita.Common.Extensions;
|
||||
@ -45,8 +46,7 @@ public interface ILibraryRepository
|
||||
Task<int> GetTotalFiles();
|
||||
IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId);
|
||||
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds);
|
||||
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds);
|
||||
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync();
|
||||
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int>? libraryIds);
|
||||
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
|
||||
Task<bool> DoAnySeriesFoldersMatch(IEnumerable<string> folders);
|
||||
Task<string?> GetLibraryCoverImageAsync(int libraryId);
|
||||
@ -260,10 +260,10 @@ public class LibraryRepository : ILibraryRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds)
|
||||
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int>? libraryIds)
|
||||
{
|
||||
var ret = await _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.WhereIf(libraryIds is {Count: > 0} , s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Metadata.Language)
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking()
|
||||
@ -272,33 +272,33 @@ public class LibraryRepository : ILibraryRepository
|
||||
|
||||
return ret
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Select(s => new LanguageDto()
|
||||
{
|
||||
Title = CultureInfo.GetCultureInfo(s).DisplayName,
|
||||
IsoCode = s
|
||||
})
|
||||
.DistinctBy(Parser.Normalize)
|
||||
.Select(GetCulture)
|
||||
.Where(s => s != null)
|
||||
.OrderBy(s => s.Title)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync()
|
||||
private static LanguageDto GetCulture(string s)
|
||||
{
|
||||
var ret = await _context.Series
|
||||
.Select(s => s.Metadata.Language)
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking()
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
return ret
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Select(s => new LanguageDto()
|
||||
try
|
||||
{
|
||||
return new LanguageDto()
|
||||
{
|
||||
Title = CultureInfo.GetCultureInfo(s).DisplayName,
|
||||
IsoCode = s
|
||||
})
|
||||
.OrderBy(s => s.Title)
|
||||
.ToList();
|
||||
};
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
return new LanguageDto()
|
||||
{
|
||||
Title = s,
|
||||
IsoCode = s
|
||||
};;
|
||||
}
|
||||
|
||||
public IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds)
|
||||
|
@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
@ -10,6 +9,7 @@ using API.Data.Scanner;
|
||||
using API.DTOs;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.DTOs.Search;
|
||||
@ -20,7 +20,9 @@ using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Extensions.QueryExtensions.Filtering;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Converters;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using API.Services.Tasks.Scanner;
|
||||
@ -95,8 +97,9 @@ public interface ISeriesRepository
|
||||
/// <returns></returns>
|
||||
Task AddSeriesModifiers(int userId, IList<SeriesDto> series);
|
||||
Task<string?> GetSeriesCoverImageAsync(int seriesId);
|
||||
Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter);
|
||||
Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter);
|
||||
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);
|
||||
Task<PagedList<SeriesDto>> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter);
|
||||
Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId);
|
||||
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
|
||||
Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
|
||||
@ -118,6 +121,7 @@ public interface ISeriesRepository
|
||||
Task<SeriesDto?> GetSeriesForMangaFile(int mangaFileId, int userId);
|
||||
Task<SeriesDto?> GetSeriesForChapter(int chapterId, int userId);
|
||||
Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter);
|
||||
Task<PagedList<SeriesDto>> GetWantToReadForUserV2Async(int userId, UserParams userParams, FilterV2Dto filter);
|
||||
Task<IList<Series>> GetWantToReadForUserAsync(int userId);
|
||||
Task<bool> IsSeriesInWantToRead(int userId, int seriesId);
|
||||
Task<Series?> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
|
||||
@ -140,6 +144,7 @@ public interface ISeriesRepository
|
||||
Task<int> GetAverageUserRating(int seriesId, int userId);
|
||||
Task RemoveFromOnDeck(int seriesId, int userId);
|
||||
Task ClearOnDeckRemoval(int seriesId, int userId);
|
||||
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto);
|
||||
}
|
||||
|
||||
public class SeriesRepository : ISeriesRepository
|
||||
@ -300,6 +305,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
/// <param name="userParams"></param>
|
||||
/// <param name="filter"></param>
|
||||
/// <returns></returns>
|
||||
[Obsolete("Use GetSeriesDtoForLibraryIdAsync")]
|
||||
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter)
|
||||
{
|
||||
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.None);
|
||||
@ -605,6 +611,18 @@ public class SeriesRepository : ISeriesRepository
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto)
|
||||
{
|
||||
var query = await CreateFilteredSearchQueryableV2(userId, filterDto, QueryContext.None);
|
||||
|
||||
var retSeries = query
|
||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking();
|
||||
|
||||
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
|
||||
public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series)
|
||||
{
|
||||
@ -644,7 +662,6 @@ public class SeriesRepository : ISeriesRepository
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of Series that were added, ordered by Created desc
|
||||
/// </summary>
|
||||
@ -653,6 +670,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
/// <param name="userParams">Contains pagination information</param>
|
||||
/// <param name="filter">Optional filter on query</param>
|
||||
/// <returns></returns>
|
||||
[Obsolete("Use GetRecentlyAddedV2")]
|
||||
public async Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter)
|
||||
{
|
||||
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.Dashboard);
|
||||
@ -666,6 +684,19 @@ public class SeriesRepository : ISeriesRepository
|
||||
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
public async Task<PagedList<SeriesDto>> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter)
|
||||
{
|
||||
var query = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.Dashboard);
|
||||
|
||||
var retSeries = query
|
||||
.OrderByDescending(s => s.Created)
|
||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking();
|
||||
|
||||
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
private IList<MangaFormat> ExtractFilters(int libraryId, int userId, FilterDto filter, ref List<int> userLibraries,
|
||||
out List<int> allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter,
|
||||
out bool hasRatingFilter, out bool hasProgressFilter, out IList<int> seriesIds, out bool hasAgeRating, out bool hasTagsFilter,
|
||||
@ -759,7 +790,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
/// <param name="userParams">Pagination information</param>
|
||||
/// <param name="filter">Optional (default null) filter on query</param>
|
||||
/// <returns></returns>
|
||||
public async Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter)
|
||||
public async Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter)
|
||||
{
|
||||
var settings = await _context.ServerSetting
|
||||
.Select(x => x)
|
||||
@ -780,11 +811,6 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Select(d => d.SeriesId)
|
||||
.AsEnumerable();
|
||||
|
||||
// var onDeckRemovals = _context.AppUser.Where(u => u.Id == userId)
|
||||
// .SelectMany(u => u.OnDeckRemovals.Select(d => d.Id))
|
||||
// .AsEnumerable();
|
||||
|
||||
|
||||
var query = _context.Series
|
||||
.Where(s => usersSeriesIds.Contains(s.Id))
|
||||
.Where(s => !onDeckRemovals.Contains(s.Id))
|
||||
@ -814,6 +840,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, QueryContext queryContext)
|
||||
{
|
||||
// NOTE: Why do we even have libraryId when the filter has the actual libraryIds?
|
||||
// TODO: Remove this method
|
||||
var userLibraries = await GetUserLibrariesForFilteredQuery(libraryId, userId, queryContext);
|
||||
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
var onlyParentSeries = await _context.AppUserPreferences.Where(u => u.AppUserId == userId)
|
||||
@ -828,29 +855,47 @@ public class SeriesRepository : ISeriesRepository
|
||||
|
||||
var query = _context.Series
|
||||
.AsNoTracking()
|
||||
.WhereIf(hasGenresFilter, s => s.Metadata.Genres.Any(g => filter.Genres.Contains(g.Id)))
|
||||
.WhereIf(hasPeopleFilter, s => s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id)))
|
||||
.WhereIf(hasCollectionTagFilter,
|
||||
s => s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id)))
|
||||
.WhereIf(hasRatingFilter, s => s.Ratings.Any(r => r.Rating >= filter.Rating && r.AppUserId == userId))
|
||||
.WhereIf(hasProgressFilter, s => seriesIds.Contains(s.Id))
|
||||
.WhereIf(hasAgeRating, s => filter.AgeRating.Contains(s.Metadata.AgeRating))
|
||||
.WhereIf(hasTagsFilter, s => s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id)))
|
||||
.WhereIf(hasLanguageFilter, s => filter.Languages.Contains(s.Metadata.Language))
|
||||
.WhereIf(hasReleaseYearMinFilter, s => s.Metadata.ReleaseYear >= filter.ReleaseYearRange!.Min)
|
||||
.WhereIf(hasReleaseYearMaxFilter, s => s.Metadata.ReleaseYear <= filter.ReleaseYearRange!.Max)
|
||||
.WhereIf(hasPublicationFilter, s => filter.PublicationStatus.Contains(s.Metadata.PublicationStatus))
|
||||
.WhereIf(hasSeriesNameFilter, s => EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%")
|
||||
|| EF.Functions.Like(s.OriginalName!, $"%{filter.SeriesNameQuery}%")
|
||||
|| EF.Functions.Like(s.LocalizedName!, $"%{filter.SeriesNameQuery}%"))
|
||||
// This new style can handle any filterComparision coming from the user
|
||||
.HasLanguage(hasLanguageFilter, FilterComparison.Contains, filter.Languages)
|
||||
.HasReleaseYear(hasReleaseYearMaxFilter, FilterComparison.LessThanEqual, filter.ReleaseYearRange?.Max)
|
||||
.HasReleaseYear(hasReleaseYearMinFilter, FilterComparison.GreaterThanEqual, filter.ReleaseYearRange?.Min)
|
||||
.HasName(hasSeriesNameFilter, FilterComparison.Matches, filter.SeriesNameQuery)
|
||||
.HasRating(hasRatingFilter, FilterComparison.GreaterThanEqual, filter.Rating, userId)
|
||||
.HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating)
|
||||
.HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus)
|
||||
.HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags)
|
||||
.HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags)
|
||||
.HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres)
|
||||
.HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!)
|
||||
.HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0)
|
||||
|
||||
// This needs different treatment
|
||||
.HasPeople(hasPeopleFilter, FilterComparison.Contains, allPeopleIds)
|
||||
|
||||
.WhereIf(onlyParentSeries,
|
||||
s => s.RelationOf.Count == 0 || s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel))
|
||||
.Where(s => userLibraries.Contains(s.LibraryId))
|
||||
.Where(s => formats.Contains(s.Format));
|
||||
.Where(s => userLibraries.Contains(s.LibraryId));
|
||||
|
||||
if (filter.ReadStatus.InProgress)
|
||||
{
|
||||
query = query.HasReadingProgress(hasProgressFilter, FilterComparison.GreaterThan,
|
||||
0, userId)
|
||||
.HasReadingProgress(hasProgressFilter, FilterComparison.LessThan,
|
||||
100, userId);
|
||||
} else if (filter.ReadStatus.Read)
|
||||
{
|
||||
query = query.HasReadingProgress(hasProgressFilter, FilterComparison.Equal,
|
||||
100, userId);
|
||||
}
|
||||
else if (filter.ReadStatus.NotRead)
|
||||
{
|
||||
query = query.HasReadingProgress(hasProgressFilter, FilterComparison.Equal,
|
||||
0, userId);
|
||||
}
|
||||
|
||||
if (userRating.AgeRating != AgeRating.NotApplicable)
|
||||
{
|
||||
// this if statement is included in the extension
|
||||
query = query.RestrictAgainstAgeRestriction(userRating);
|
||||
}
|
||||
|
||||
@ -889,7 +934,109 @@ public class SeriesRepository : ISeriesRepository
|
||||
};
|
||||
}
|
||||
|
||||
return query;
|
||||
return query.AsSplitQuery();
|
||||
}
|
||||
|
||||
private async Task<IQueryable<Series>> CreateFilteredSearchQueryableV2(int userId, FilterV2Dto filter, QueryContext queryContext, IQueryable<Series>? query = null)
|
||||
{
|
||||
// NOTE: Why do we even have libraryId when the filter has the actual libraryIds?
|
||||
var userLibraries = await GetUserLibrariesForFilteredQuery(0, userId, queryContext);
|
||||
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
var onlyParentSeries = await _context.AppUserPreferences.Where(u => u.AppUserId == userId)
|
||||
.Select(u => u.CollapseSeriesRelationships)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
query ??= _context.Series
|
||||
.AsNoTracking();
|
||||
|
||||
var filterLibs = new List<int>();
|
||||
|
||||
// First setup any FilterField.Libraries in the statements, as these don't have any traditional query statements applied here
|
||||
if (filter.Statements != null)
|
||||
{
|
||||
foreach (var stmt in filter.Statements.Where(stmt => stmt.Field == FilterField.Libraries))
|
||||
{
|
||||
filterLibs.Add(int.Parse(stmt.Value));
|
||||
}
|
||||
|
||||
// Remove as filterLibs now has everything
|
||||
filter.Statements = filter.Statements.Where(stmt => stmt.Field != FilterField.Libraries).ToList();
|
||||
}
|
||||
|
||||
|
||||
query = BuildFilterQuery(userId, filter, query);
|
||||
|
||||
query = query
|
||||
.WhereIf(userLibraries.Count > 0, s => userLibraries.Contains(s.LibraryId))
|
||||
.WhereIf(filterLibs.Count > 0, s => filterLibs.Contains(s.LibraryId))
|
||||
.WhereIf(onlyParentSeries, s =>
|
||||
s.RelationOf.Count == 0 ||
|
||||
s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel));
|
||||
|
||||
if (userRating.AgeRating != AgeRating.NotApplicable)
|
||||
{
|
||||
// this if statement is included in the extension
|
||||
query = query.RestrictAgainstAgeRestriction(userRating);
|
||||
}
|
||||
|
||||
return ApplyLimit(query
|
||||
.Sort(filter.SortOptions)
|
||||
.AsSplitQuery(), filter.LimitTo);
|
||||
}
|
||||
|
||||
private static IQueryable<Series> BuildFilterQuery(int userId, FilterV2Dto filterDto, IQueryable<Series> query)
|
||||
{
|
||||
if (filterDto.Statements == null || !filterDto.Statements.Any()) return query;
|
||||
|
||||
|
||||
var queries = filterDto.Statements
|
||||
.Select(statement => BuildFilterGroup(userId, statement, query))
|
||||
.ToList();
|
||||
|
||||
return filterDto.Combination == FilterCombination.And
|
||||
? queries.Aggregate((q1, q2) => q1.Intersect(q2))
|
||||
: queries.Aggregate((q1, q2) => q1.Union(q2));
|
||||
}
|
||||
|
||||
private static IQueryable<Series> ApplyLimit(IQueryable<Series> query, int limit)
|
||||
{
|
||||
return limit <= 0 ? query : query.Take(limit);
|
||||
}
|
||||
|
||||
private static IQueryable<Series> BuildFilterGroup(int userId, FilterStatementDto statement, IQueryable<Series> query)
|
||||
{
|
||||
var (value, _) = FilterFieldValueConverter.ConvertValue(statement.Field, statement.Value);
|
||||
return statement.Field switch
|
||||
{
|
||||
FilterField.Summary => query.HasSummary(true, statement.Comparison, (string) value),
|
||||
FilterField.SeriesName => query.HasName(true, statement.Comparison, (string) value),
|
||||
FilterField.PublicationStatus => query.HasPublicationStatus(true, statement.Comparison,
|
||||
(IList<PublicationStatus>) value),
|
||||
FilterField.Languages => query.HasLanguage(true, statement.Comparison, (IList<string>) value),
|
||||
FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList<AgeRating>) value),
|
||||
FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId),
|
||||
FilterField.Tags => query.HasTags(true, statement.Comparison, (IList<int>) value),
|
||||
FilterField.CollectionTags => query.HasCollectionTags(true, statement.Comparison, (IList<int>) value),
|
||||
FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||
FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||
FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||
FilterField.Editor => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||
FilterField.CoverArtist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||
FilterField.Letterer => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||
FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||
FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||
FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||
FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||
FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) value),
|
||||
FilterField.Libraries =>
|
||||
// This is handled in the code before this as it's handled in a more general, combined manner
|
||||
query,
|
||||
FilterField.ReadProgress => query.HasReadingProgress(true, statement.Comparison, (int) value, userId),
|
||||
FilterField.Formats => query.HasFormat(true, statement.Comparison, (IList<MangaFormat>) value),
|
||||
FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value),
|
||||
FilterField.ReadTime => query.HasAverageReadTime(true, statement.Comparison, (int) value),
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, IQueryable<Series> sQuery)
|
||||
@ -919,41 +1066,10 @@ public class SeriesRepository : ISeriesRepository
|
||||
|| EF.Functions.Like(s.LocalizedName!, $"%{filter.SeriesNameQuery}%"))
|
||||
.Where(s => userLibraries.Contains(s.LibraryId)
|
||||
&& formats.Contains(s.Format))
|
||||
.Sort(filter.SortOptions)
|
||||
.AsNoTracking();
|
||||
|
||||
// If no sort options, default to using SortName
|
||||
filter.SortOptions ??= new SortOptions()
|
||||
{
|
||||
IsAscending = true,
|
||||
SortField = SortField.SortName
|
||||
};
|
||||
|
||||
if (filter.SortOptions.IsAscending)
|
||||
{
|
||||
query = filter.SortOptions.SortField switch
|
||||
{
|
||||
SortField.SortName => query.OrderBy(s => s.SortName!.ToLower()),
|
||||
SortField.CreatedDate => query.OrderBy(s => s.Created),
|
||||
SortField.LastModifiedDate => query.OrderBy(s => s.LastModified),
|
||||
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
|
||||
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
|
||||
_ => query
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
query = filter.SortOptions.SortField switch
|
||||
{
|
||||
SortField.SortName => query.OrderByDescending(s => s.SortName!.ToLower()),
|
||||
SortField.CreatedDate => query.OrderByDescending(s => s.Created),
|
||||
SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified),
|
||||
SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded),
|
||||
SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead),
|
||||
_ => query
|
||||
};
|
||||
}
|
||||
|
||||
return query;
|
||||
return query.AsSplitQuery();
|
||||
}
|
||||
|
||||
public async Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId)
|
||||
@ -1615,6 +1731,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
.AsEnumerable();
|
||||
}
|
||||
|
||||
[Obsolete("Use GetWantToReadForUserV2Async")]
|
||||
public async Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter)
|
||||
{
|
||||
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
|
||||
@ -1630,6 +1747,21 @@ public class SeriesRepository : ISeriesRepository
|
||||
return await PagedList<SeriesDto>.CreateAsync(filteredQuery.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
public async Task<PagedList<SeriesDto>> GetWantToReadForUserV2Async(int userId, UserParams userParams, FilterV2Dto filter)
|
||||
{
|
||||
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
|
||||
var query = _context.AppUser
|
||||
.Where(user => user.Id == userId)
|
||||
.SelectMany(u => u.WantToRead)
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking();
|
||||
|
||||
var filteredQuery = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.None, query);
|
||||
|
||||
return await PagedList<SeriesDto>.CreateAsync(filteredQuery.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
public async Task<IList<Series>> GetWantToReadForUserAsync(int userId)
|
||||
{
|
||||
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
|
||||
|
@ -7,6 +7,7 @@ using API.Constants;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Account;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.Scrobbling;
|
||||
using API.DTOs.SeriesDetail;
|
||||
@ -53,7 +54,7 @@ public interface IUserRepository
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
|
||||
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterDto filter);
|
||||
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterV2Dto filter);
|
||||
Task<IEnumerable<AppUserBookmark>> GetAllBookmarksAsync();
|
||||
Task<AppUserBookmark?> GetBookmarkForPage(int page, int chapterId, int userId);
|
||||
Task<AppUserBookmark?> GetBookmarkAsync(int bookmarkId);
|
||||
@ -374,29 +375,71 @@ public class UserRepository : IUserRepository
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="filter">Only supports SeriesNameQuery</param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterDto filter)
|
||||
public async Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterV2Dto filter)
|
||||
{
|
||||
var query = _context.AppUserBookmark
|
||||
.Where(x => x.AppUserId == userId)
|
||||
.OrderBy(x => x.Created)
|
||||
.AsNoTracking();
|
||||
|
||||
if (string.IsNullOrEmpty(filter.SeriesNameQuery))
|
||||
var filterStatement = filter.Statements.FirstOrDefault(f => f.Field == FilterField.SeriesName);
|
||||
if (filterStatement == null || string.IsNullOrWhiteSpace(filterStatement.Value))
|
||||
return await query
|
||||
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
var seriesNameQueryNormalized = filter.SeriesNameQuery.ToNormalized();
|
||||
var queryString = filterStatement.Value.ToNormalized();
|
||||
var filterSeriesQuery = query.Join(_context.Series, b => b.SeriesId, s => s.Id, (bookmark, series) => new
|
||||
{
|
||||
bookmark,
|
||||
series
|
||||
})
|
||||
.Where(o => (EF.Functions.Like(o.series.Name, $"%{filter.SeriesNameQuery}%"))
|
||||
|| (o.series.OriginalName != null && EF.Functions.Like(o.series.OriginalName, $"%{filter.SeriesNameQuery}%"))
|
||||
|| (o.series.LocalizedName != null && EF.Functions.Like(o.series.LocalizedName, $"%{filter.SeriesNameQuery}%"))
|
||||
|| (EF.Functions.Like(o.series.NormalizedName, $"%{seriesNameQueryNormalized}%"))
|
||||
);
|
||||
});
|
||||
|
||||
switch (filterStatement.Comparison)
|
||||
{
|
||||
case FilterComparison.Equal:
|
||||
filterSeriesQuery = filterSeriesQuery.Where(s => s.series.Name.Equals(queryString)
|
||||
|| s.series.OriginalName.Equals(queryString)
|
||||
|| s.series.LocalizedName.Equals(queryString)
|
||||
|| s.series.SortName.Equals(queryString));
|
||||
break;
|
||||
case FilterComparison.BeginsWith:
|
||||
filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.series.Name, $"{queryString}%")
|
||||
||EF.Functions.Like(s.series.OriginalName, $"{queryString}%")
|
||||
|| EF.Functions.Like(s.series.LocalizedName, $"{queryString}%")
|
||||
|| EF.Functions.Like(s.series.SortName, $"{queryString}%"));
|
||||
break;
|
||||
case FilterComparison.EndsWith:
|
||||
filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.series.Name, $"%{queryString}")
|
||||
||EF.Functions.Like(s.series.OriginalName, $"%{queryString}")
|
||||
|| EF.Functions.Like(s.series.LocalizedName, $"%{queryString}")
|
||||
|| EF.Functions.Like(s.series.SortName, $"%{queryString}"));
|
||||
break;
|
||||
case FilterComparison.Matches:
|
||||
filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.series.Name, $"%{queryString}%")
|
||||
||EF.Functions.Like(s.series.OriginalName, $"%{queryString}%")
|
||||
|| EF.Functions.Like(s.series.LocalizedName, $"%{queryString}%")
|
||||
|| EF.Functions.Like(s.series.SortName, $"%{queryString}%"));
|
||||
break;
|
||||
case FilterComparison.NotEqual:
|
||||
filterSeriesQuery = filterSeriesQuery.Where(s => s.series.Name != queryString
|
||||
|| s.series.OriginalName != queryString
|
||||
|| s.series.LocalizedName != queryString
|
||||
|| s.series.SortName != queryString);
|
||||
break;
|
||||
case FilterComparison.NotContains:
|
||||
case FilterComparison.GreaterThan:
|
||||
case FilterComparison.GreaterThanEqual:
|
||||
case FilterComparison.LessThan:
|
||||
case FilterComparison.LessThanEqual:
|
||||
case FilterComparison.Contains:
|
||||
case FilterComparison.IsBefore:
|
||||
case FilterComparison.IsAfter:
|
||||
case FilterComparison.IsInLast:
|
||||
case FilterComparison.IsNotInLast:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
query = filterSeriesQuery.Select(o => o.bookmark);
|
||||
|
||||
|
@ -83,6 +83,7 @@ public static class ApplicationServiceExtensions
|
||||
options.UseInMemory(EasyCacheProfiles.License);
|
||||
options.UseInMemory(EasyCacheProfiles.Library);
|
||||
options.UseInMemory(EasyCacheProfiles.RevokedJwt);
|
||||
options.UseInMemory(EasyCacheProfiles.Filter);
|
||||
|
||||
// KavitaPlus stuff
|
||||
options.UseInMemory(EasyCacheProfiles.KavitaPlusReviews);
|
||||
|
515
API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs
Normal file
515
API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
53
API/Extensions/QueryExtensions/Filtering/SeriesSort.cs
Normal file
53
API/Extensions/QueryExtensions/Filtering/SeriesSort.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -110,6 +110,55 @@ public static class QueryableExtensions
|
||||
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)
|
||||
{
|
||||
if (isDesc)
|
||||
|
76
API/Helpers/Converters/FilterFieldValueConverter.cs
Normal file
76
API/Helpers/Converters/FilterFieldValueConverter.cs
Normal 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")
|
||||
};
|
||||
}
|
||||
}
|
@ -35,7 +35,7 @@ public class StatsService : IStatsService
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly DataContext _context;
|
||||
private readonly IStatisticService _statisticService;
|
||||
private const string ApiUrl = "https://stats.kavitareader.com";
|
||||
private const string ApiUrl = "https://stats.kavitareader.com"; // ""
|
||||
|
||||
public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork, DataContext context, IStatisticService statisticService)
|
||||
{
|
||||
|
@ -54,7 +54,6 @@ public class TokenService : ITokenService
|
||||
};
|
||||
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
|
||||
claims.AddRange(roles.Select(role => new Claim(Role, role)));
|
||||
|
||||
var credentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature);
|
||||
|
@ -317,7 +317,7 @@ public class Startup
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials() // For SignalR token query param
|
||||
.WithOrigins("http://localhost:4200", $"http://{GetLocalIpAddress()}:4200", $"http://{GetLocalIpAddress()}:5000", "https://kavita.majora2007.duckdns.org")
|
||||
.WithOrigins("http://localhost:4200", $"http://{GetLocalIpAddress()}:4200", $"http://{GetLocalIpAddress()}:5000")
|
||||
.WithExposedHeaders("Content-Disposition", "Pagination"));
|
||||
}
|
||||
else
|
||||
@ -327,7 +327,6 @@ public class Startup
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials() // For SignalR token query param
|
||||
.WithOrigins("https://kavita.majora2007.duckdns.org")
|
||||
.WithExposedHeaders("Content-Disposition", "Pagination"));
|
||||
}
|
||||
|
||||
|
@ -2,3 +2,4 @@ export interface Language {
|
||||
isoCode: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { MangaFormat } from "../manga-format";
|
||||
import { SeriesFilterV2 } from "./v2/series-filter-v2";
|
||||
import {FilterField} from "./v2/filter-field";
|
||||
|
||||
export interface FilterItem<T> {
|
||||
title: string;
|
||||
@ -6,38 +8,6 @@ export interface FilterItem<T> {
|
||||
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 {
|
||||
sortField: SortField;
|
||||
isAscending: boolean;
|
||||
@ -52,11 +22,9 @@ export enum SortField {
|
||||
ReleaseYear = 6,
|
||||
}
|
||||
|
||||
export interface ReadStatus {
|
||||
notRead: boolean,
|
||||
inProgress: boolean,
|
||||
read: boolean,
|
||||
}
|
||||
export const allSortFields = Object.keys(SortField)
|
||||
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
|
||||
.map(key => parseInt(key, 10)) as SortField[];
|
||||
|
||||
export const mangaFormatFilters = [
|
||||
{
|
||||
@ -82,7 +50,7 @@ export const mangaFormatFilters = [
|
||||
];
|
||||
|
||||
export interface FilterEvent {
|
||||
filter: SeriesFilter;
|
||||
filterV2: SeriesFilterV2;
|
||||
isFirst: boolean;
|
||||
}
|
||||
|
||||
|
4
UI/Web/src/app/_models/metadata/v2/filter-combination.ts
Normal file
4
UI/Web/src/app/_models/metadata/v2/filter-combination.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum FilterCombination {
|
||||
Or = 0,
|
||||
And = 1
|
||||
}
|
45
UI/Web/src/app/_models/metadata/v2/filter-comparison.ts
Normal file
45
UI/Web/src/app/_models/metadata/v2/filter-comparison.ts
Normal 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,
|
||||
}
|
33
UI/Web/src/app/_models/metadata/v2/filter-field.ts
Normal file
33
UI/Web/src/app/_models/metadata/v2/filter-field.ts
Normal 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[];
|
8
UI/Web/src/app/_models/metadata/v2/filter-statement.ts
Normal file
8
UI/Web/src/app/_models/metadata/v2/filter-statement.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { FilterComparison } from "./filter-comparison";
|
||||
import { FilterField } from "./filter-field";
|
||||
|
||||
export interface FilterStatement {
|
||||
comparison: FilterComparison;
|
||||
field: FilterField;
|
||||
value: string;
|
||||
}
|
11
UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts
Normal file
11
UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts
Normal 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;
|
||||
}
|
@ -55,7 +55,6 @@ export class AccountService {
|
||||
private messageHub: MessageHubService, private themeService: ThemeService) {
|
||||
messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate),
|
||||
map(evt => evt.payload as UserUpdateEvent),
|
||||
tap(u => console.log('user update: ', u)),
|
||||
filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username),
|
||||
switchMap(() => this.refreshAccount()))
|
||||
.subscribe(() => {});
|
||||
@ -307,7 +306,6 @@ export class AccountService {
|
||||
|
||||
|
||||
private refreshAccount() {
|
||||
console.log('Refreshing account');
|
||||
if (this.currentUser === null || this.currentUser === undefined) return of();
|
||||
return this.httpClient.get<User>(this.baseUrl + 'account/refresh-account').pipe(map((user: User) => {
|
||||
if (user) {
|
||||
|
@ -92,6 +92,8 @@ export enum Action {
|
||||
* Removes the Series from On Deck inclusion
|
||||
*/
|
||||
RemoveFromOnDeck = 19,
|
||||
AddRuleGroup = 20,
|
||||
RemoveRuleGroup = 21
|
||||
}
|
||||
|
||||
export interface ActionItem<T> {
|
||||
@ -178,6 +180,15 @@ export class ActionFactoryService {
|
||||
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) {}
|
||||
|
||||
filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import { of } from 'rxjs';
|
||||
import {map, tap} from 'rxjs/operators';
|
||||
import {of, ReplaySubject, switchMap} from 'rxjs';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {Genre} from '../_models/metadata/genre';
|
||||
import {AgeRating} from '../_models/metadata/age-rating';
|
||||
@ -11,6 +11,13 @@ import { PublicationStatusDto } from '../_models/metadata/publication-status-dto
|
||||
import {Person} from '../_models/metadata/person';
|
||||
import {Tag} from '../_models/tag';
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
import {FilterComparison} from '../_models/metadata/v2/filter-comparison';
|
||||
import {FilterField} from '../_models/metadata/v2/filter-field';
|
||||
import {Router} from "@angular/router";
|
||||
import {SortField} from "../_models/metadata/series-filter";
|
||||
import {FilterCombination} from "../_models/metadata/v2/filter-combination";
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
import {FilterStatement} from "../_models/metadata/v2/filter-statement";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -19,10 +26,37 @@ export class MetadataService {
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
private currentThemeSource = new ReplaySubject<SeriesFilterV2>(1);
|
||||
|
||||
private ageRatingTypes: {[key: number]: string} | undefined = undefined;
|
||||
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) {
|
||||
if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {
|
||||
@ -78,6 +112,7 @@ export class MetadataService {
|
||||
return this.httpClient.get<Array<Language>>(this.baseUrl + method);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* All the potential language tags there can be
|
||||
*/
|
||||
@ -100,4 +135,30 @@ export class MetadataService {
|
||||
getChapterSummary(chapterId: number) {
|
||||
return this.httpClient.get<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 + '';
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,6 @@ import { MangaFormat } from '../_models/manga-format';
|
||||
import { BookmarkInfo } from '../_models/manga-reader/bookmark-info';
|
||||
import { PageBookmark } from '../_models/readers/page-bookmark';
|
||||
import { ProgressBookmark } from '../_models/readers/progress-bookmark';
|
||||
import { SeriesFilter } from '../_models/metadata/series-filter';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
|
||||
import { FileDimension } from '../manga-reader/_models/file-dimension';
|
||||
@ -19,6 +18,7 @@ import { TextResonse } from '../_types/text-response';
|
||||
import { AccountService } from './account.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {PersonalToC} from "../_models/readers/personal-toc";
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
|
||||
export const CHAPTER_ID_DOESNT_EXIST = -1;
|
||||
export const CHAPTER_ID_NOT_FETCHED = -2;
|
||||
@ -70,12 +70,8 @@ export class ReaderService {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page});
|
||||
}
|
||||
|
||||
getAllBookmarks(filter: SeriesFilter | undefined) {
|
||||
let params = new HttpParams();
|
||||
params = this.utilityService.addPaginationIfExists(params, undefined, undefined);
|
||||
const data = this.filterUtilityService.createSeriesFilter(filter);
|
||||
|
||||
return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', data);
|
||||
getAllBookmarks(filter: SeriesFilterV2 | undefined) {
|
||||
return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter);
|
||||
}
|
||||
|
||||
getBookmarks(chapterId: number) {
|
||||
|
@ -12,12 +12,12 @@ import { PaginatedResult } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
import { RelatedSeries } from '../_models/series-detail/related-series';
|
||||
import { SeriesDetail } from '../_models/series-detail/series-detail';
|
||||
import { SeriesFilter } from '../_models/metadata/series-filter';
|
||||
import { SeriesGroup } from '../_models/series-group';
|
||||
import { SeriesMetadata } from '../_models/metadata/series-metadata';
|
||||
import { Volume } from '../_models/volume';
|
||||
import { ImageService } from './image.service';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import { SeriesFilterV2 } from '../_models/metadata/v2/series-filter-v2';
|
||||
import {UserReview} from "../_single-module/review-card/user-review";
|
||||
import {Rating} from "../_models/rating";
|
||||
import {Recommendation} from "../_models/series-detail/recommendation";
|
||||
@ -32,12 +32,12 @@ export class SeriesService {
|
||||
paginatedSeriesForTagsResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>();
|
||||
|
||||
constructor(private httpClient: HttpClient, private imageService: ImageService,
|
||||
private utilityService: UtilityService, private filterUtilityService: FilterUtilitiesService) { }
|
||||
private utilityService: UtilityService) { }
|
||||
|
||||
getAllSeries(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
|
||||
getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
|
||||
let params = new HttpParams();
|
||||
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
const data = this.filterUtilityService.createSeriesFilter(filter);
|
||||
const data = filter || {};
|
||||
|
||||
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series/all', data, {observe: 'response', params}).pipe(
|
||||
map((response: any) => {
|
||||
@ -46,12 +46,12 @@ export class SeriesService {
|
||||
);
|
||||
}
|
||||
|
||||
getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
|
||||
getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
|
||||
let params = new HttpParams();
|
||||
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
const data = this.filterUtilityService.createSeriesFilter(filter);
|
||||
const data = filter || {};
|
||||
|
||||
return this.httpClient.post<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) => {
|
||||
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});
|
||||
}
|
||||
|
||||
getRecentlyAdded(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
|
||||
const data = this.filterUtilityService.createSeriesFilter(filter);
|
||||
getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
|
||||
let params = new HttpParams();
|
||||
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
|
||||
return this.httpClient.post<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 => {
|
||||
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', {});
|
||||
}
|
||||
|
||||
getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter): Observable<PaginatedResult<Series[]>> {
|
||||
const data = this.filterUtilityService.createSeriesFilter(filter);
|
||||
|
||||
getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2): Observable<PaginatedResult<Series[]>> {
|
||||
let params = new HttpParams();
|
||||
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 => {
|
||||
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) {
|
||||
const data = this.filterUtilityService.createSeriesFilter(filter);
|
||||
|
||||
getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
|
||||
let params = new HttpParams();
|
||||
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
const data = filter || {};
|
||||
|
||||
return this.httpClient.post<Series[]>(this.baseUrl + 'series/on-deck?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
|
||||
map(response => {
|
||||
|
@ -4,8 +4,8 @@ import { take } from 'rxjs';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {DynamicListPipe} from "../../dynamic-list.pipe";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-actionables',
|
@ -34,7 +34,6 @@ export class SpoilerComponent implements OnInit{
|
||||
ngOnInit() {
|
||||
this.isCollapsed = true;
|
||||
this.cdRef.markForCheck();
|
||||
console.log('html: ', this.html)
|
||||
}
|
||||
|
||||
|
||||
|
@ -4,7 +4,7 @@
|
||||
<div class="form-check" *ngIf="allLibraries.length > 0">
|
||||
<input id="select-all" type="checkbox" class="form-check-input"
|
||||
[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>
|
||||
<ul>
|
||||
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
|
||||
|
@ -28,7 +28,7 @@
|
||||
</button>
|
||||
|
||||
<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)"
|
||||
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)"
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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>
|
||||
{{title}}
|
||||
</h2>
|
||||
|
@ -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 { Pagination } from 'src/app/_models/pagination';
|
||||
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 { ActionService } from 'src/app/_services/action.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 { NgIf, DecimalPipe } from '@angular/common';
|
||||
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 {
|
||||
|
||||
title: string = 'All Series';
|
||||
title: string = translate('all-series.title');
|
||||
series: Series[] = [];
|
||||
loadingSeries = false;
|
||||
pagination!: Pagination;
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
filter: SeriesFilterV2 | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
filterActiveCheck!: SeriesFilter;
|
||||
filterActiveCheck!: SeriesFilterV2;
|
||||
filterActive: boolean = false;
|
||||
jumpbarKeys: Array<JumpKey> = [];
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
@ -112,12 +114,19 @@ export class AllSeriesComponent implements OnInit {
|
||||
|
||||
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.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();
|
||||
}
|
||||
|
||||
@ -144,9 +153,13 @@ export class AllSeriesComponent implements OnInit {
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@ -154,7 +167,7 @@ export class AllSeriesComponent implements OnInit {
|
||||
this.filterActive = !this.utilityService.deepEqual(this.filter, this.filterActiveCheck);
|
||||
this.loadingSeries = true;
|
||||
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.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (s: Series) => s.name);
|
||||
this.pagination = series.pagination;
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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>
|
||||
{{t('title')}}
|
||||
</h2>
|
||||
|
@ -20,7 +20,7 @@ import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
|
||||
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
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 { ImageService } from 'src/app/_services/image.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 { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmarks',
|
||||
@ -53,11 +55,11 @@ export class BookmarksComponent implements OnInit {
|
||||
jumpbarKeys: Array<JumpKey> = [];
|
||||
|
||||
pagination!: Pagination;
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
filter: SeriesFilterV2 | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
filterActive: boolean = false;
|
||||
filterActiveCheck!: SeriesFilter;
|
||||
filterActiveCheck!: SeriesFilterV2;
|
||||
|
||||
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
|
||||
refresh: EventEmitter<void> = new EventEmitter();
|
||||
@ -71,18 +73,14 @@ export class BookmarksComponent implements OnInit {
|
||||
private router: Router, private readonly cdRef: ChangeDetectorRef,
|
||||
private filterUtilityService: FilterUtilitiesService, private route: ActivatedRoute,
|
||||
private jumpbarService: JumpbarService) {
|
||||
this.filterSettings.ageRatingDisabled = true;
|
||||
this.filterSettings.collectionDisabled = true;
|
||||
this.filterSettings.formatDisabled = true;
|
||||
this.filterSettings.genresDisabled = true;
|
||||
this.filterSettings.languageDisabled = true;
|
||||
this.filterSettings.libraryDisabled = true;
|
||||
this.filterSettings.peopleDisabled = true;
|
||||
this.filterSettings.publicationStatusDisabled = true;
|
||||
this.filterSettings.ratingDisabled = true;
|
||||
this.filterSettings.readProgressDisabled = true;
|
||||
this.filterSettings.tagsDisabled = true;
|
||||
this.filterSettings.sortDisabled = true;
|
||||
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;
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@ -151,11 +149,6 @@ export class BookmarksComponent implements OnInit {
|
||||
}
|
||||
|
||||
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.cdRef.markForCheck();
|
||||
|
||||
@ -222,9 +215,13 @@ export class BookmarksComponent implements OnInit {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
@ -11,9 +11,9 @@ import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/acti
|
||||
import { BulkSelectionService } from '../bulk-selection.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {AsyncPipe, CommonModule} from "@angular/common";
|
||||
import {CardActionablesComponent} from "../card-item/card-actionables/card-actionables.component";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-bulk-operations',
|
||||
|
@ -43,13 +43,13 @@ import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
|
||||
import {EntityInfoCardsComponent} from "../entity-info-cards/entity-info-cards.component";
|
||||
import {CoverImageChooserComponent} from "../cover-image-chooser/cover-image-chooser.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 {BytesPipe} from "../../pipe/bytes.pipe";
|
||||
import {BadgeExpanderComponent} from "../../shared/badge-expander/badge-expander.component";
|
||||
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
|
||||
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
|
||||
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||
|
||||
enum TabID {
|
||||
General = 0,
|
||||
|
@ -5,12 +5,15 @@ import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ContentChild,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostListener, inject,
|
||||
HostListener,
|
||||
inject,
|
||||
Inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
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 { Library } from 'src/app/_models/library';
|
||||
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 { JumpbarService } from 'src/app/_services/jumpbar.service';
|
||||
import { ScrollService } from 'src/app/_services/scroll.service';
|
||||
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 {MetadataFilterComponent} from "../../metadata-filter/metadata-filter.component";
|
||||
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({
|
||||
selector: 'app-card-detail-layout',
|
||||
@ -84,12 +89,13 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
||||
@ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent;
|
||||
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
filter: SeriesFilter = this.filterUtilityService.createSeriesFilter();
|
||||
filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter();
|
||||
libraries: Array<FilterItem<Library>> = [];
|
||||
|
||||
updateApplied: number = 0;
|
||||
hasResumedJumpKey: boolean = false;
|
||||
|
||||
|
||||
get Breakpoint() {
|
||||
return Breakpoint;
|
||||
}
|
||||
@ -157,7 +163,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
||||
}
|
||||
|
||||
hasCustomSort() {
|
||||
return this.filter.sortOptions || this.filterSettings?.presets?.sortOptions;
|
||||
return this.filter?.sortOptions || this.filterSettings?.presetsV2?.sortOptions;
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
@ -169,7 +175,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
||||
applyMetadataFilter(event: FilterEvent) {
|
||||
this.applyFilter.emit(event);
|
||||
this.updateApplied++;
|
||||
this.filter = event.filter;
|
||||
this.filter = event.filterV2;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
|
@ -36,11 +36,11 @@ import {DownloadIndicatorComponent} from "../download-indicator/download-indicat
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {MangaFormatPipe} from "../../pipe/manga-format.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 {CommonModule} from "@angular/common";
|
||||
import {RouterLink} from "@angular/router";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-item',
|
||||
|
@ -3,11 +3,11 @@
|
||||
<div class="mt-4 mb-3">
|
||||
<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 -->
|
||||
<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>
|
||||
</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>
|
||||
</app-metadata-detail>
|
||||
</div>
|
||||
|
@ -28,6 +28,7 @@ import {MetadataDetailComponent} from "../../series-detail/_components/metadata-
|
||||
import {FilterQueryParam} from "../../shared/_services/filter-utilities.service";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||
|
||||
@Component({
|
||||
selector: 'app-entity-info-cards',
|
||||
@ -75,6 +76,8 @@ export class EntityInfoCardsComponent implements OnInit {
|
||||
return AgeRating;
|
||||
}
|
||||
|
||||
get FilterField() { return FilterField; }
|
||||
|
||||
get WebLinks() {
|
||||
if (this.chapter.webLinks === '') return [];
|
||||
return this.chapter.webLinks.split(',');
|
||||
|
@ -24,9 +24,9 @@ import {CommonModule} from "@angular/common";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.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 {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-list-item',
|
||||
|
@ -12,16 +12,16 @@
|
||||
<ng-container *ngIf="seriesMetadata">
|
||||
<ng-container *ngIf="seriesMetadata.ageRating">
|
||||
<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}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
</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">
|
||||
<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}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
@ -33,7 +33,7 @@
|
||||
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
|
||||
<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'}}"
|
||||
(click)="handleGoTo(FilterQueryParam.PublicationStatus, seriesMetadata.publicationStatus)"
|
||||
(click)="handleGoTo(FilterField.PublicationStatus, seriesMetadata.publicationStatus)"
|
||||
[ngbTooltip]="t('publication-status-tooltip') + ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')'">
|
||||
{{pubStatus}}
|
||||
</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">
|
||||
<app-icon-and-title [label]="t('format-title')" [clickable]="true"
|
||||
[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}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
|
@ -10,7 +10,6 @@ import {
|
||||
Output
|
||||
} from '@angular/core';
|
||||
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 { UserProgressUpdateEvent } from 'src/app/_models/events/user-progress-update-event';
|
||||
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 { EVENTS, MessageHubService } from 'src/app/_services/message-hub.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 {ScrobblingService} from "../../_services/scrobbling.service";
|
||||
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
|
||||
*/
|
||||
@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};
|
||||
isScrobbling: boolean = true;
|
||||
@ -64,8 +64,8 @@ export class SeriesInfoCardsComponent implements OnInit, OnChanges {
|
||||
return MangaFormat;
|
||||
}
|
||||
|
||||
get FilterQueryParam() {
|
||||
return FilterQueryParam;
|
||||
get FilterField() {
|
||||
return FilterField;
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
|
@ -26,7 +26,7 @@
|
||||
header="Series"
|
||||
[isLoading]="isLoading"
|
||||
[items]="series"
|
||||
[pagination]="seriesPagination"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
[parentScroll]="scrollingBlock"
|
||||
|
@ -28,7 +28,7 @@ import {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added-
|
||||
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
|
||||
import {Pagination} from 'src/app/_models/pagination';
|
||||
import {Series} from 'src/app/_models/series';
|
||||
import {FilterEvent, 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 {ActionService} from 'src/app/_services/action.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 {ReadMoreComponent} from '../../../shared/read-more/read-more.component';
|
||||
import {ImageComponent} from '../../../shared/image/image.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 {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
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({
|
||||
selector: 'app-collection-detail',
|
||||
@ -69,14 +73,14 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
||||
tagImage: string = '';
|
||||
isLoading: boolean = true;
|
||||
series: Array<Series> = [];
|
||||
seriesPagination!: Pagination;
|
||||
pagination!: Pagination;
|
||||
collectionTagActions: ActionItem<CollectionTag>[] = [];
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
filter: SeriesFilterV2 | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
summary: string = '';
|
||||
|
||||
actionInProgress: boolean = false;
|
||||
filterActiveCheck!: SeriesFilter;
|
||||
filterActiveCheck!: SeriesFilterV2;
|
||||
filterActive: boolean = false;
|
||||
|
||||
jumpbarKeys: Array<JumpKey> = [];
|
||||
@ -165,11 +169,16 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
||||
}
|
||||
const tagId = parseInt(routeId, 10);
|
||||
|
||||
this.seriesPagination = this.filterUtilityService.pagination(this.route.snapshot);
|
||||
[this.filterSettings.presets, this.filterSettings.openByDefault] = this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot);
|
||||
this.filterSettings.presets.collectionTags = [tagId];
|
||||
this.filterActiveCheck = this.filterUtilityService.createSeriesFilter();
|
||||
this.filterActiveCheck.collectionTags = [tagId];
|
||||
this.pagination = this.filterUtilityService.pagination(this.route.snapshot);
|
||||
|
||||
this.filter = this.filterUtilityService.filterPresetsFromUrlV2(this.route.snapshot);
|
||||
if (this.filter.statements.filter(stmt => stmt.field === FilterField.Libraries).length === 0) {
|
||||
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.updateTag(tagId);
|
||||
@ -213,14 +222,17 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
||||
const matchingTags = tags.filter(t => t.id === tagId);
|
||||
if (matchingTags.length === 0) {
|
||||
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('/');
|
||||
return;
|
||||
}
|
||||
|
||||
this.collectionTag = matchingTags[0];
|
||||
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.titleService.setTitle(this.translocoService.translate('errors.collection-invalid-access', {collectionName: this.collectionTag.title}));
|
||||
// TODO: BUG: This title key is incorrect!
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
@ -230,16 +242,9 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
||||
this.isLoading = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
if (!this.filter) {
|
||||
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.seriesService.getAllSeriesV2(undefined, undefined, this.filter).pipe(take(1)).subscribe(series => {
|
||||
this.series = series.result;
|
||||
this.seriesPagination = series.pagination;
|
||||
this.pagination = series.pagination;
|
||||
this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (series: Series) => series.name);
|
||||
this.isLoading = false;
|
||||
window.scrollTo(0, 0);
|
||||
@ -248,9 +253,13 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,6 @@ import { AllCollectionsComponent } from './_components/all-collections/all-colle
|
||||
import { CollectionsRoutingModule } from './collections-routing.module';
|
||||
import {ImageComponent} from "../shared/image/image.component";
|
||||
import {ReadMoreComponent} from "../shared/read-more/read-more.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";
|
||||
@ -13,6 +12,7 @@ import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.
|
||||
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
|
||||
import {SeriesCardComponent} from "../cards/series-card/series-card.component";
|
||||
import {CardItemComponent} from "../cards/card-item/card-item.component";
|
||||
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
|
||||
|
||||
|
||||
|
||||
|
@ -1,17 +1,9 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Input,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
||||
import {Title} from '@angular/platform-browser';
|
||||
import {Router, RouterLink} from '@angular/router';
|
||||
import {Observable, of, ReplaySubject} from 'rxjs';
|
||||
import { debounceTime, map, take, tap, shareReplay } from 'rxjs/operators';
|
||||
import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service';
|
||||
import {debounceTime, map, shareReplay, take, tap} from 'rxjs/operators';
|
||||
import {FilterQueryParam, FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
|
||||
import {SeriesAddedEvent} from 'src/app/_models/events/series-added-event';
|
||||
import {SeriesRemovedEvent} from 'src/app/_models/events/series-removed-event';
|
||||
import {Library} from 'src/app/_models/library';
|
||||
@ -22,15 +14,19 @@ 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 {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
|
||||
import {SeriesService} from 'src/app/_services/series.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {CardItemComponent} from '../../cards/card-item/card-item.component';
|
||||
import {SeriesCardComponent} from '../../cards/series-card/series-card.component';
|
||||
import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.component';
|
||||
import { NgIf, AsyncPipe } from '@angular/common';
|
||||
import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {AsyncPipe, NgIf} from '@angular/common';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
@ -61,6 +57,7 @@ export class DashboardComponent implements OnInit {
|
||||
*/
|
||||
private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
|
||||
constructor(public accountService: AccountService, private libraryService: LibraryService,
|
||||
private seriesService: SeriesService, private router: Router,
|
||||
@ -138,9 +135,11 @@ export class DashboardComponent implements OnInit {
|
||||
}
|
||||
|
||||
loadRecentlyAddedSeries() {
|
||||
let api = this.seriesService.getRecentlyAdded(0, 1, 30);
|
||||
let api = this.seriesService.getRecentlyAdded(1, 30);
|
||||
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) => {
|
||||
this.recentlyAddedSeries = updatedSeries.result;
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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>
|
||||
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
|
||||
<span>{{libraryName}}</span>
|
||||
|
@ -1,7 +1,8 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
@ -16,7 +17,7 @@ import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||
import {Library} from '../_models/library';
|
||||
import {Pagination} from '../_models/pagination';
|
||||
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 {ActionService} from '../_services/action.service';
|
||||
import {LibraryService} from '../_services/library.service';
|
||||
@ -33,11 +34,17 @@ import { BulkOperationsComponent } from '../cards/bulk-operations/bulk-operation
|
||||
import {SeriesCardComponent} from '../cards/series-card/series-card.component';
|
||||
import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import {LibraryRecommendedComponent} from './library-recommended/library-recommended.component';
|
||||
import { NgFor, NgIf, DecimalPipe } from '@angular/common';
|
||||
import { NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap';
|
||||
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 {DecimalPipe, NgFor, NgIf} from '@angular/common';
|
||||
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
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({
|
||||
selector: 'app-library-detail',
|
||||
@ -55,11 +62,11 @@ export class LibraryDetailComponent implements OnInit {
|
||||
loadingSeries = false;
|
||||
pagination!: Pagination;
|
||||
actions: ActionItem<Library>[] = [];
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
filterV2: SeriesFilterV2 | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
filterActive: boolean = false;
|
||||
filterActiveCheck!: SeriesFilter;
|
||||
filterActiveCheck!: SeriesFilterV2;
|
||||
refresh: EventEmitter<void> = new EventEmitter();
|
||||
|
||||
jumpKeys: Array<JumpKey> = [];
|
||||
@ -72,6 +79,9 @@ export class LibraryDetailComponent implements OnInit {
|
||||
];
|
||||
active = this.tabs[0];
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
|
||||
bulkActionCallback = (action: ActionItem<any>, data: any) => {
|
||||
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,
|
||||
private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService,
|
||||
private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
|
||||
private utilityService: UtilityService, public navService: NavService, private filterUtilityService: FilterUtilitiesService,
|
||||
private readonly cdRef: ChangeDetectorRef) {
|
||||
private utilityService: UtilityService, public navService: NavService, private filterUtilityService: FilterUtilitiesService) {
|
||||
const routeId = this.route.snapshot.paramMap.get('libraryId');
|
||||
if (routeId === null) {
|
||||
this.router.navigateByUrl('/libraries');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
this.libraryId = parseInt(routeId, 10);
|
||||
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.pagination = this.filterUtilityService.pagination(this.route.snapshot);
|
||||
[this.filterSettings.presets, this.filterSettings.openByDefault] = this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot);
|
||||
if (this.filterSettings.presets) this.filterSettings.presets.libraries = [this.libraryId];
|
||||
// Setup filterActiveCheck to check filter against
|
||||
this.filterActiveCheck = this.filterUtilityService.createSeriesFilter();
|
||||
this.filterActiveCheck.libraries = [this.libraryId];
|
||||
this.filterV2 = this.filterUtilityService.filterPresetsFromUrlV2(this.route.snapshot);
|
||||
|
||||
if (this.filterV2.statements.filter(stmt => stmt.field === FilterField.Libraries).length === 0) {
|
||||
this.filterV2!.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
|
||||
if (event.event === EVENTS.SeriesAdded) {
|
||||
const seriesAdded = event.payload as SeriesAddedEvent;
|
||||
if (seriesAdded.libraryId !== this.libraryId) return;
|
||||
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) {
|
||||
if (!this.utilityService.deepEqual(this.filterV2, this.filterActiveCheck)) {
|
||||
this.loadPage();
|
||||
return;
|
||||
}
|
||||
@ -188,7 +202,7 @@ export class LibraryDetailComponent implements OnInit {
|
||||
} else if (event.event === EVENTS.SeriesRemoved) {
|
||||
const seriesRemoved = event.payload as SeriesRemovedEvent;
|
||||
if (seriesRemoved.libraryId !== this.libraryId) return;
|
||||
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) {
|
||||
if (!this.utilityService.deepEqual(this.filterV2, this.filterActiveCheck)) {
|
||||
this.loadPage();
|
||||
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;
|
||||
if (library === undefined) {
|
||||
lib = {id: this.libraryId, name: this.libraryName};
|
||||
}
|
||||
switch (action.action) {
|
||||
case(Action.Scan):
|
||||
this.actionService.scanLibrary(lib);
|
||||
await this.actionService.scanLibrary(lib);
|
||||
break;
|
||||
case(Action.RefreshMetadata):
|
||||
this.actionService.refreshMetadata(lib);
|
||||
await this.actionService.refreshMetadata(lib);
|
||||
break;
|
||||
case(Action.Edit):
|
||||
this.actionService.editLibrary(lib);
|
||||
@ -245,25 +259,23 @@ export class LibraryDetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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.filterActive = !this.utilityService.deepEqual(this.filter, this.filterActiveCheck);
|
||||
this.filterActive = !this.utilityService.deepEqual(this.filterV2, this.filterActiveCheck);
|
||||
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.pagination = series.pagination;
|
||||
this.loadingSeries = false;
|
||||
|
@ -5,7 +5,6 @@ import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { LibraryDetailRoutingModule } from './library-detail-routing.module';
|
||||
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 {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
|
||||
import {SeriesCardComponent} from "../cards/series-card/series-card.component";
|
||||
@ -13,6 +12,7 @@ import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
|
||||
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
|
||||
|
||||
|
||||
|
||||
|
@ -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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
@ -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
|
||||
* style properites, just scroll port visibility.
|
||||
* style properties, just scroll port visibility.
|
||||
* @param elem
|
||||
* @returns
|
||||
*/
|
||||
|
@ -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>
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
66
UI/Web/src/app/metadata-filter/_pipes/filter-field.pipe.ts
Normal file
66
UI/Web/src/app/metadata-filter/_pipes/filter-field.pipe.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,24 +1,6 @@
|
||||
import { SeriesFilter } from "../_models/metadata/series-filter";
|
||||
import { SeriesFilterV2 } from "../_models/metadata/v2/series-filter-v2";
|
||||
|
||||
export class FilterSettings {
|
||||
libraryDisabled = false;
|
||||
formatDisabled = false;
|
||||
collectionDisabled = false;
|
||||
genresDisabled = false;
|
||||
peopleDisabled = false;
|
||||
readProgressDisabled = false;
|
||||
ratingDisabled = false;
|
||||
sortDisabled = false;
|
||||
ageRatingDisabled = false;
|
||||
tagsDisabled = false;
|
||||
languageDisabled = false;
|
||||
publicationStatusDisabled = false;
|
||||
searchNameDisabled = false;
|
||||
releaseYearDisabled = false;
|
||||
presets: SeriesFilter | undefined;
|
||||
/**
|
||||
* Should the filter section be open by default
|
||||
* @deprecated This is deprecated UX pattern. New style is to show highlight on filter button.
|
||||
*/
|
||||
openByDefault = false;
|
||||
presetsV2: SeriesFilterV2 | undefined;
|
||||
}
|
@ -19,349 +19,31 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-template #filterSection>
|
||||
<ng-template #globalFilterTooltip>{{t('format-tooltip')}}</ng-template>
|
||||
<div class="filter-section mx-auto pb-3" *ngIf="fullyLoaded">
|
||||
<div class="row justify-content-center g-0">
|
||||
<div class="col-md-2 me-3">
|
||||
<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>
|
||||
<app-metadata-builder [filter]="filterV2!" [availableFilterFields]="allFilterFields" (update)="handleFilters($event)"></app-metadata-builder>
|
||||
</div>
|
||||
<form [formGroup]="sortGroup" class="container-fluid">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-2">
|
||||
<div class="form-group pe-1">
|
||||
<label for="limit-to" class="form-label">{{t('limit-label')}}</label>
|
||||
<input id="limit-to" type="number" inputmode="numeric" class="form-control" formControlName="limitTo">
|
||||
</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 class="row justify-content-center g-0">
|
||||
<!-- The People row -->
|
||||
<div class="col-md-2 me-3">
|
||||
<div class="mb-3">
|
||||
<label for="cover-artist" class="form-label">{{t('cover-artist-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePersonFilters($event, PersonRole.CoverArtist)" [settings]="getPersonsSettings(PersonRole.CoverArtist)"
|
||||
[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 class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="inprogress" formControlName="inProgress">
|
||||
<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'">★</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">
|
||||
<div class="col-md-3">
|
||||
<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>
|
||||
<ng-template #descSort>
|
||||
<i class="fa fa-arrow-down" [title]="t('descending-alt')"></i>
|
||||
</ng-template>
|
||||
</button>
|
||||
<select id="sort-options" class="form-select" formControlName="sortField" style="height: 38px;">
|
||||
<option [value]="SortField.SortName">{{SortField.SortName | 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>
|
||||
<option *ngFor="let field of allSortFields" [value]="field">{{field | sortField}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- TODO: I might want to put a Clear button which blanks out the whole filter -->
|
||||
<div class="col-md-2 me-3 mt-4">
|
||||
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
|
||||
</div>
|
||||
@ -369,7 +51,7 @@
|
||||
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
</ng-container>
|
||||
|
@ -2,40 +2,31 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ContentChild, DestroyRef,
|
||||
ContentChild,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms';
|
||||
import { NgbCollapse, NgbTooltip, NgbRating } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { distinctUntilChanged, forkJoin, map, Observable, of, ReplaySubject } from 'rxjs';
|
||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||
import {NgbCollapse, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service';
|
||||
import {Breakpoint, UtilityService} from '../shared/_services/utility.service';
|
||||
import { TypeaheadSettings } from '../typeahead/_models/typeahead-settings';
|
||||
import { CollectionTag } from '../_models/collection-tag';
|
||||
import { Genre } from '../_models/metadata/genre';
|
||||
import {Library} from '../_models/library';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
|
||||
import { Language } from '../_models/metadata/language';
|
||||
import { PublicationStatusDto } from '../_models/metadata/publication-status-dto';
|
||||
import { Person, PersonRole } from '../_models/metadata/person';
|
||||
import { FilterEvent, FilterItem, mangaFormatFilters, SeriesFilter, SortField } from '../_models/metadata/series-filter';
|
||||
import { Tag } from '../_models/tag';
|
||||
import { CollectionTagService } from '../_services/collection-tag.service';
|
||||
import { LibraryService } from '../_services/library.service';
|
||||
import { MetadataService } from '../_services/metadata.service';
|
||||
import {allSortFields, FilterEvent, FilterItem, SortField} from '../_models/metadata/series-filter';
|
||||
import {ToggleService} from '../_services/toggle.service';
|
||||
import {FilterSettings} from './filter-settings';
|
||||
import {SeriesFilterV2} from '../_models/metadata/v2/series-filter-v2';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {TypeaheadComponent} from '../typeahead/_components/typeahead.component';
|
||||
import {DrawerComponent} from '../shared/drawer/drawer.component';
|
||||
import { NgIf, NgTemplateOutlet, AsyncPipe } from '@angular/common';
|
||||
import {AsyncPipe, NgForOf, NgIf, NgTemplateOutlet} from '@angular/common';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {SortFieldPipe} from "../pipe/sort-field.pipe";
|
||||
import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component";
|
||||
import {allFields} from "../_models/metadata/v2/filter-field";
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-filter',
|
||||
@ -44,7 +35,7 @@ import {SortFieldPipe} from "../pipe/sort-field.pipe";
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, NgbCollapse, NgTemplateOutlet, DrawerComponent, NgbTooltip, TypeaheadComponent,
|
||||
ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe]
|
||||
ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe, MetadataBuilderComponent, NgForOf]
|
||||
})
|
||||
export class MetadataFilterComponent implements OnInit {
|
||||
|
||||
@ -66,47 +57,34 @@ export class MetadataFilterComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
|
||||
formatSettings: TypeaheadSettings<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.
|
||||
*/
|
||||
filteringCollapsed: boolean = true;
|
||||
|
||||
filter!: SeriesFilter;
|
||||
libraries: Array<FilterItem<Library>> = [];
|
||||
|
||||
|
||||
readProgressGroup!: FormGroup;
|
||||
sortGroup!: FormGroup;
|
||||
seriesNameGroup!: FormGroup;
|
||||
releaseYearRange!: FormGroup;
|
||||
isAscendingSort: boolean = true;
|
||||
|
||||
updateApplied: number = 0;
|
||||
|
||||
fullyLoaded: boolean = false;
|
||||
filterV2: SeriesFilterV2 | undefined;
|
||||
allSortFields = allSortFields;
|
||||
allFilterFields = allFields;
|
||||
|
||||
get PersonRole(): typeof PersonRole {
|
||||
return PersonRole;
|
||||
handleFilters(filter: SeriesFilterV2) {
|
||||
this.filterV2 = filter;
|
||||
}
|
||||
|
||||
get SortField(): typeof SortField {
|
||||
return SortField;
|
||||
}
|
||||
|
||||
constructor(private libraryService: LibraryService, private metadataService: MetadataService, private utilityService: UtilityService,
|
||||
private collectionTagService: CollectionTagService, public toggleService: ToggleService,
|
||||
private readonly cdRef: ChangeDetectorRef, private filterUtilitySerivce: FilterUtilitiesService) {
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
|
||||
constructor(private utilityService: UtilityService,
|
||||
public toggleService: ToggleService,
|
||||
private filterUtilityService: FilterUtilitiesService) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@ -123,78 +101,6 @@ export class MetadataFilterComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
this.filter = this.filterUtilitySerivce.createSeriesFilter();
|
||||
this.readProgressGroup = new FormGroup({
|
||||
read: new FormControl({value: this.filter.readStatus.read, disabled: this.filterSettings.readProgressDisabled}, []),
|
||||
notRead: new FormControl({value: this.filter.readStatus.notRead, disabled: this.filterSettings.readProgressDisabled}, []),
|
||||
inProgress: new FormControl({value: this.filter.readStatus.inProgress, disabled: this.filterSettings.readProgressDisabled}, []),
|
||||
});
|
||||
|
||||
this.sortGroup = new FormGroup({
|
||||
sortField: new FormControl({value: this.filter.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []),
|
||||
});
|
||||
|
||||
this.seriesNameGroup = new FormGroup({
|
||||
seriesNameQuery: new FormControl({value: this.filter.seriesNameQuery || '', disabled: this.filterSettings.searchNameDisabled}, [])
|
||||
});
|
||||
|
||||
this.releaseYearRange = new FormGroup({
|
||||
min: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999), Validators.maxLength(4), Validators.minLength(4)]),
|
||||
max: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999), Validators.maxLength(4), Validators.minLength(4)])
|
||||
});
|
||||
|
||||
this.readProgressGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(changes => {
|
||||
this.filter.readStatus.read = this.readProgressGroup.get('read')?.value;
|
||||
this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value;
|
||||
this.filter.readStatus.notRead = this.readProgressGroup.get('notRead')?.value;
|
||||
|
||||
let sum = 0;
|
||||
sum += (this.filter.readStatus.read ? 1 : 0);
|
||||
sum += (this.filter.readStatus.inProgress ? 1 : 0);
|
||||
sum += (this.filter.readStatus.notRead ? 1 : 0);
|
||||
|
||||
if (sum === 1) {
|
||||
if (this.filter.readStatus.read) this.readProgressGroup.get('read')?.disable({ emitEvent: false });
|
||||
if (this.filter.readStatus.notRead) this.readProgressGroup.get('notRead')?.disable({ emitEvent: false });
|
||||
if (this.filter.readStatus.inProgress) this.readProgressGroup.get('inProgress')?.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.readProgressGroup.get('read')?.enable({ emitEvent: false });
|
||||
this.readProgressGroup.get('notRead')?.enable({ emitEvent: false });
|
||||
this.readProgressGroup.get('inProgress')?.enable({ emitEvent: false });
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(changes => {
|
||||
if (this.filter.sortOptions == null) {
|
||||
this.filter.sortOptions = {
|
||||
isAscending: this.isAscendingSort,
|
||||
sortField: parseInt(this.sortGroup.get('sortField')?.value, 10)
|
||||
};
|
||||
}
|
||||
this.filter.sortOptions.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.seriesNameGroup.get('seriesNameQuery')?.valueChanges.pipe(
|
||||
map(val => (val || '').trim()),
|
||||
distinctUntilChanged(),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(changes => {
|
||||
this.filter.seriesNameQuery = changes; // TODO: See if we can make this into observable
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.releaseYearRange.valueChanges.pipe(
|
||||
distinctUntilChanged(),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(changes => {
|
||||
this.filter.releaseYearRange = {min: this.releaseYearRange.get('min')?.value, max: this.releaseYearRange.get('max')?.value};
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.loadFromPresetsAndSetup();
|
||||
}
|
||||
|
||||
@ -205,444 +111,80 @@ export class MetadataFilterComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
getPersonsSettings(role: PersonRole) {
|
||||
return this.peopleSettings[role];
|
||||
deepClone(obj: any): any {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (obj instanceof Array) {
|
||||
return obj.map(item => this.deepClone(item));
|
||||
}
|
||||
|
||||
const clonedObj: any = {};
|
||||
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
clonedObj[key] = this.deepClone(obj[key]);
|
||||
} else {
|
||||
clonedObj[key] = obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clonedObj;
|
||||
}
|
||||
|
||||
|
||||
loadFromPresetsAndSetup() {
|
||||
this.fullyLoaded = false;
|
||||
if (this.filterSettings.presets) {
|
||||
this.readProgressGroup.get('read')?.patchValue(this.filterSettings.presets.readStatus.read);
|
||||
this.readProgressGroup.get('notRead')?.patchValue(this.filterSettings.presets.readStatus.notRead);
|
||||
this.readProgressGroup.get('inProgress')?.patchValue(this.filterSettings.presets.readStatus.inProgress);
|
||||
|
||||
if (this.filterSettings.presets.sortOptions) {
|
||||
this.sortGroup.get('sortField')?.setValue(this.filterSettings.presets.sortOptions.sortField);
|
||||
this.isAscendingSort = this.filterSettings.presets.sortOptions.isAscending;
|
||||
if (this.filter.sortOptions) {
|
||||
this.filter.sortOptions.isAscending = this.isAscendingSort;
|
||||
this.filter.sortOptions.sortField = this.filterSettings.presets.sortOptions.sortField;
|
||||
}
|
||||
}
|
||||
this.filterV2 = this.deepClone(this.filterSettings.presetsV2);
|
||||
|
||||
if (this.filterSettings.presets.rating > 0) {
|
||||
this.updateRating(this.filterSettings.presets.rating);
|
||||
}
|
||||
this.sortGroup = new FormGroup({
|
||||
sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []),
|
||||
limitTo: new FormControl(this.filterV2?.limitTo || 0, [])
|
||||
});
|
||||
|
||||
if (this.filterSettings.presets.seriesNameQuery !== '') {
|
||||
this.seriesNameGroup.get('searchNameQuery')?.setValue(this.filterSettings.presets.seriesNameQuery);
|
||||
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.setupFormatTypeahead();
|
||||
this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
|
||||
this.filterV2!.limitTo = parseInt(this.sortGroup.get('limitTo')?.value, 10);
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
if (this.filterSettings.sortDisabled) return;
|
||||
this.isAscendingSort = !this.isAscendingSort;
|
||||
if (this.filter.sortOptions === null) {
|
||||
this.filter.sortOptions = {
|
||||
if (this.filterV2?.sortOptions === null) {
|
||||
this.filterV2.sortOptions = {
|
||||
isAscending: this.isAscendingSort,
|
||||
sortField: SortField.SortName
|
||||
}
|
||||
}
|
||||
|
||||
this.filter.sortOptions.isAscending = this.isAscendingSort;
|
||||
this.filterV2!.sortOptions!.isAscending = this.isAscendingSort;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.filter = this.filterUtilitySerivce.createSeriesFilter();
|
||||
this.readProgressGroup.get('read')?.setValue(true);
|
||||
this.readProgressGroup.get('notRead')?.setValue(true);
|
||||
this.readProgressGroup.get('inProgress')?.setValue(true);
|
||||
this.sortGroup.get('sortField')?.setValue(SortField.SortName);
|
||||
this.isAscendingSort = true;
|
||||
this.seriesNameGroup.get('seriesNameQuery')?.setValue('');
|
||||
this.cdRef.markForCheck();
|
||||
// Apply any presets which will trigger the apply
|
||||
// Apply any presets which will trigger the "apply"
|
||||
this.loadFromPresetsAndSetup();
|
||||
}
|
||||
|
||||
apply() {
|
||||
this.applyFilter.emit({filter: this.filter, isFirst: this.updateApplied === 0});
|
||||
|
||||
this.applyFilter.emit({isFirst: this.updateApplied === 0, filterV2: this.filterV2!});
|
||||
|
||||
if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile && this.updateApplied !== 0) {
|
||||
this.toggleSelected();
|
||||
|
@ -96,7 +96,6 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
|
||||
this.activeEvents += 1;
|
||||
this.cdRef.markForCheck();
|
||||
} else if (event.event === EVENTS.UpdateAvailable) {
|
||||
console.log('event: ', event);
|
||||
this.handleUpdateAvailableClick(event.payload);
|
||||
}
|
||||
});
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Pipe, PipeTransform, SecurityContext } from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
|
||||
@ -7,8 +8,8 @@ import { DomSanitizer } from '@angular/platform-browser';
|
||||
standalone: true
|
||||
})
|
||||
export class SafeHtmlPipe implements PipeTransform {
|
||||
|
||||
constructor(private dom: DomSanitizer) {}
|
||||
private readonly dom: DomSanitizer = inject(DomSanitizer);
|
||||
constructor() {}
|
||||
|
||||
transform(value: string): unknown {
|
||||
return this.dom.sanitize(SecurityContext.HTML, value);
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
|
||||
@ -6,9 +7,8 @@ import { DomSanitizer } from '@angular/platform-browser';
|
||||
standalone: true
|
||||
})
|
||||
export class SafeStylePipe implements PipeTransform {
|
||||
|
||||
constructor(private sanitizer: DomSanitizer){
|
||||
}
|
||||
private readonly sanitizer: DomSanitizer = inject(DomSanitizer);
|
||||
constructor(){}
|
||||
|
||||
transform(style: string) {
|
||||
return this.sanitizer.bypassSecurityTrustStyle(style);
|
||||
|
@ -26,7 +26,7 @@
|
||||
<div class="col-auto ms-2 mt-2" *ngIf="!(readingList?.promoted && !this.isAdmin)">
|
||||
<div class="form-check form-check-inline">
|
||||
<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>
|
||||
@ -52,7 +52,7 @@
|
||||
<span class="read-btn--text">{{t('continue')}}</span>
|
||||
</span>
|
||||
</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>
|
||||
<div class="dropdown-menu" ngbDropdownMenu>
|
||||
<button ngbDropdownItem (click)="read()">
|
||||
@ -106,10 +106,10 @@
|
||||
<ng-container *ngIf="characters$ | async as characters">
|
||||
<div class="row mb-2">
|
||||
<div class="row" *ngIf="characters && characters.length > 0">
|
||||
<h5>Characters</h5>
|
||||
<h5>{{t('characters-title')}}</h5>
|
||||
<app-badge-expander [items]="characters">
|
||||
<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>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
|
@ -12,7 +12,10 @@ import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/acti
|
||||
import {ActionService} from 'src/app/_services/action.service';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
import {ReadingListService} from 'src/app/_services/reading-list.service';
|
||||
import { IndexUpdateEvent, DraggableOrderedListComponent } from '../draggable-ordered-list/draggable-ordered-list.component';
|
||||
import {
|
||||
DraggableOrderedListComponent,
|
||||
IndexUpdateEvent
|
||||
} from '../draggable-ordered-list/draggable-ordered-list.component';
|
||||
import {forkJoin, Observable} from 'rxjs';
|
||||
import {ReaderService} from 'src/app/_services/reader.service';
|
||||
import {LibraryService} from 'src/app/_services/library.service';
|
||||
@ -23,12 +26,18 @@ import { A11yClickDirective } from '../../../shared/a11y-click.directive';
|
||||
import {PersonBadgeComponent} from '../../../shared/person-badge/person-badge.component';
|
||||
import {BadgeExpanderComponent} from '../../../shared/badge-expander/badge-expander.component';
|
||||
import {ReadMoreComponent} from '../../../shared/read-more/read-more.component';
|
||||
import { NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem } from '@ng-bootstrap/ng-bootstrap';
|
||||
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {ImageComponent} from '../../../shared/image/image.component';
|
||||
import { CardActionablesComponent } from '../../../cards/card-item/card-actionables/card-actionables.component';
|
||||
import { NgIf, NgClass, AsyncPipe, DecimalPipe, DatePipe } from '@angular/common';
|
||||
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.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({
|
||||
selector: 'app-reading-list-detail',
|
||||
@ -36,7 +45,7 @@ import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
styleUrls: ['./reading-list-detail.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
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 {
|
||||
items: Array<ReadingListItem> = [];
|
||||
@ -67,7 +76,7 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
private actionService: ActionService, private actionFactoryService: ActionFactoryService, public utilityService: UtilityService,
|
||||
public imageService: ImageService, private accountService: AccountService, private toastr: ToastrService,
|
||||
private confirmService: ConfirmService, private libraryService: LibraryService, private readerService: ReaderService,
|
||||
private readonly cdRef: ChangeDetectorRef) {}
|
||||
private readonly cdRef: ChangeDetectorRef, private filterUtilityService: FilterUtilitiesService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
const listId = this.route.snapshot.paramMap.get('id');
|
||||
@ -93,7 +102,7 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
|
||||
if (readingList == null) {
|
||||
// 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');
|
||||
return;
|
||||
}
|
||||
@ -224,4 +233,8 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
this.accessibilityMode = !this.accessibilityMode;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
goToCharacter(character: Person) {
|
||||
this.filterUtilityService.applyFilter(['all-series'], FilterField.Characters, FilterComparison.Contains, character.id + '');
|
||||
}
|
||||
}
|
||||
|
@ -16,9 +16,9 @@ import { ImportCblModalComponent } from '../../_modals/import-cbl-modal/import-c
|
||||
import { CardItemComponent } from '../../../cards/card-item/card-item.component';
|
||||
import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component';
|
||||
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 {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-reading-lists',
|
||||
|
@ -126,7 +126,6 @@ export class EditReadingListModalComponent implements OnInit {
|
||||
|
||||
updateSelectedIndex(index: number) {
|
||||
this.coverImageIndex = index;
|
||||
console.log(this.coverImageIndex)
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
|
||||
|
@ -15,7 +15,6 @@ import {ImageComponent} from "../shared/image/image.component";
|
||||
import {ReadMoreComponent} from "../shared/read-more/read-more.component";
|
||||
import {PersonBadgeComponent} from "../shared/person-badge/person-badge.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 {MangaFormatIconPipe} from "../pipe/manga-format-icon.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";
|
||||
import {LoadingComponent} from "../shared/loading/loading.component";
|
||||
import {A11yClickDirective} from "../shared/a11y-click.directive";
|
||||
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
@ -60,19 +60,18 @@ export class UserLoginComponent implements OnInit {
|
||||
|
||||
this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => {
|
||||
this.firstTimeFlow = !adminExists;
|
||||
this.isLoaded = true;
|
||||
|
||||
if (this.firstTimeFlow) {
|
||||
this.router.navigateByUrl('registration/register');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoaded = true;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.route.queryParamMap.subscribe(params => {
|
||||
const val = params.get('apiKey');
|
||||
console.log('key: ', val);
|
||||
if (val != null && val.length > 0) {
|
||||
this.login(val);
|
||||
}
|
||||
|
@ -3,8 +3,10 @@ import {CommonModule} from '@angular/common';
|
||||
import {A11yClickDirective} from "../../../shared/a11y-click.directive";
|
||||
import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.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 {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-detail',
|
||||
@ -19,19 +21,17 @@ export class MetadataDetailComponent {
|
||||
@Input({required: true}) tags: Array<any> = [];
|
||||
@Input({required: true}) libraryId!: number;
|
||||
@Input({required: true}) heading!: string;
|
||||
@Input() queryParam: FilterQueryParam = FilterQueryParam.None;
|
||||
@Input() queryParam: FilterField = FilterField.None;
|
||||
@ContentChild('titleTemplate') titleTemplate!: TemplateRef<any>;
|
||||
@ContentChild('itemTemplate') itemTemplate?: TemplateRef<any>;
|
||||
|
||||
private readonly router = inject(Router);
|
||||
private readonly filterUtilitiesService = inject(FilterUtilitiesService);
|
||||
protected readonly TagBadgeCursor = TagBadgeCursor;
|
||||
|
||||
|
||||
goTo(queryParamName: FilterQueryParam, filter: any) {
|
||||
if (queryParamName === FilterQueryParam.None) return;
|
||||
let params: any = {};
|
||||
params[queryParamName] = filter;
|
||||
params[FilterQueryParam.Page] = 1;
|
||||
this.router.navigate(['library', this.libraryId], {queryParams: params});
|
||||
goTo(queryParamName: FilterField, filter: any) {
|
||||
if (queryParamName === FilterField.None) return;
|
||||
this.filterUtilitiesService.applyFilter(['library', this.libraryId], queryParamName, FilterComparison.Equal, filter);
|
||||
}
|
||||
}
|
||||
|
@ -68,9 +68,9 @@ import { CarouselReelComponent } from '../../../carousel/_components/carousel-re
|
||||
import { SeriesMetadataDetailComponent } from '../series-metadata-detail/series-metadata-detail.component';
|
||||
import { ImageComponent } from '../../../shared/image/image.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 {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
|
||||
|
||||
interface RelatedSeriesPair {
|
||||
series: Series;
|
||||
|
@ -26,11 +26,11 @@
|
||||
</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>
|
||||
</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>
|
||||
</app-metadata-detail>
|
||||
|
||||
@ -56,7 +56,7 @@
|
||||
</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>
|
||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
@ -64,55 +64,55 @@
|
||||
|
||||
|
||||
<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>
|
||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</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>
|
||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</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>
|
||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</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>
|
||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</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>
|
||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</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>
|
||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</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>
|
||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</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>
|
||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</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>
|
||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
|
@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges
|
||||
import { Router } from '@angular/router';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
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 { MangaFormat } from '../../../_models/manga-format';
|
||||
import { ReadingList } from '../../../_models/reading-list';
|
||||
@ -21,6 +21,8 @@ import {SeriesInfoCardsComponent} from "../../../cards/series-info-cards/series-
|
||||
import {LibraryType} from "../../../_models/library";
|
||||
import {MetadataDetailComponent} from "../metadata-detail/metadata-detail.component";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||
|
||||
|
||||
@Component({
|
||||
@ -57,7 +59,10 @@ export class SeriesMetadataDetailComponent implements OnChanges {
|
||||
get LibraryType() { return LibraryType; }
|
||||
get MangaFormat() { return MangaFormat; }
|
||||
get TagBadgeCursor() { return TagBadgeCursor; }
|
||||
get FilterQueryParam() { return FilterQueryParam; }
|
||||
|
||||
get FilterField() {
|
||||
return FilterField;
|
||||
}
|
||||
|
||||
get WebLinks() {
|
||||
if (this.seriesMetadata?.webLinks === '') return [];
|
||||
@ -66,7 +71,7 @@ export class SeriesMetadataDetailComponent implements OnChanges {
|
||||
|
||||
constructor(public utilityService: UtilityService,
|
||||
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();
|
||||
}
|
||||
|
||||
handleGoTo(event: {queryParamName: FilterQueryParam, filter: any}) {
|
||||
handleGoTo(event: {queryParamName: FilterField, filter: any}) {
|
||||
this.goTo(event.queryParamName, event.filter);
|
||||
}
|
||||
|
||||
goTo(queryParamName: FilterQueryParam, filter: any) {
|
||||
let params: any = {};
|
||||
params[queryParamName] = filter;
|
||||
params[FilterQueryParam.Page] = 1;
|
||||
this.router.navigate(['library', this.series.libraryId], {queryParams: params});
|
||||
goTo(queryParamName: FilterField, filter: any) {
|
||||
this.filterUtilityService.applyFilter(['library', this.series.libraryId], queryParamName,
|
||||
FilterComparison.Equal, filter);
|
||||
}
|
||||
|
||||
navigate(basePage: string, id: number) {
|
||||
|
@ -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 {ExternalListItemComponent} from "../cards/external-list-item/external-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 {TagBadgeComponent} from "../shared/tag-badge/tag-badge.component";
|
||||
import {LoadingComponent} from "../shared/loading/loading.component";
|
||||
@ -37,6 +36,7 @@ import {
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
|
||||
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
|
||||
|
||||
|
||||
@NgModule({
|
||||
|
@ -228,7 +228,6 @@ export class DownloadService {
|
||||
).pipe(
|
||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||
download((blob, filename) => {
|
||||
console.log('saving: ', filename)
|
||||
this.save(blob, decodeURIComponent(filename));
|
||||
}),
|
||||
tap((d) => this.updateDownloadState(d, downloadType, subtitle)),
|
||||
|
@ -1,7 +1,13 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import { ActivatedRouteSnapshot } from '@angular/router';
|
||||
import {ActivatedRouteSnapshot, Router} from '@angular/router';
|
||||
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
|
||||
@ -45,27 +51,32 @@ export enum FilterQueryParam {
|
||||
})
|
||||
export class FilterUtilitiesService {
|
||||
|
||||
constructor() { }
|
||||
constructor(private metadataService: MetadataService, private router: Router) {
|
||||
}
|
||||
|
||||
applyFilter(page: Array<any>, filter: FilterField, comparison: FilterComparison, value: string) {
|
||||
const dto: SeriesFilterV2 = {
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
updateUrlFromFilterV2(pagination: Pagination, filter: SeriesFilterV2 | undefined) {
|
||||
const params = '?page=' + pagination.currentPage + '&';
|
||||
|
||||
const url = this.urlFromFilter(window.location.href.split('?')[0] + params, filter);
|
||||
const url = this.urlFromFilterV2(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);
|
||||
@ -88,260 +99,133 @@ export class FilterUtilitiesService {
|
||||
* @param filter Filter to build url off
|
||||
* @returns current url with query params added
|
||||
*/
|
||||
urlFromFilter(currentUrl: string, filter: SeriesFilter | undefined) {
|
||||
urlFromFilterV2(currentUrl: string, filter: SeriesFilterV2 | 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}`;
|
||||
return currentUrl + this.encodeSeriesFilter(filter);
|
||||
}
|
||||
|
||||
// sortBy (additional check to not save to url if default case)
|
||||
if (filter.sortOptions && !(filter.sortOptions.sortField === SortField.SortName && filter.sortOptions.isAscending === true)) {
|
||||
params += `&${FilterQueryParam.SortBy}=${filter.sortOptions.sortField},${filter.sortOptions.isAscending}`;
|
||||
encodeSeriesFilter(filter: SeriesFilterV2) {
|
||||
const encodedStatements = this.encodeFilterStatements(filter.statements);
|
||||
const encodedSortOptions = filter.sortOptions ? `sortOptions=${this.encodeSortOptions(filter.sortOptions)}` : '';
|
||||
const encodedLimitTo = `limitTo=${filter.limitTo}`;
|
||||
|
||||
return `${this.encodeName(filter.name)}stmts=${encodedStatements}&${encodedSortOptions}&${encodedLimitTo}&combination=${filter.combination}`;
|
||||
}
|
||||
|
||||
if (filter.rating > 0) {
|
||||
params += `&${FilterQueryParam.Rating}=${filter.rating}`;
|
||||
}
|
||||
|
||||
if (filter.seriesNameQuery !== '') {
|
||||
params += `&${FilterQueryParam.Name}=${encodeURIComponent(filter.seriesNameQuery)}`;
|
||||
}
|
||||
|
||||
return currentUrl + params;
|
||||
}
|
||||
|
||||
private joinFilter(filterProp: Array<any>, key: string) {
|
||||
let params = '';
|
||||
if (filterProp.length > 0) {
|
||||
params += `&${key}=${filterProp.join(',')}`;
|
||||
}
|
||||
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))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const ageRating = snapshot.queryParamMap.get(FilterQueryParam.AgeRating);
|
||||
if (ageRating !== undefined && ageRating !== null) {
|
||||
filter.ageRating = [...filter.ageRating, ...ageRating.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const publicationStatus = snapshot.queryParamMap.get(FilterQueryParam.PublicationStatus);
|
||||
if (publicationStatus !== undefined && publicationStatus !== null) {
|
||||
filter.publicationStatus = [...filter.publicationStatus, ...publicationStatus.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const tags = snapshot.queryParamMap.get(FilterQueryParam.Tags);
|
||||
if (tags !== undefined && tags !== null) {
|
||||
filter.tags = [...filter.tags, ...tags.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const languages = snapshot.queryParamMap.get(FilterQueryParam.Languages);
|
||||
if (languages !== undefined && languages !== null) {
|
||||
filter.languages = [...filter.languages, ...languages.split(',')];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const writers = snapshot.queryParamMap.get(FilterQueryParam.Writers);
|
||||
if (writers !== undefined && writers !== null) {
|
||||
filter.writers = [...filter.writers, ...writers.split(',').map(item => parseInt(item, 10))];
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
const artists = snapshot.queryParamMap.get(FilterQueryParam.Artists);
|
||||
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 (character !== undefined && character !== null) {
|
||||
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 searchNameQuery = snapshot.queryParamMap.get(FilterQueryParam.Name);
|
||||
if (searchNameQuery !== undefined && searchNameQuery !== null && searchNameQuery !== '') {
|
||||
filter.seriesNameQuery = decodeURIComponent(searchNameQuery);
|
||||
anyChanged = true;
|
||||
encodeName(name: string | undefined) {
|
||||
if (name === undefined || name === '') return '';
|
||||
return `name=${encodeURIComponent(name)}&`
|
||||
}
|
||||
|
||||
|
||||
return [filter, false]; // anyChanged. Testing out if having a filter active but keep drawer closed by default works better
|
||||
encodeSortOptions(sortOptions: SortOptions) {
|
||||
return `sortField=${sortOptions.sortField}&isAscending=${sortOptions.isAscending}`;
|
||||
}
|
||||
|
||||
createSeriesFilter(filter?: SeriesFilter) {
|
||||
if (filter !== undefined) return filter;
|
||||
const data: SeriesFilter = {
|
||||
formats: [],
|
||||
libraries: [],
|
||||
genres: [],
|
||||
writers: [],
|
||||
artists: [],
|
||||
penciller: [],
|
||||
inker: [],
|
||||
colorist: [],
|
||||
letterer: [],
|
||||
coverArtist: [],
|
||||
editor: [],
|
||||
publisher: [],
|
||||
character: [],
|
||||
translators: [],
|
||||
collectionTags: [],
|
||||
rating: 0,
|
||||
readStatus: {
|
||||
read: true,
|
||||
inProgress: true,
|
||||
notRead: true
|
||||
encodeFilterStatements(statements: Array<FilterStatement>) {
|
||||
return encodeURIComponent(statements.map(statement => {
|
||||
const encodedComparison = `comparison=${statement.comparison}`;
|
||||
const encodedField = `field=${statement.field}`;
|
||||
const encodedValue = `value=${encodeURIComponent(statement.value)}`;
|
||||
|
||||
return `${encodedComparison}&${encodedField}&${encodedValue}`;
|
||||
}).join(','));
|
||||
}
|
||||
|
||||
filterPresetsFromUrlV2(snapshot: ActivatedRouteSnapshot): SeriesFilterV2 {
|
||||
const filter = this.metadataService.createDefaultFilterDto();
|
||||
if (!window.location.href.includes('?')) return filter;
|
||||
|
||||
const queryParams = snapshot.queryParams;
|
||||
|
||||
if (queryParams.name) {
|
||||
filter.name = queryParams.name;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
decodeSortOptions(encodedSortOptions: string): SortOptions | null {
|
||||
const parts = encodedSortOptions.split('&');
|
||||
const sortFieldPart = parts.find(part => part.startsWith('sortField='));
|
||||
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;
|
||||
|
||||
const comparisonStartToken = parts.find(part => part.startsWith('comparison='));
|
||||
if (!comparisonStartToken) return null;
|
||||
const comparison = parseInt(comparisonStartToken.split('=')[1], 10) as FilterComparison;
|
||||
|
||||
const fieldStartToken = parts.find(part => part.startsWith('field='));
|
||||
if (!fieldStartToken) return null;
|
||||
const field = parseInt(fieldStartToken.split('=')[1], 10) as FilterField;
|
||||
|
||||
const valueStartToken = parts.find(part => part.startsWith('value='));
|
||||
if (!valueStartToken) return null;
|
||||
const value = decodeURIComponent(valueStartToken.split('=')[1]);
|
||||
return {comparison, field, value};
|
||||
}).filter(o => o != null) as FilterStatement[];
|
||||
}
|
||||
|
||||
createSeriesV2Filter(): SeriesFilterV2 {
|
||||
return {
|
||||
combination: FilterCombination.And,
|
||||
statements: [],
|
||||
limitTo: 0,
|
||||
sortOptions: {
|
||||
isAscending: true,
|
||||
sortField: SortField.SortName
|
||||
},
|
||||
sortOptions: null,
|
||||
ageRating: [],
|
||||
tags: [],
|
||||
languages: [],
|
||||
publicationStatus: [],
|
||||
seriesNameQuery: '',
|
||||
releaseYearRange: null
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
createSeriesV2DefaultStatement(): FilterStatement {
|
||||
return {
|
||||
comparison: FilterComparison.Equal,
|
||||
value: '',
|
||||
field: FilterField.SeriesName
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -155,6 +155,7 @@ export class UtilityService {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private isObject(object: any) {
|
||||
return object != null && typeof object === 'object';
|
||||
}
|
||||
|
@ -37,11 +37,6 @@ export class SideNavCompanionBarComponent implements OnInit {
|
||||
*/
|
||||
@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.
|
||||
*/
|
||||
@ -62,8 +57,6 @@ export class SideNavCompanionBarComponent implements OnInit {
|
||||
}
|
||||
|
||||
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
|
||||
this.navService.sideNavCollapsed$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(sideNavCollapsed => {
|
||||
if (this.isFilterOpen && sideNavCollapsed && this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet) {
|
||||
|
@ -23,10 +23,10 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {switchMap} from "rxjs";
|
||||
import {CommonModule} from "@angular/common";
|
||||
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 {FormsModule} from "@angular/forms";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-side-nav',
|
||||
|
@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, Component, DestroyRef, HostListener, inject} fr
|
||||
import {Router} from '@angular/router';
|
||||
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
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 {Series} from 'src/app/_models/series';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
@ -22,8 +22,10 @@ import { FileBreakdownStatsComponent } from '../file-breakdown-stats/file-breakd
|
||||
import {TopReadersComponent} from '../top-readers/top-readers.component';
|
||||
import {StatListComponent} from '../stat-list/stat-list.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 {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-stats',
|
||||
@ -65,7 +67,8 @@ export class ServerStatsComponent {
|
||||
get Breakpoint() { return Breakpoint; }
|
||||
|
||||
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) => {
|
||||
if (data.extra) return this.imageService.getSeriesCoverImage(data.extra.id);
|
||||
return '';
|
||||
@ -114,10 +117,7 @@ export class ServerStatsComponent {
|
||||
ref.componentInstance.items = genres.map(t => t.title);
|
||||
ref.componentInstance.title = this.translocoService.translate('server-stats.genres');
|
||||
ref.componentInstance.clicked = (item: string) => {
|
||||
const params: any = {};
|
||||
params[FilterQueryParam.Genres] = genres.filter(g => g.title === item)[0].id;
|
||||
params[FilterQueryParam.Page] = 1;
|
||||
this.router.navigate(['all-series'], {queryParams: params});
|
||||
this.filterUtilityService.applyFilter(['all-series'], FilterField.Genres, FilterComparison.Contains, genres.filter(g => g.title === item)[0].id + '');
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -128,10 +128,7 @@ export class ServerStatsComponent {
|
||||
ref.componentInstance.items = tags.map(t => t.title);
|
||||
ref.componentInstance.title = this.translocoService.translate('server-stats.tags');
|
||||
ref.componentInstance.clicked = (item: string) => {
|
||||
const params: any = {};
|
||||
params[FilterQueryParam.Tags] = tags.filter(g => g.title === item)[0].id;
|
||||
params[FilterQueryParam.Page] = 1;
|
||||
this.router.navigate(['all-series'], {queryParams: params});
|
||||
this.filterUtilityService.applyFilter(['all-series'], FilterField.Tags, FilterComparison.Contains, tags.filter(g => g.title === item)[0].id + '');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -1,13 +1,5 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
|
||||
import {map, Observable, shareReplay} from 'rxjs';
|
||||
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
|
||||
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';
|
||||
@ -15,7 +7,7 @@ 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 {AsyncPipe, NgIf, PercentPipe} from '@angular/common';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {StatListComponent} from '../stat-list/stat-list.component';
|
||||
import {ReadingActivityComponent} from '../reading-activity/reading-activity.component';
|
||||
@ -47,7 +39,7 @@ export class UserStatsComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
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) {
|
||||
this.isAdmin$ = this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(u => {
|
||||
if (!u) return false;
|
||||
@ -57,8 +49,6 @@ export class UserStatsComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const filter = this.filterService.createSeriesFilter();
|
||||
filter.readStatus = {read: true, notRead: false, inProgress: true};
|
||||
this.memberService.getMember().subscribe(me => {
|
||||
this.userId = me.id;
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -6,7 +6,7 @@
|
||||
{{t('title')}}
|
||||
</h2>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="series"
|
||||
[pagination]="seriesPagination"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
[jumpBarKeys]="jumpbarKeys"
|
||||
|
@ -23,7 +23,7 @@ import { SeriesRemovedEvent } from 'src/app/_models/events/series-removed-event'
|
||||
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { 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 { ActionService } from 'src/app/_services/action.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 { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.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({
|
||||
@ -55,12 +56,12 @@ export class WantToReadComponent implements OnInit, AfterContentChecked {
|
||||
|
||||
isLoading: boolean = true;
|
||||
series: Array<Series> = [];
|
||||
seriesPagination!: Pagination;
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
pagination!: Pagination;
|
||||
filter: SeriesFilterV2 | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
refresh: EventEmitter<void> = new EventEmitter();
|
||||
|
||||
filterActiveCheck!: SeriesFilter;
|
||||
filterActiveCheck!: SeriesFilterV2;
|
||||
filterActive: boolean = false;
|
||||
|
||||
jumpbarKeys: Array<JumpKey> = [];
|
||||
@ -84,7 +85,6 @@ export class WantToReadComponent implements OnInit, AfterContentChecked {
|
||||
}
|
||||
|
||||
collectionTag: any;
|
||||
tagImage: any;
|
||||
|
||||
get ScrollingBlockHeight() {
|
||||
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 jumpbarService: JumpbarService) {
|
||||
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.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.seriesPagination.totalItems--;
|
||||
this.pagination.totalItems--;
|
||||
this.cdRef.markForCheck();
|
||||
this.refresh.emit();
|
||||
}
|
||||
@ -156,7 +163,7 @@ export class WantToReadComponent implements OnInit, AfterContentChecked {
|
||||
|
||||
removeSeries(seriesId: number) {
|
||||
this.series = this.series.filter(s => s.id != seriesId);
|
||||
this.seriesPagination.totalItems--;
|
||||
this.pagination.totalItems--;
|
||||
this.cdRef.markForCheck();
|
||||
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.series = paginatedList.result;
|
||||
this.seriesPagination = paginatedList.pagination;
|
||||
this.pagination = paginatedList.pagination;
|
||||
this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (series: Series) => series.name);
|
||||
this.isLoading = false;
|
||||
window.scrollTo(0, 0);
|
||||
@ -177,28 +184,15 @@ export class WantToReadComponent implements OnInit, AfterContentChecked {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -164,7 +164,7 @@
|
||||
|
||||
"user-holds": {
|
||||
"title": "Scrobble Holds",
|
||||
"description": "This is a user-managed list of Series that will not be scrobbled to upstream providers. You can remove a series at any time and the next Scrobble-able event (reading progress, rating, want to read status) will trigger events."
|
||||
"description": "This is a user-managed list of Series that will not be scrobbled to upstream providers. You can remove a series at any time and the next scrobble-able event (reading progress, rating, want to read status) will trigger events."
|
||||
},
|
||||
|
||||
"theme-manager": {
|
||||
@ -522,6 +522,7 @@
|
||||
},
|
||||
|
||||
"all-series": {
|
||||
"title": "All Series",
|
||||
"series-count": "{{common.series-count}}"
|
||||
},
|
||||
|
||||
@ -1322,7 +1323,8 @@
|
||||
"read": "{{common.read}}",
|
||||
"read-options-alt": "Read options",
|
||||
"incognito-alt": "(Incognito)",
|
||||
"no-data": "Nothing added"
|
||||
"no-data": "Nothing added",
|
||||
"characters-title": "{{series-metadata-detail.characters-title}}"
|
||||
},
|
||||
|
||||
"events-widget": {
|
||||
@ -1435,7 +1437,7 @@
|
||||
"close-reader-alt": "Close Reader"
|
||||
},
|
||||
|
||||
"infinite-reader": {
|
||||
"infinite-scroller": {
|
||||
"continuous-reading-prev-chapter-alt": "Scroll up to move to previous chapter",
|
||||
"continuous-reading-prev-chapter": "Previous Chapter",
|
||||
"continuous-reading-next-chapter-alt": "Scroll up to move to next chapter",
|
||||
@ -1481,8 +1483,14 @@
|
||||
|
||||
"metadata-filter": {
|
||||
"filter-title": "Filter",
|
||||
"sort-by-label": "Sort By",
|
||||
"ascending-alt": "Ascending",
|
||||
"descending-alt": "Descending",
|
||||
"reset": "{{common.reset}}",
|
||||
"apply": "{{common.apply}}",
|
||||
"limit-label": "Limit To",
|
||||
|
||||
"format-label": "Format",
|
||||
"format-tooltip": "This is library agnostic",
|
||||
"libraries-label": "Libraries",
|
||||
"collections-label": "Collections",
|
||||
"genres-label": "Genres",
|
||||
@ -1509,13 +1517,7 @@
|
||||
"series-name-tooltip": "Series name will filter against Name, Sort Name, or Localized Name",
|
||||
"release-label": "Release",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"sort-by-label": "Sort By",
|
||||
"ascending-alt": "Ascending",
|
||||
"descending-alt": "Descending",
|
||||
"reset": "{{common.reset}}",
|
||||
"apply": "{{common.apply}}"
|
||||
|
||||
"max": "Max"
|
||||
},
|
||||
|
||||
"sort-field-pipe": {
|
||||
@ -1688,6 +1690,58 @@
|
||||
"invalid-password-reset-url": "Invalid reset password url"
|
||||
},
|
||||
|
||||
"metadata-builder": {
|
||||
"or": "Match any of the following",
|
||||
"and": "Match all of the following",
|
||||
"add-rule": "Add Rule",
|
||||
"remove-rule": "Remove Row {{num}}"
|
||||
},
|
||||
|
||||
"filter-field-pipe": {
|
||||
"age-rating": "Age Rating",
|
||||
"characters": "Characters",
|
||||
"collection-tags": "Collection Tags",
|
||||
"colorist": "Colorist",
|
||||
"cover-artist": "Cover Artist",
|
||||
"editor": "Editor",
|
||||
"formats": "Formats",
|
||||
"genres": "Genres",
|
||||
"inker": "Inker",
|
||||
"languages": "Languages",
|
||||
"libraries": "Libraries",
|
||||
"letterer": "Letterer",
|
||||
"publication-status": "Publication Status",
|
||||
"penciller": "Penciller",
|
||||
"publisher": "Publisher",
|
||||
"read-progress": "Read Progress",
|
||||
"read-time": "Read Time",
|
||||
"release-year": "Release Year",
|
||||
"series-name": "Series Name",
|
||||
"summary": "Summary",
|
||||
"tags": "Tags",
|
||||
"translators": "Translators",
|
||||
"user-rating": "User Rating",
|
||||
"writers": "Writers"
|
||||
},
|
||||
|
||||
"filter-comparison-pipe": {
|
||||
"begins-with": "Begins with",
|
||||
"contains": "Contains",
|
||||
"equal": "Equal",
|
||||
"greater-than": "Greater than",
|
||||
"greater-than-or-equal": "Greater than or equal",
|
||||
"less-than": "Less than",
|
||||
"less-than-or-equal": "Less than or equal",
|
||||
"matches": "Matches",
|
||||
"does-not-contain": "Does not contain",
|
||||
"not-equal": "Not equal",
|
||||
"ends-with": "Ends with",
|
||||
"is-before": "Is before",
|
||||
"is-after": "Is after",
|
||||
"is-in-last": "Is in last",
|
||||
"is-not-in-last": "Is not in last"
|
||||
},
|
||||
|
||||
"toasts": {
|
||||
"regen-cover": "A job has been enqueued to regenerate the cover image",
|
||||
"no-pages": "There are no pages. Kavita was not able to read this archive.",
|
||||
@ -1763,7 +1817,8 @@
|
||||
"alert-bad-theme": "There is invalid or unsafe css in the theme. Please reach out to your admin to have this corrected. Defaulting to dark theme.",
|
||||
"confirm-library-delete": "Are you sure you want to delete the {{name}} library? You cannot undo this action.",
|
||||
"confirm-library-type-change": "Changing library type will trigger a new scan with different parsing rules and may lead to series being re-created and hence you may loose progress and bookmarks. You should backup before you do this. Are you sure you want to continue?",
|
||||
"confirm-download-size": "The {{entityType}} is {{size}}. Are you sure you want to continue?"
|
||||
"confirm-download-size": "The {{entityType}} is {{size}}. Are you sure you want to continue?",
|
||||
"list-doesnt-exist": "This list doesn't exist"
|
||||
},
|
||||
|
||||
"actionable": {
|
||||
@ -1790,7 +1845,11 @@
|
||||
"view-series": "View Series",
|
||||
"clear": "Clear",
|
||||
"import-cbl": "Import CBL",
|
||||
"read": "Read"
|
||||
"read": "Read",
|
||||
"add-rule-group-and": "Add Rule Group (AND)",
|
||||
"add-rule-group-or": "Add Rule Group (OR)",
|
||||
"remove-rule-group": "Remove Rule Group"
|
||||
|
||||
},
|
||||
|
||||
"preferences": {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user