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

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

View File

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

View File

@ -15,6 +15,10 @@ public static class EasyCacheProfiles
/// Cache the libraries on the server
/// </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";

View File

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

View File

@ -138,19 +138,14 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
/// <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());
return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
}
[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>

View File

@ -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,21 +231,19 @@ public class OpdsController : BaseApiController
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix);
SetFeedId(feed, "collections");
foreach (var tag in tags)
feed.Entries.AddRange(tags.Select(tag => new FeedEntry()
{
feed.Entries.Add(new FeedEntry()
Id = tag.Id.ToString(),
Title = tag.Title,
Summary = tag.Summary,
Links = new List<FeedLink>()
{
Id = tag.Id.ToString(),
Title = tag.Title,
Summary = tag.Summary,
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.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
}
}));
return CreateXmlResult(SerializeXml(feed));
}
@ -315,6 +318,8 @@ public class OpdsController : BaseApiController
Links = new List<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}"),

View File

@ -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));
}

View File

@ -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>

View File

@ -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)
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
@ -10,6 +9,7 @@ using API.Data.Scanner;
using API.DTOs;
using API.DTOs.CollectionTags;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata;
using API.DTOs.ReadingLists;
using API.DTOs.Search;
@ -20,7 +20,9 @@ using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Extensions.QueryExtensions.Filtering;
using API.Helpers;
using API.Helpers.Converters;
using API.Services;
using API.Services.Tasks;
using API.Services.Tasks.Scanner;
@ -95,8 +97,9 @@ public interface ISeriesRepository
/// <returns></returns>
Task AddSeriesModifiers(int userId, IList<SeriesDto> series);
Task<string?> GetSeriesCoverImageAsync(int seriesId);
Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter);
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter);
Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId);
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
@ -118,6 +121,7 @@ public interface ISeriesRepository
Task<SeriesDto?> GetSeriesForMangaFile(int mangaFileId, int userId);
Task<SeriesDto?> GetSeriesForChapter(int chapterId, int userId);
Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetWantToReadForUserV2Async(int userId, UserParams userParams, FilterV2Dto filter);
Task<IList<Series>> GetWantToReadForUserAsync(int userId);
Task<bool> IsSeriesInWantToRead(int userId, int seriesId);
Task<Series?> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
@ -140,6 +144,7 @@ public interface ISeriesRepository
Task<int> GetAverageUserRating(int seriesId, int userId);
Task RemoveFromOnDeck(int seriesId, int userId);
Task ClearOnDeckRemoval(int seriesId, int userId);
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto);
}
public class SeriesRepository : ISeriesRepository
@ -300,6 +305,7 @@ public class SeriesRepository : ISeriesRepository
/// <param name="userParams"></param>
/// <param name="filter"></param>
/// <returns></returns>
[Obsolete("Use GetSeriesDtoForLibraryIdAsync")]
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter)
{
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.None);
@ -605,6 +611,18 @@ public class SeriesRepository : ISeriesRepository
return await query.ToListAsync();
}
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto)
{
var query = await CreateFilteredSearchQueryableV2(userId, filterDto, QueryContext.None);
var retSeries = query
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series)
{
@ -644,7 +662,6 @@ public class SeriesRepository : ISeriesRepository
}
/// <summary>
/// Returns a list of Series that were added, ordered by Created desc
/// </summary>
@ -653,6 +670,7 @@ public class SeriesRepository : ISeriesRepository
/// <param name="userParams">Contains pagination information</param>
/// <param name="filter">Optional filter on query</param>
/// <returns></returns>
[Obsolete("Use GetRecentlyAddedV2")]
public async Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter)
{
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.Dashboard);
@ -666,6 +684,19 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter)
{
var query = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.Dashboard);
var retSeries = query
.OrderByDescending(s => s.Created)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
private IList<MangaFormat> ExtractFilters(int libraryId, int userId, FilterDto filter, ref List<int> userLibraries,
out List<int> allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter,
out bool hasRatingFilter, out bool hasProgressFilter, out IList<int> seriesIds, out bool hasAgeRating, out bool hasTagsFilter,
@ -759,7 +790,7 @@ public class SeriesRepository : ISeriesRepository
/// <param name="userParams">Pagination information</param>
/// <param name="filter">Optional (default null) filter on query</param>
/// <returns></returns>
public async Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter)
public async Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter)
{
var settings = await _context.ServerSetting
.Select(x => x)
@ -780,11 +811,6 @@ public class SeriesRepository : ISeriesRepository
.Select(d => d.SeriesId)
.AsEnumerable();
// var onDeckRemovals = _context.AppUser.Where(u => u.Id == userId)
// .SelectMany(u => u.OnDeckRemovals.Select(d => d.Id))
// .AsEnumerable();
var query = _context.Series
.Where(s => usersSeriesIds.Contains(s.Id))
.Where(s => !onDeckRemovals.Contains(s.Id))
@ -814,6 +840,7 @@ public class SeriesRepository : ISeriesRepository
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, QueryContext queryContext)
{
// NOTE: Why do we even have libraryId when the filter has the actual libraryIds?
// TODO: Remove this method
var userLibraries = await GetUserLibrariesForFilteredQuery(libraryId, userId, queryContext);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
var onlyParentSeries = await _context.AppUserPreferences.Where(u => u.AppUserId == userId)
@ -828,29 +855,47 @@ public class SeriesRepository : ISeriesRepository
var query = _context.Series
.AsNoTracking()
.WhereIf(hasGenresFilter, s => s.Metadata.Genres.Any(g => filter.Genres.Contains(g.Id)))
.WhereIf(hasPeopleFilter, s => s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id)))
.WhereIf(hasCollectionTagFilter,
s => s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id)))
.WhereIf(hasRatingFilter, s => s.Ratings.Any(r => r.Rating >= filter.Rating && r.AppUserId == userId))
.WhereIf(hasProgressFilter, s => seriesIds.Contains(s.Id))
.WhereIf(hasAgeRating, s => filter.AgeRating.Contains(s.Metadata.AgeRating))
.WhereIf(hasTagsFilter, s => s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id)))
.WhereIf(hasLanguageFilter, s => filter.Languages.Contains(s.Metadata.Language))
.WhereIf(hasReleaseYearMinFilter, s => s.Metadata.ReleaseYear >= filter.ReleaseYearRange!.Min)
.WhereIf(hasReleaseYearMaxFilter, s => s.Metadata.ReleaseYear <= filter.ReleaseYearRange!.Max)
.WhereIf(hasPublicationFilter, s => filter.PublicationStatus.Contains(s.Metadata.PublicationStatus))
.WhereIf(hasSeriesNameFilter, s => EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(s.OriginalName!, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(s.LocalizedName!, $"%{filter.SeriesNameQuery}%"))
// This new style can handle any filterComparision coming from the user
.HasLanguage(hasLanguageFilter, FilterComparison.Contains, filter.Languages)
.HasReleaseYear(hasReleaseYearMaxFilter, FilterComparison.LessThanEqual, filter.ReleaseYearRange?.Max)
.HasReleaseYear(hasReleaseYearMinFilter, FilterComparison.GreaterThanEqual, filter.ReleaseYearRange?.Min)
.HasName(hasSeriesNameFilter, FilterComparison.Matches, filter.SeriesNameQuery)
.HasRating(hasRatingFilter, FilterComparison.GreaterThanEqual, filter.Rating, userId)
.HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating)
.HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus)
.HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags)
.HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags)
.HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres)
.HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!)
.HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0)
// This needs different treatment
.HasPeople(hasPeopleFilter, FilterComparison.Contains, allPeopleIds)
.WhereIf(onlyParentSeries,
s => s.RelationOf.Count == 0 || s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel))
.Where(s => userLibraries.Contains(s.LibraryId))
.Where(s => formats.Contains(s.Format));
.Where(s => userLibraries.Contains(s.LibraryId));
if (filter.ReadStatus.InProgress)
{
query = query.HasReadingProgress(hasProgressFilter, FilterComparison.GreaterThan,
0, userId)
.HasReadingProgress(hasProgressFilter, FilterComparison.LessThan,
100, userId);
} else if (filter.ReadStatus.Read)
{
query = query.HasReadingProgress(hasProgressFilter, FilterComparison.Equal,
100, userId);
}
else if (filter.ReadStatus.NotRead)
{
query = query.HasReadingProgress(hasProgressFilter, FilterComparison.Equal,
0, userId);
}
if (userRating.AgeRating != AgeRating.NotApplicable)
{
// this if statement is included in the extension
query = query.RestrictAgainstAgeRestriction(userRating);
}
@ -889,7 +934,109 @@ public class SeriesRepository : ISeriesRepository
};
}
return query;
return query.AsSplitQuery();
}
private async Task<IQueryable<Series>> CreateFilteredSearchQueryableV2(int userId, FilterV2Dto filter, QueryContext queryContext, IQueryable<Series>? query = null)
{
// NOTE: Why do we even have libraryId when the filter has the actual libraryIds?
var userLibraries = await GetUserLibrariesForFilteredQuery(0, userId, queryContext);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
var onlyParentSeries = await _context.AppUserPreferences.Where(u => u.AppUserId == userId)
.Select(u => u.CollapseSeriesRelationships)
.SingleOrDefaultAsync();
query ??= _context.Series
.AsNoTracking();
var filterLibs = new List<int>();
// First setup any FilterField.Libraries in the statements, as these don't have any traditional query statements applied here
if (filter.Statements != null)
{
foreach (var stmt in filter.Statements.Where(stmt => stmt.Field == FilterField.Libraries))
{
filterLibs.Add(int.Parse(stmt.Value));
}
// Remove as filterLibs now has everything
filter.Statements = filter.Statements.Where(stmt => stmt.Field != FilterField.Libraries).ToList();
}
query = BuildFilterQuery(userId, filter, query);
query = query
.WhereIf(userLibraries.Count > 0, s => userLibraries.Contains(s.LibraryId))
.WhereIf(filterLibs.Count > 0, s => filterLibs.Contains(s.LibraryId))
.WhereIf(onlyParentSeries, s =>
s.RelationOf.Count == 0 ||
s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel));
if (userRating.AgeRating != AgeRating.NotApplicable)
{
// this if statement is included in the extension
query = query.RestrictAgainstAgeRestriction(userRating);
}
return ApplyLimit(query
.Sort(filter.SortOptions)
.AsSplitQuery(), filter.LimitTo);
}
private static IQueryable<Series> BuildFilterQuery(int userId, FilterV2Dto filterDto, IQueryable<Series> query)
{
if (filterDto.Statements == null || !filterDto.Statements.Any()) return query;
var queries = filterDto.Statements
.Select(statement => BuildFilterGroup(userId, statement, query))
.ToList();
return filterDto.Combination == FilterCombination.And
? queries.Aggregate((q1, q2) => q1.Intersect(q2))
: queries.Aggregate((q1, q2) => q1.Union(q2));
}
private static IQueryable<Series> ApplyLimit(IQueryable<Series> query, int limit)
{
return limit <= 0 ? query : query.Take(limit);
}
private static IQueryable<Series> BuildFilterGroup(int userId, FilterStatementDto statement, IQueryable<Series> query)
{
var (value, _) = FilterFieldValueConverter.ConvertValue(statement.Field, statement.Value);
return statement.Field switch
{
FilterField.Summary => query.HasSummary(true, statement.Comparison, (string) value),
FilterField.SeriesName => query.HasName(true, statement.Comparison, (string) value),
FilterField.PublicationStatus => query.HasPublicationStatus(true, statement.Comparison,
(IList<PublicationStatus>) value),
FilterField.Languages => query.HasLanguage(true, statement.Comparison, (IList<string>) value),
FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList<AgeRating>) value),
FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId),
FilterField.Tags => query.HasTags(true, statement.Comparison, (IList<int>) value),
FilterField.CollectionTags => query.HasCollectionTags(true, statement.Comparison, (IList<int>) value),
FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Editor => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.CoverArtist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Letterer => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) value),
FilterField.Libraries =>
// This is handled in the code before this as it's handled in a more general, combined manner
query,
FilterField.ReadProgress => query.HasReadingProgress(true, statement.Comparison, (int) value, userId),
FilterField.Formats => query.HasFormat(true, statement.Comparison, (IList<MangaFormat>) value),
FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value),
FilterField.ReadTime => query.HasAverageReadTime(true, statement.Comparison, (int) value),
_ => throw new ArgumentOutOfRangeException()
};
}
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, IQueryable<Series> sQuery)
@ -919,41 +1066,10 @@ public class SeriesRepository : ISeriesRepository
|| EF.Functions.Like(s.LocalizedName!, $"%{filter.SeriesNameQuery}%"))
.Where(s => userLibraries.Contains(s.LibraryId)
&& formats.Contains(s.Format))
.Sort(filter.SortOptions)
.AsNoTracking();
// If no sort options, default to using SortName
filter.SortOptions ??= new SortOptions()
{
IsAscending = true,
SortField = SortField.SortName
};
if (filter.SortOptions.IsAscending)
{
query = filter.SortOptions.SortField switch
{
SortField.SortName => query.OrderBy(s => s.SortName!.ToLower()),
SortField.CreatedDate => query.OrderBy(s => s.Created),
SortField.LastModifiedDate => query.OrderBy(s => s.LastModified),
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
_ => query
};
}
else
{
query = filter.SortOptions.SortField switch
{
SortField.SortName => query.OrderByDescending(s => s.SortName!.ToLower()),
SortField.CreatedDate => query.OrderByDescending(s => s.Created),
SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified),
SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead),
_ => query
};
}
return query;
return query.AsSplitQuery();
}
public async Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId)
@ -1615,6 +1731,7 @@ public class SeriesRepository : ISeriesRepository
.AsEnumerable();
}
[Obsolete("Use GetWantToReadForUserV2Async")]
public async Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter)
{
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
@ -1630,6 +1747,21 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(filteredQuery.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> GetWantToReadForUserV2Async(int userId, UserParams userParams, FilterV2Dto filter)
{
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
var query = _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead)
.Where(s => libraryIds.Contains(s.LibraryId))
.AsSplitQuery()
.AsNoTracking();
var filteredQuery = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.None, query);
return await PagedList<SeriesDto>.CreateAsync(filteredQuery.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize);
}
public async Task<IList<Series>> GetWantToReadForUserAsync(int userId)
{
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();

View File

@ -7,6 +7,7 @@ using API.Constants;
using API.DTOs;
using API.DTOs.Account;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Reader;
using API.DTOs.Scrobbling;
using API.DTOs.SeriesDetail;
@ -53,7 +54,7 @@ public interface IUserRepository
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterDto filter);
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterV2Dto filter);
Task<IEnumerable<AppUserBookmark>> GetAllBookmarksAsync();
Task<AppUserBookmark?> GetBookmarkForPage(int page, int chapterId, int userId);
Task<AppUserBookmark?> GetBookmarkAsync(int bookmarkId);
@ -374,29 +375,71 @@ public class UserRepository : IUserRepository
/// <param name="userId"></param>
/// <param name="filter">Only supports SeriesNameQuery</param>
/// <returns></returns>
public async Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterDto filter)
public async Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterV2Dto filter)
{
var query = _context.AppUserBookmark
.Where(x => x.AppUserId == userId)
.OrderBy(x => x.Created)
.AsNoTracking();
if (string.IsNullOrEmpty(filter.SeriesNameQuery))
var filterStatement = filter.Statements.FirstOrDefault(f => f.Field == FilterField.SeriesName);
if (filterStatement == null || string.IsNullOrWhiteSpace(filterStatement.Value))
return await query
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
var seriesNameQueryNormalized = filter.SeriesNameQuery.ToNormalized();
var queryString = filterStatement.Value.ToNormalized();
var filterSeriesQuery = query.Join(_context.Series, b => b.SeriesId, s => s.Id, (bookmark, series) => new
{
bookmark,
series
})
.Where(o => (EF.Functions.Like(o.series.Name, $"%{filter.SeriesNameQuery}%"))
|| (o.series.OriginalName != null && EF.Functions.Like(o.series.OriginalName, $"%{filter.SeriesNameQuery}%"))
|| (o.series.LocalizedName != null && EF.Functions.Like(o.series.LocalizedName, $"%{filter.SeriesNameQuery}%"))
|| (EF.Functions.Like(o.series.NormalizedName, $"%{seriesNameQueryNormalized}%"))
);
});
switch (filterStatement.Comparison)
{
case FilterComparison.Equal:
filterSeriesQuery = filterSeriesQuery.Where(s => s.series.Name.Equals(queryString)
|| s.series.OriginalName.Equals(queryString)
|| s.series.LocalizedName.Equals(queryString)
|| s.series.SortName.Equals(queryString));
break;
case FilterComparison.BeginsWith:
filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.series.Name, $"{queryString}%")
||EF.Functions.Like(s.series.OriginalName, $"{queryString}%")
|| EF.Functions.Like(s.series.LocalizedName, $"{queryString}%")
|| EF.Functions.Like(s.series.SortName, $"{queryString}%"));
break;
case FilterComparison.EndsWith:
filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.series.Name, $"%{queryString}")
||EF.Functions.Like(s.series.OriginalName, $"%{queryString}")
|| EF.Functions.Like(s.series.LocalizedName, $"%{queryString}")
|| EF.Functions.Like(s.series.SortName, $"%{queryString}"));
break;
case FilterComparison.Matches:
filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.series.Name, $"%{queryString}%")
||EF.Functions.Like(s.series.OriginalName, $"%{queryString}%")
|| EF.Functions.Like(s.series.LocalizedName, $"%{queryString}%")
|| EF.Functions.Like(s.series.SortName, $"%{queryString}%"));
break;
case FilterComparison.NotEqual:
filterSeriesQuery = filterSeriesQuery.Where(s => s.series.Name != queryString
|| s.series.OriginalName != queryString
|| s.series.LocalizedName != queryString
|| s.series.SortName != queryString);
break;
case FilterComparison.NotContains:
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Contains:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
default:
break;
}
query = filterSeriesQuery.Select(o => o.bookmark);

View File

@ -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);

View File

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

View File

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

View File

@ -110,6 +110,55 @@ public static class QueryableExtensions
return condition ? queryable.Where(predicate) : queryable;
}
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)

View File

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

View File

@ -35,7 +35,7 @@ public class StatsService : IStatsService
private readonly IUnitOfWork _unitOfWork;
private readonly 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)
{

View File

@ -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);

View File

@ -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"));
}

View File

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

View File

@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,7 +55,6 @@ export class AccountService {
private messageHub: MessageHubService, private themeService: ThemeService) {
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) {

View File

@ -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) {

View File

@ -1,16 +1,23 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {map, tap} from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Genre } from '../_models/metadata/genre';
import { AgeRating } from '../_models/metadata/age-rating';
import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
import { Language } from '../_models/metadata/language';
import { PublicationStatusDto } from '../_models/metadata/publication-status-dto';
import { Person } from '../_models/metadata/person';
import { Tag } from '../_models/tag';
import { TextResonse } from '../_types/text-response';
import {of, ReplaySubject, switchMap} from 'rxjs';
import {environment} from 'src/environments/environment';
import {Genre} from '../_models/metadata/genre';
import {AgeRating} from '../_models/metadata/age-rating';
import {AgeRatingDto} from '../_models/metadata/age-rating-dto';
import {Language} from '../_models/metadata/language';
import {PublicationStatusDto} from '../_models/metadata/publication-status-dto';
import {Person} from '../_models/metadata/person';
import {Tag} from '../_models/tag';
import {TextResonse} from '../_types/text-response';
import {FilterComparison} from '../_models/metadata/v2/filter-comparison';
import {FilterField} from '../_models/metadata/v2/filter-field';
import {Router} from "@angular/router";
import {SortField} from "../_models/metadata/series-filter";
import {FilterCombination} from "../_models/metadata/v2/filter-combination";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {FilterStatement} from "../_models/metadata/v2/filter-statement";
@Injectable({
providedIn: 'root'
@ -19,10 +26,37 @@ export class MetadataService {
baseUrl = environment.apiUrl;
private currentThemeSource = new ReplaySubject<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 + '';
}
}

View File

@ -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) {

View File

@ -12,12 +12,12 @@ import { PaginatedResult } from '../_models/pagination';
import { Series } from '../_models/series';
import { RelatedSeries } from '../_models/series-detail/related-series';
import { SeriesDetail } from '../_models/series-detail/series-detail';
import { SeriesFilter } from '../_models/metadata/series-filter';
import { SeriesGroup } from '../_models/series-group';
import { SeriesMetadata } from '../_models/metadata/series-metadata';
import { Volume } from '../_models/volume';
import { ImageService } from './image.service';
import { TextResonse } from '../_types/text-response';
import { SeriesFilterV2 } from '../_models/metadata/v2/series-filter-v2';
import {UserReview} from "../_single-module/review-card/user-review";
import {Rating} from "../_models/rating";
import {Recommendation} from "../_models/series-detail/recommendation";
@ -32,26 +32,26 @@ export class SeriesService {
paginatedSeriesForTagsResults: PaginatedResult<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) => {
return this.utilityService.createPaginatedResult(response, this.paginatedResults);
})
map((response: any) => {
return this.utilityService.createPaginatedResult(response, this.paginatedResults);
})
);
}
getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = this.filterUtilityService.createSeriesFilter(filter);
const data = filter || {};
return this.httpClient.post<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 => {

View File

@ -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',

View File

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

View File

@ -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">

View File

@ -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)"

View File

@ -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>

View File

@ -17,7 +17,7 @@ import { UtilityService, KEY_CODES } from 'src/app/shared/_services/utility.serv
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { 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;

View File

@ -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>

View File

@ -20,7 +20,7 @@ import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
import { 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();
}

View File

@ -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',

View File

@ -43,13 +43,13 @@ import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {EntityInfoCardsComponent} from "../entity-info-cards/entity-info-cards.component";
import {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,

View File

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

View File

@ -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();
}

View File

@ -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',

View File

@ -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>

View File

@ -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(',');

View File

@ -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',

View File

@ -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>

View File

@ -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});
}

View File

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

View File

@ -28,7 +28,7 @@ import {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added-
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
import {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();
}

View File

@ -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";

View File

@ -1,36 +1,32 @@
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 { 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';
import { RecentlyAddedItem } from 'src/app/_models/recently-added-item';
import { Series } from 'src/app/_models/series';
import { SortField } from 'src/app/_models/metadata/series-filter';
import { SeriesGroup } from 'src/app/_models/series-group';
import { AccountService } from 'src/app/_services/account.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { MessageHubService, EVENTS } from 'src/app/_services/message-hub.service';
import { SeriesService } from 'src/app/_services/series.service';
import {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, 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';
import {RecentlyAddedItem} from 'src/app/_models/recently-added-item';
import {Series} from 'src/app/_models/series';
import {SortField} from 'src/app/_models/metadata/series-filter';
import {SeriesGroup} from 'src/app/_models/series-group';
import {AccountService} from 'src/app/_services/account.service';
import {ImageService} from 'src/app/_services/image.service';
import {LibraryService} from 'src/app/_services/library.service';
import {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 {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 {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;

View File

@ -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>

View File

@ -1,43 +1,50 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
Component,
DestroyRef,
EventEmitter,
HostListener,
inject,
OnInit
} from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { take } from 'rxjs/operators';
import { BulkSelectionService } from '../cards/bulk-selection.service';
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
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 { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
import { ActionService } from '../_services/action.service';
import { LibraryService } from '../_services/library.service';
import { EVENTS, MessageHubService } from '../_services/message-hub.service';
import { SeriesService } from '../_services/series.service';
import { NavService } from '../_services/nav.service';
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
import { FilterSettings } from '../metadata-filter/filter-settings';
import { JumpKey } from '../_models/jumpbar/jump-key';
import { SeriesRemovedEvent } from '../_models/events/series-removed-event';
import {Title} from '@angular/platform-browser';
import {ActivatedRoute, Router} from '@angular/router';
import {take} from 'rxjs/operators';
import {BulkSelectionService} from '../cards/bulk-selection.service';
import {KEY_CODES, UtilityService} from '../shared/_services/utility.service';
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} 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';
import {EVENTS, MessageHubService} from '../_services/message-hub.service';
import {SeriesService} from '../_services/series.service';
import {NavService} from '../_services/nav.service';
import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service';
import {FilterSettings} from '../metadata-filter/filter-settings';
import {JumpKey} from '../_models/jumpbar/jump-key';
import {SeriesRemovedEvent} from '../_models/events/series-removed-event';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SentenceCasePipe } from '../pipe/sentence-case.pipe';
import { BulkOperationsComponent } from '../cards/bulk-operations/bulk-operations.component';
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 {SentenceCasePipe} from '../pipe/sentence-case.pipe';
import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component';
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 {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;

View File

@ -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";

View File

@ -150,7 +150,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
/**
* Debug mode. Will show extra information. Use bitwise (|) operators between different modes to enable different output
*/
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
*/

View File

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

View File

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

View File

@ -0,0 +1,38 @@
<form [formGroup]="formGroup">
<div class="row g-0">
<div class="col-md-3 me-2 col-10 mb-2">
<ng-container *ngIf="formGroup.get('input') as control">
<select class="form-select me-2" formControlName="input">
<option *ngFor="let field of availableFields" [value]="field">{{field | filterField}}</option>
</select>
</ng-container>
</div>
<div class="col-md-2 me-2 col-10 mb-2">
<select class="col-auto form-select" formControlName="comparison">
<option *ngFor="let comparison of validComparisons$ | async" [value]="comparison">{{comparison | filterComparison}}</option>
</select>
</div>
<div class="col-md-4 col-10 mb-2">
<ng-container *ngIf="predicateType$ | async as predicateType">
<ng-container [ngSwitch]="predicateType">
<ng-container *ngSwitchCase="PredicateType.Text">
<input type="text" class="form-control me-2" autocomplete="true" formControlName="filterValue">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Number">
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Dropdown">
<select class="col-auto form-select me-2" formControlName="filterValue">
<option *ngFor="let option of dropdownOptions$ | async" [value]="option.value">{{option.title}}</option>
</select>
</ng-container>
</ng-container>
</ng-container>
</div>
<ng-content #removeBtn></ng-content>
</div>
</form>

View File

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

View File

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

View File

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

View File

@ -1,24 +1,6 @@
import { SeriesFilter } from "../_models/metadata/series-filter";
import { SeriesFilterV2 } from "../_models/metadata/v2/series-filter-v2";
export class FilterSettings {
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;
}

View File

@ -19,357 +19,39 @@
</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>
</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>
<app-metadata-builder [filter]="filterV2!" [availableFilterFields]="allFilterFields" (update)="handleFilters($event)"></app-metadata-builder>
</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>
<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 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'">&#9733;</span>
</ng-template>
</ngb-rating>
</form>
</div>
<div class="col-md-2 me-3">
<label for="age-rating" class="form-label">{{t('age-rating-label')}}</label>
<app-typeahead (selectedData)="updateAgeRating($event)" [settings]="ageRatingSettings" [reset]="resetTypeaheads" [disabled]="filterSettings.ageRatingDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
<div class="col-md-2 me-3">
<label for="languages" class="form-label">{{t('language-label')}}</label>
<app-typeahead (selectedData)="updateLanguages($event)" [settings]="languageSettings"
[reset]="resetTypeaheads" [disabled]="filterSettings.languageDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
<div class="col-md-2 me-3">
<label for="publication-status" class="form-label">{{t('publication-status-label')}}</label>
<app-typeahead (selectedData)="updatePublicationStatus($event)" [settings]="publicationStatusSettings"
[reset]="resetTypeaheads" [disabled]="filterSettings.publicationStatusDisabled">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}}
</ng-template>
</app-typeahead>
</div>
<div class="col-md-2 me-3"></div>
</div>
<div class="row justify-content-center g-0">
<div class="col-md-2 me-3">
<form [formGroup]="seriesNameGroup">
<div class="mb-3">
<label for="series-name" class="form-label me-1">{{t('series-name-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="seriesNameFilterTooltip" role="button" tabindex="0"></i>
<span class="visually-hidden" id="filter-series-name-help"><ng-container [ngTemplateOutlet]="seriesNameFilterTooltip"></ng-container></span>
<ng-template #seriesNameFilterTooltip>{{t('series-name-tooltip')}}</ng-template>
<input type="text" id="series-name" formControlName="seriesNameQuery" class="form-control" aria-describedby="filter-series-name-help" (keyup.enter)="apply()">
</div>
</form>
</div>
<div class="col-md-2 me-3">
<form [formGroup]="releaseYearRange" class="d-flex justify-content-between">
<div class="mb-3">
<label for="release-year-min" class="form-label">{{t('release-label')}}</label>
<input type="number" id="release-year-min" formControlName="min" class="form-control custom-number" style="width: 62px" [placeholder]="t('min')" (keyup.enter)="apply()">
</div>
<div style="margin-top: 37px !important;">
<i class="fa-solid fa-minus" aria-hidden="true"></i>
</div>
<div class="mb-3" style="margin-top: 0.5rem">
<label for="release-year-max" class="form-label"><span class="visually-hidden">Max</span></label>
<input type="number" id="release-year-max" formControlName="max" class="form-control custom-number" style="width: 62px" [placeholder]="t('max')" (keyup.enter)="apply()">
</div>
</form>
</div>
<div class="col-md-2 me-3">
<form [formGroup]="sortGroup">
<div class="mb-3">
</div>
<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>
<div class="col-md-2 me-3 mt-4">
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
</div>
<div class="col-md-2 me-3 mt-4">
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
</div>
</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>
<div class="col-md-2 me-3 mt-4">
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
</div>
</div>
</form>
</div>
</ng-template>
</ng-container>

View File

@ -2,40 +2,31 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChild, DestroyRef,
ContentChild,
DestroyRef,
EventEmitter,
inject,
Input,
OnInit,
Output
} from '@angular/core';
import { FormControl, FormGroup, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms';
import { NgbCollapse, NgbTooltip, NgbRating } from '@ng-bootstrap/ng-bootstrap';
import { distinctUntilChanged, forkJoin, map, Observable, of, ReplaySubject } from 'rxjs';
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
import { Breakpoint, UtilityService } from '../shared/_services/utility.service';
import { TypeaheadSettings } from '../typeahead/_models/typeahead-settings';
import { CollectionTag } from '../_models/collection-tag';
import { Genre } from '../_models/metadata/genre';
import { Library } from '../_models/library';
import { MangaFormat } from '../_models/manga-format';
import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
import { Language } from '../_models/metadata/language';
import { PublicationStatusDto } from '../_models/metadata/publication-status-dto';
import { Person, PersonRole } from '../_models/metadata/person';
import { FilterEvent, FilterItem, mangaFormatFilters, SeriesFilter, SortField } from '../_models/metadata/series-filter';
import { Tag } from '../_models/tag';
import { CollectionTagService } from '../_services/collection-tag.service';
import { LibraryService } from '../_services/library.service';
import { MetadataService } from '../_services/metadata.service';
import { ToggleService } from '../_services/toggle.service';
import { FilterSettings } from './filter-settings';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {NgbCollapse, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service';
import {Breakpoint, UtilityService} from '../shared/_services/utility.service';
import {Library} from '../_models/library';
import {allSortFields, FilterEvent, FilterItem, SortField} from '../_models/metadata/series-filter';
import {ToggleService} from '../_services/toggle.service';
import {FilterSettings} from './filter-settings';
import {SeriesFilterV2} from '../_models/metadata/v2/series-filter-v2';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { TypeaheadComponent } from '../typeahead/_components/typeahead.component';
import { DrawerComponent } from '../shared/drawer/drawer.component';
import { NgIf, NgTemplateOutlet, AsyncPipe } from '@angular/common';
import {TypeaheadComponent} from '../typeahead/_components/typeahead.component';
import {DrawerComponent} from '../shared/drawer/drawer.component';
import {AsyncPipe, NgForOf, NgIf, NgTemplateOutlet} from '@angular/common';
import {TranslocoModule} from "@ngneat/transloco";
import {SortFieldPipe} from "../pipe/sort-field.pipe";
import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component";
import {allFields} from "../_models/metadata/v2/filter-field";
@Component({
selector: 'app-metadata-filter',
@ -44,7 +35,7 @@ import {SortFieldPipe} from "../pipe/sort-field.pipe";
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgbCollapse, NgTemplateOutlet, DrawerComponent, NgbTooltip, TypeaheadComponent,
ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe]
ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe, MetadataBuilderComponent, NgForOf]
})
export class MetadataFilterComponent implements OnInit {
@ -66,47 +57,34 @@ export class MetadataFilterComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
formatSettings: TypeaheadSettings<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);
}
if (this.filterSettings.presets.seriesNameQuery !== '') {
this.seriesNameGroup.get('searchNameQuery')?.setValue(this.filterSettings.presets.seriesNameQuery);
}
}
this.setupFormatTypeahead();
this.cdRef.markForCheck();
forkJoin([
this.setupLibraryTypeahead(),
this.setupCollectionTagTypeahead(),
this.setupAgeRatingSettings(),
this.setupPublicationStatusSettings(),
this.setupTagSettings(),
this.setupLanguageSettings(),
this.setupGenreTypeahead(),
this.setupPersonTypeahead(),
]).subscribe(results => {
this.fullyLoaded = true;
this.resetTypeaheads.next(false); // Pass false to ensure we reset to the preset and not to an empty typeahead
this.cdRef.markForCheck();
this.apply();
this.sortGroup = new FormGroup({
sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []),
limitTo: new FormControl(this.filterV2?.limitTo || 0, [])
});
this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
if (this.filterV2?.sortOptions === null) {
this.filterV2.sortOptions = {
isAscending: this.isAscendingSort,
sortField: parseInt(this.sortGroup.get('sortField')?.value, 10)
};
}
this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
this.filterV2!.limitTo = parseInt(this.sortGroup.get('limitTo')?.value, 10);
this.cdRef.markForCheck();
});
this.fullyLoaded = true;
this.cdRef.markForCheck();
this.apply();
}
setupFormatTypeahead() {
this.formatSettings.minCharacters = 0;
this.formatSettings.multiple = true;
this.formatSettings.id = 'format';
this.formatSettings.unique = true;
this.formatSettings.addIfNonExisting = false;
this.formatSettings.fetchFn = (filter: string) => of(mangaFormatFilters).pipe(map(items => this.formatSettings.compareFn(items, filter)));
this.formatSettings.compareFn = (options: FilterItem<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();

View File

@ -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);
}
});

View File

@ -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);

View File

@ -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);

View File

@ -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>

View File

@ -1,34 +1,43 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { LibraryType } from 'src/app/_models/library';
import { MangaFormat } from 'src/app/_models/manga-format';
import { ReadingList, ReadingListItem } from 'src/app/_models/reading-list';
import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
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 { forkJoin, Observable } from 'rxjs';
import { ReaderService } from 'src/app/_services/reader.service';
import { LibraryService } from 'src/app/_services/library.service';
import { Person } from 'src/app/_models/metadata/person';
import { ReadingListItemComponent } from '../reading-list-item/reading-list-item.component';
import { LoadingComponent } from '../../../shared/loading/loading.component';
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 { 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 {ActivatedRoute, Router} from '@angular/router';
import {ToastrService} from 'ngx-toastr';
import {take} from 'rxjs/operators';
import {ConfirmService} from 'src/app/shared/confirm.service';
import {UtilityService} from 'src/app/shared/_services/utility.service';
import {LibraryType} from 'src/app/_models/library';
import {MangaFormat} from 'src/app/_models/manga-format';
import {ReadingList, ReadingListItem} from 'src/app/_models/reading-list';
import {AccountService} from 'src/app/_services/account.service';
import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
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 {
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';
import {Person} from 'src/app/_models/metadata/person';
import {ReadingListItemComponent} from '../reading-list-item/reading-list-item.component';
import {LoadingComponent} from '../../../shared/loading/loading.component';
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, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap';
import {ImageComponent} from '../../../shared/image/image.component';
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 + '');
}
}

View File

@ -16,9 +16,9 @@ import { ImportCblModalComponent } from '../../_modals/import-cbl-modal/import-c
import { CardItemComponent } from '../../../cards/card-item/card-item.component';
import { 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',

View File

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

View File

@ -15,7 +15,6 @@ import {ImageComponent} from "../shared/image/image.component";
import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {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: [

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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>

View File

@ -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) {

View File

@ -14,7 +14,6 @@ import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.co
import {ExternalSeriesCardComponent} from "../cards/external-series-card/external-series-card.component";
import {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({

View File

@ -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)),

View File

@ -1,347 +1,231 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router';
import { Pagination } from 'src/app/_models/pagination';
import { SeriesFilter, SortField } from 'src/app/_models/metadata/series-filter';
import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, Router} from '@angular/router';
import {Pagination} from 'src/app/_models/pagination';
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
*/
export enum FilterQueryParam {
Format = 'format',
Genres = 'genres',
AgeRating = 'ageRating',
PublicationStatus = 'publicationStatus',
Tags = 'tags',
Languages = 'languages',
CollectionTags = 'collectionTags',
Libraries = 'libraries',
Writers = 'writers',
Artists = 'artists',
Character = 'character',
Colorist = 'colorist',
CoverArtists = 'coverArtists',
Editor = 'editor',
Inker = 'inker',
Letterer = 'letterer',
Penciller = 'penciller',
Publisher = 'publisher',
Translator = 'translators',
ReadStatus = 'readStatus',
SortBy = 'sortBy',
Rating = 'rating',
Name = 'name',
/**
* This is a pagination control
*/
Page = 'page',
/**
* Special case for the UI. Does not trigger filtering
*/
None = 'none'
Format = 'format',
Genres = 'genres',
AgeRating = 'ageRating',
PublicationStatus = 'publicationStatus',
Tags = 'tags',
Languages = 'languages',
CollectionTags = 'collectionTags',
Libraries = 'libraries',
Writers = 'writers',
Artists = 'artists',
Character = 'character',
Colorist = 'colorist',
CoverArtists = 'coverArtists',
Editor = 'editor',
Inker = 'inker',
Letterer = 'letterer',
Penciller = 'penciller',
Publisher = 'publisher',
Translator = 'translators',
ReadStatus = 'readStatus',
SortBy = 'sortBy',
Rating = 'rating',
Name = 'name',
/**
* This is a pagination control
*/
Page = 'page',
/**
* Special case for the UI. Does not trigger filtering
*/
None = 'none'
}
@Injectable({
providedIn: 'root'
providedIn: 'root'
})
export class FilterUtilitiesService {
constructor() { }
/**
* Updates the window location with a custom url based on filter and pagination objects
* @param pagination
* @param filter
*/
updateUrlFromFilter(pagination: Pagination, filter: SeriesFilter | undefined) {
const params = '?page=' + pagination.currentPage;
const url = this.urlFromFilter(window.location.href.split('?')[0] + params, filter);
window.history.replaceState(window.location.href, '', this.replacePaginationOnUrl(url, pagination));
}
/**
* Patches the page query param in the window location.
* @param pagination
*/
updateUrlFromPagination(pagination: Pagination) {
window.history.replaceState(window.location.href, '', this.replacePaginationOnUrl(window.location.href, pagination));
}
private replacePaginationOnUrl(url: string, pagination: Pagination) {
return url.replace(/page=\d+/i, 'page=' + pagination.currentPage);
}
/**
* Will fetch current page from route if present
* @param ActivatedRouteSnapshot to fetch page from. Must be from component else may get stale data
* @param itemsPerPage If you want pagination, pass non-zero number
* @returns A default pagination object
*/
pagination(snapshot: ActivatedRouteSnapshot, itemsPerPage: number = 0): Pagination {
return {currentPage: parseInt(snapshot.queryParamMap.get('page') || '1', 10), itemsPerPage, totalItems: 0, totalPages: 1};
}
/**
* Returns the current url with query params for the filter
* @param currentUrl Full url, with ?page=1 as a minimum
* @param filter Filter to build url off
* @returns current url with query params added
*/
urlFromFilter(currentUrl: string, filter: SeriesFilter | undefined) {
if (filter === undefined) return currentUrl;
let params = '';
params += this.joinFilter(filter.formats, FilterQueryParam.Format);
params += this.joinFilter(filter.genres, FilterQueryParam.Genres);
params += this.joinFilter(filter.ageRating, FilterQueryParam.AgeRating);
params += this.joinFilter(filter.publicationStatus, FilterQueryParam.PublicationStatus);
params += this.joinFilter(filter.tags, FilterQueryParam.Tags);
params += this.joinFilter(filter.languages, FilterQueryParam.Languages);
params += this.joinFilter(filter.collectionTags, FilterQueryParam.CollectionTags);
params += this.joinFilter(filter.libraries, FilterQueryParam.Libraries);
params += this.joinFilter(filter.writers, FilterQueryParam.Writers);
params += this.joinFilter(filter.artists, FilterQueryParam.Artists);
params += this.joinFilter(filter.character, FilterQueryParam.Character);
params += this.joinFilter(filter.colorist, FilterQueryParam.Colorist);
params += this.joinFilter(filter.coverArtist, FilterQueryParam.CoverArtists);
params += this.joinFilter(filter.editor, FilterQueryParam.Editor);
params += this.joinFilter(filter.inker, FilterQueryParam.Inker);
params += this.joinFilter(filter.letterer, FilterQueryParam.Letterer);
params += this.joinFilter(filter.penciller, FilterQueryParam.Penciller);
params += this.joinFilter(filter.publisher, FilterQueryParam.Publisher);
params += this.joinFilter(filter.translators, FilterQueryParam.Translator);
// readStatus (we need to do an additonal check as there is a default case)
if (filter.readStatus && filter.readStatus.inProgress !== true && filter.readStatus.notRead !== true && filter.readStatus.read !== true) {
params += `&${FilterQueryParam.ReadStatus}=${filter.readStatus.inProgress},${filter.readStatus.notRead},${filter.readStatus.read}`;
constructor(private metadataService: MetadataService, private router: Router) {
}
// 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}`;
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));
}
if (filter.rating > 0) {
params += `&${FilterQueryParam.Rating}=${filter.rating}`;
/**
* Updates the window location with a custom url based on filter and pagination objects
* @param pagination
* @param filter
*/
updateUrlFromFilterV2(pagination: Pagination, filter: SeriesFilterV2 | undefined) {
const params = '?page=' + pagination.currentPage + '&';
const url = this.urlFromFilterV2(window.location.href.split('?')[0] + params, filter);
window.history.replaceState(window.location.href, '', this.replacePaginationOnUrl(url, pagination));
}
if (filter.seriesNameQuery !== '') {
params += `&${FilterQueryParam.Name}=${encodeURIComponent(filter.seriesNameQuery)}`;
private replacePaginationOnUrl(url: string, pagination: Pagination) {
return url.replace(/page=\d+/i, 'page=' + pagination.currentPage);
}
return currentUrl + params;
}
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;
/**
* Will fetch current page from route if present
* @param ActivatedRouteSnapshot to fetch page from. Must be from component else may get stale data
* @param itemsPerPage If you want pagination, pass non-zero number
* @returns A default pagination object
*/
pagination(snapshot: ActivatedRouteSnapshot, itemsPerPage: number = 0): Pagination {
return {currentPage: parseInt(snapshot.queryParamMap.get('page') || '1', 10), itemsPerPage, totalItems: 0, totalPages: 1};
}
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;
/**
* Returns the current url with query params for the filter
* @param currentUrl Full url, with ?page=1 as a minimum
* @param filter Filter to build url off
* @returns current url with query params added
*/
urlFromFilterV2(currentUrl: string, filter: SeriesFilterV2 | undefined) {
if (filter === undefined) return currentUrl;
return currentUrl + this.encodeSeriesFilter(filter);
}
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;
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}`;
}
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;
encodeName(name: string | undefined) {
if (name === undefined || name === '') return '';
return `name=${encodeURIComponent(name)}&`
}
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;
encodeSortOptions(sortOptions: SortOptions) {
return `sortField=${sortOptions.sortField}&isAscending=${sortOptions.isAscending}`;
}
const languages = snapshot.queryParamMap.get(FilterQueryParam.Languages);
if (languages !== undefined && languages !== null) {
filter.languages = [...filter.languages, ...languages.split(',')];
anyChanged = 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(','));
}
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;
}
filterPresetsFromUrlV2(snapshot: ActivatedRouteSnapshot): SeriesFilterV2 {
const filter = this.metadataService.createDefaultFilterDto();
if (!window.location.href.includes('?')) return filter;
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 queryParams = snapshot.queryParams;
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])
if (queryParams.name) {
filter.name = queryParams.name;
}
anyChanged = true;
}
const fullUrl = window.location.href.split('?')[1];
const stmtsStartIndex = fullUrl.indexOf('stmts=');
let endIndex = fullUrl.indexOf('&sortOptions=');
if (endIndex < 0) {
endIndex = fullUrl.indexOf('&limitTo=');
}
if (stmtsStartIndex !== -1 && endIndex !== -1) {
const stmtsEncoded = fullUrl.substring(stmtsStartIndex + 6, endIndex);
filter.statements = this.decodeFilterStatements(stmtsEncoded);
}
if (queryParams.sortOptions) {
const sortOptions = this.decodeSortOptions(queryParams.sortOptions);
if (sortOptions) {
filter.sortOptions = sortOptions;
}
}
if (queryParams.limitTo) {
filter.limitTo = parseInt(queryParams.limitTo, 10);
}
if (queryParams.combination) {
filter.combination = parseInt(queryParams.combination, 10) as FilterCombination;
}
return filter;
}
const searchNameQuery = snapshot.queryParamMap.get(FilterQueryParam.Name);
if (searchNameQuery !== undefined && searchNameQuery !== null && searchNameQuery !== '') {
filter.seriesNameQuery = decodeURIComponent(searchNameQuery);
anyChanged = true;
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;
return [filter, false]; // anyChanged. Testing out if having a filter active but keep drawer closed by default works better
}
const comparisonStartToken = parts.find(part => part.startsWith('comparison='));
if (!comparisonStartToken) return null;
const comparison = parseInt(comparisonStartToken.split('=')[1], 10) as FilterComparison;
createSeriesFilter(filter?: SeriesFilter) {
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
},
sortOptions: null,
ageRating: [],
tags: [],
languages: [],
publicationStatus: [],
seriesNameQuery: '',
releaseYearRange: null
};
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
},
};
}
createSeriesV2DefaultStatement(): FilterStatement {
return {
comparison: FilterComparison.Equal,
value: '',
field: FilterField.SeriesName
}
}
return data;
}
}

View File

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

View File

@ -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) {

View File

@ -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',

View File

@ -1,29 +1,31 @@
import {ChangeDetectionStrategy, Component, DestroyRef, HostListener, inject} from '@angular/core';
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 { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { Series } from 'src/app/_models/series';
import { ImageService } from 'src/app/_services/image.service';
import { MetadataService } from 'src/app/_services/metadata.service';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { PieDataItem } from '../../_models/pie-data-item';
import { ServerStatistics } from '../../_models/server-statistics';
import { GenericListModalComponent } from '../_modals/generic-list-modal/generic-list-modal.component';
import {Router} from '@angular/router';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {map, Observable, ReplaySubject, shareReplay} from 'rxjs';
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';
import {MetadataService} from 'src/app/_services/metadata.service';
import {StatisticsService} from 'src/app/_services/statistics.service';
import {PieDataItem} from '../../_models/pie-data-item';
import {ServerStatistics} from '../../_models/server-statistics';
import {GenericListModalComponent} from '../_modals/generic-list-modal/generic-list-modal.component';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { BytesPipe } from '../../../pipe/bytes.pipe';
import { TimeDurationPipe } from '../../../pipe/time-duration.pipe';
import { CompactNumberPipe } from '../../../pipe/compact-number.pipe';
import { DayBreakdownComponent } from '../day-breakdown/day-breakdown.component';
import { ReadingActivityComponent } from '../reading-activity/reading-activity.component';
import { PublicationStatusStatsComponent } from '../publication-status-stats/publication-status-stats.component';
import { FileBreakdownStatsComponent } from '../file-breakdown-stats/file-breakdown-stats.component';
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 {BytesPipe} from '../../../pipe/bytes.pipe';
import {TimeDurationPipe} from '../../../pipe/time-duration.pipe';
import {CompactNumberPipe} from '../../../pipe/compact-number.pipe';
import {DayBreakdownComponent} from '../day-breakdown/day-breakdown.component';
import {ReadingActivityComponent} from '../reading-activity/reading-activity.component';
import {PublicationStatusStatsComponent} from '../publication-status-stats/publication-status-stats.component';
import {FileBreakdownStatsComponent} from '../file-breakdown-stats/file-breakdown-stats.component';
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 {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 + '');
};
});
}

View File

@ -1,25 +1,17 @@
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';
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 {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {map, Observable, shareReplay} from 'rxjs';
import {UserReadStatistics} from 'src/app/statistics/_models/user-read-statistics';
import {StatisticsService} from 'src/app/_services/statistics.service';
import {ReadHistoryEvent} from '../../_models/read-history-event';
import {MemberService} from 'src/app/_services/member.service';
import {AccountService} from 'src/app/_services/account.service';
import {PieDataItem} from '../../_models/pie-data-item';
import {LibraryService} from 'src/app/_services/library.service';
import {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';
import { UserStatsInfoCardsComponent } from '../user-stats-info-cards/user-stats-info-cards.component';
import {StatListComponent} from '../stat-list/stat-list.component';
import {ReadingActivityComponent} from '../reading-activity/reading-activity.component';
import {UserStatsInfoCardsComponent} from '../user-stats-info-cards/user-stats-info-cards.component';
import {TranslocoModule} from "@ngneat/transloco";
@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();

View File

@ -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"

View File

@ -23,7 +23,7 @@ import { SeriesRemovedEvent } from 'src/app/_models/events/series-removed-event'
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { 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;
// }
}
}

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