Must Contains Filter (#2249)

* Removed docker-compose.yml as it's not used and may confuse users.

* Added ability to delete single collections from card actions. Updated transloco library which fixes older iOS browsers not being able to load Kavita.

* Added a Must Contains comparison which will make so all values must exist.

* Fixed up multiselect dropdowns not reseting value when changing filter field
This commit is contained in:
Joe Milazzo 2023-09-01 14:19:51 -07:00 committed by GitHub
parent b5540e58e0
commit 072fadf1de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 210 additions and 87 deletions

View File

@ -32,7 +32,7 @@ public class CollectionController : BaseApiController
} }
/// <summary> /// <summary>
/// Return a list of all collection tags on the server /// Return a list of all collection tags on the server for the logged in user.
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[HttpGet] [HttpGet]
@ -130,7 +130,6 @@ public class CollectionController : BaseApiController
{ {
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updateSeriesForTagDto.Tag.Id, CollectionTagIncludes.SeriesMetadata); var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updateSeriesForTagDto.Tag.Id, CollectionTagIncludes.SeriesMetadata);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
tag.SeriesMetadatas ??= new List<SeriesMetadata>();
if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove)) if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove))
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated")); return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated"));
@ -142,4 +141,29 @@ public class CollectionController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
} }
/// <summary>
/// Removes the collection tag from all Series it was attached to
/// </summary>
/// <param name="tagId"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete]
public async Task<ActionResult> DeleteTag(int tagId)
{
try
{
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
if (await _collectionService.DeleteTag(tag))
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-deleted"));
}
catch (Exception)
{
await _unitOfWork.RollbackAsync();
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
} }

View File

@ -11,15 +11,20 @@ public enum FilterComparison
LessThan = 3, LessThan = 3,
LessThanEqual = 4, LessThanEqual = 4,
/// <summary> /// <summary>
/// /// value is within any of the series. This is inheritently an OR, even if combinator is an AND
/// </summary> /// </summary>
/// <remarks>Only works with IList</remarks> /// <remarks>Only works with IList</remarks>
Contains = 5, Contains = 5,
/// <summary> /// <summary>
/// value is within All of the series. This is an AND, even if combinator ORs the different statements
/// </summary>
/// <remarks>Only works with IList</remarks>
MustContains = 6,
/// <summary>
/// Performs a LIKE %value% /// Performs a LIKE %value%
/// </summary> /// </summary>
Matches = 6, Matches = 7,
NotContains = 7, NotContains = 8,
/// <summary> /// <summary>
/// Not Equal to /// Not Equal to
/// </summary> /// </summary>

View File

@ -976,14 +976,15 @@ public class SeriesRepository : ISeriesRepository
{ {
foreach (var stmt in filter.Statements.Where(stmt => stmt.Field == FilterField.Libraries)) foreach (var stmt in filter.Statements.Where(stmt => stmt.Field == FilterField.Libraries))
{ {
var libIds = stmt.Value.Split(',').Select(int.Parse);
if (stmt.Comparison is FilterComparison.Equal or FilterComparison.Contains) if (stmt.Comparison is FilterComparison.Equal or FilterComparison.Contains)
{ {
filterIncludeLibs.AddRange(stmt.Value.Split(',').Select(int.Parse)); filterIncludeLibs.AddRange(libIds);
} }
else else
{ {
filterExcludeLibs.AddRange(stmt.Value.Split(',').Select(int.Parse)); filterExcludeLibs.AddRange(libIds);
} }
} }

View File

@ -1,8 +1,6 @@
using System; using System;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Linq.Expressions;
using API.DTOs.Filtering.v2; using API.DTOs.Filtering.v2;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
@ -28,6 +26,8 @@ public static class SeriesFilter
return queryable.Where(s => s.Metadata.Language.Equals(languages.First())); return queryable.Where(s => s.Metadata.Language.Equals(languages.First()));
case FilterComparison.Contains: case FilterComparison.Contains:
return queryable.Where(s => languages.Contains(s.Metadata.Language)); return queryable.Where(s => languages.Contains(s.Metadata.Language));
case FilterComparison.MustContains:
return queryable.Where(s => languages.All(s2 => s2.Equals(s.Metadata.Language)));
case FilterComparison.NotContains: case FilterComparison.NotContains:
return queryable.Where(s => !languages.Contains(s.Metadata.Language)); return queryable.Where(s => !languages.Contains(s.Metadata.Language));
case FilterComparison.NotEqual: case FilterComparison.NotEqual:
@ -78,6 +78,7 @@ public static class SeriesFilter
case FilterComparison.NotEqual: case FilterComparison.NotEqual:
case FilterComparison.BeginsWith: case FilterComparison.BeginsWith:
case FilterComparison.EndsWith: case FilterComparison.EndsWith:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.ReleaseYear"); throw new KavitaException($"{comparison} not applicable for Series.ReleaseYear");
default: default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
@ -112,6 +113,7 @@ public static class SeriesFilter
case FilterComparison.IsAfter: case FilterComparison.IsAfter:
case FilterComparison.IsInLast: case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast: case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.Rating"); throw new KavitaException($"{comparison} not applicable for Series.Rating");
default: default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
@ -149,6 +151,7 @@ public static class SeriesFilter
case FilterComparison.IsAfter: case FilterComparison.IsAfter:
case FilterComparison.IsInLast: case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast: case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.AgeRating"); throw new KavitaException($"{comparison} not applicable for Series.AgeRating");
default: default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
@ -182,6 +185,7 @@ public static class SeriesFilter
case FilterComparison.IsAfter: case FilterComparison.IsAfter:
case FilterComparison.IsInLast: case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast: case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.AverageReadTime"); throw new KavitaException($"{comparison} not applicable for Series.AverageReadTime");
default: default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
@ -204,6 +208,7 @@ public static class SeriesFilter
return queryable.Where(s => !pubStatues.Contains(s.Metadata.PublicationStatus)); return queryable.Where(s => !pubStatues.Contains(s.Metadata.PublicationStatus));
case FilterComparison.NotEqual: case FilterComparison.NotEqual:
return queryable.Where(s => s.Metadata.PublicationStatus != firstStatus); return queryable.Where(s => s.Metadata.PublicationStatus != firstStatus);
case FilterComparison.MustContains:
case FilterComparison.GreaterThan: case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual: case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan: case FilterComparison.LessThan:
@ -273,6 +278,7 @@ public static class SeriesFilter
case FilterComparison.IsAfter: case FilterComparison.IsAfter:
case FilterComparison.IsInLast: case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast: case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.ReadProgress"); throw new KavitaException($"{comparison} not applicable for Series.ReadProgress");
default: default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
@ -295,6 +301,15 @@ public static class SeriesFilter
case FilterComparison.NotEqual: case FilterComparison.NotEqual:
case FilterComparison.NotContains: case FilterComparison.NotContains:
return queryable.Where(s => s.Metadata.Tags.Any(t => !tags.Contains(t.Id))); return queryable.Where(s => s.Metadata.Tags.Any(t => !tags.Contains(t.Id)));
case FilterComparison.MustContains:
// Deconstruct and do a Union of a bunch of where statements since this doesn't translate
var queries = new List<IQueryable<Series>>()
{
queryable
};
queries.AddRange(tags.Select(gId => queryable.Where(s => s.Metadata.Tags.Any(p => p.Id == gId))));
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
case FilterComparison.GreaterThan: case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual: case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan: case FilterComparison.LessThan:
@ -325,6 +340,15 @@ public static class SeriesFilter
case FilterComparison.NotEqual: case FilterComparison.NotEqual:
case FilterComparison.NotContains: case FilterComparison.NotContains:
return queryable.Where(s => s.Metadata.People.Any(t => !people.Contains(t.Id))); return queryable.Where(s => s.Metadata.People.Any(t => !people.Contains(t.Id)));
case FilterComparison.MustContains:
// Deconstruct and do a Union of a bunch of where statements since this doesn't translate
var queries = new List<IQueryable<Series>>()
{
queryable
};
queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.Id == gId))));
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
case FilterComparison.GreaterThan: case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual: case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan: case FilterComparison.LessThan:
@ -355,6 +379,15 @@ public static class SeriesFilter
case FilterComparison.NotEqual: case FilterComparison.NotEqual:
case FilterComparison.NotContains: case FilterComparison.NotContains:
return queryable.Where(s => s.Metadata.Genres.All(p => !genres.Contains(p.Id))); return queryable.Where(s => s.Metadata.Genres.All(p => !genres.Contains(p.Id)));
case FilterComparison.MustContains:
// Deconstruct and do a Union of a bunch of where statements since this doesn't translate
var queries = new List<IQueryable<Series>>()
{
queryable
};
queries.AddRange(genres.Select(gId => queryable.Where(s => s.Metadata.Genres.Any(p => p.Id == gId))));
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
case FilterComparison.GreaterThan: case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual: case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan: case FilterComparison.LessThan:
@ -385,6 +418,7 @@ public static class SeriesFilter
case FilterComparison.NotContains: case FilterComparison.NotContains:
case FilterComparison.NotEqual: case FilterComparison.NotEqual:
return queryable.Where(s => !formats.Contains(s.Format)); return queryable.Where(s => !formats.Contains(s.Format));
case FilterComparison.MustContains:
case FilterComparison.GreaterThan: case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual: case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan: case FilterComparison.LessThan:
@ -407,7 +441,6 @@ public static class SeriesFilter
{ {
if (!condition || collectionTags.Count == 0) return queryable; if (!condition || collectionTags.Count == 0) return queryable;
//var first = collectionTags.First();
switch (comparison) switch (comparison)
{ {
case FilterComparison.Equal: case FilterComparison.Equal:
@ -416,6 +449,15 @@ public static class SeriesFilter
case FilterComparison.NotContains: case FilterComparison.NotContains:
case FilterComparison.NotEqual: case FilterComparison.NotEqual:
return queryable.Where(s => !s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id))); return queryable.Where(s => !s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id)));
case FilterComparison.MustContains:
// Deconstruct and do a Union of a bunch of where statements since this doesn't translate
var queries = new List<IQueryable<Series>>()
{
queryable
};
queries.AddRange(collectionTags.Select(gId => queryable.Where(s => s.Metadata.CollectionTags.Any(p => p.Id == gId))));
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
case FilterComparison.GreaterThan: case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual: case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan: case FilterComparison.LessThan:
@ -475,6 +517,7 @@ public static class SeriesFilter
case FilterComparison.IsAfter: case FilterComparison.IsAfter:
case FilterComparison.IsInLast: case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast: case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.Name"); throw new KavitaException($"{comparison} not applicable for Series.Name");
default: default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"); throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
@ -508,6 +551,7 @@ public static class SeriesFilter
case FilterComparison.IsAfter: case FilterComparison.IsAfter:
case FilterComparison.IsInLast: case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast: case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.Metadata.Summary"); throw new KavitaException($"{comparison} not applicable for Series.Metadata.Summary");
default: default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"); throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
@ -543,6 +587,7 @@ public static class SeriesFilter
case FilterComparison.IsAfter: case FilterComparison.IsAfter:
case FilterComparison.IsInLast: case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast: case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.FolderPath"); throw new KavitaException($"{comparison} not applicable for Series.FolderPath");
default: default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"); throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
@ -618,6 +663,7 @@ public static class SeriesFilter
case FilterComparison.IsAfter: case FilterComparison.IsAfter:
case FilterComparison.IsInLast: case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast: case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.FolderPath"); throw new KavitaException($"{comparison} not applicable for Series.FolderPath");
default: default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"); throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");

View File

@ -43,6 +43,7 @@
"file-missing": "File was not found in book", "file-missing": "File was not found in book",
"collection-updated": "Collection updated successfully", "collection-updated": "Collection updated successfully",
"collection-deleted": "Collection deleted",
"generic-error": "Something went wrong, please try again", "generic-error": "Something went wrong, please try again",
"collection-doesnt-exist": "Collection does not exist", "collection-doesnt-exist": "Collection does not exist",

View File

@ -17,6 +17,7 @@ namespace API.Services;
public interface ICollectionTagService public interface ICollectionTagService
{ {
Task<bool> TagExistsByName(string name); Task<bool> TagExistsByName(string name);
Task<bool> DeleteTag(CollectionTag tag);
Task<bool> UpdateTag(CollectionTagDto dto); Task<bool> UpdateTag(CollectionTagDto dto);
Task<bool> AddTagToSeries(CollectionTag? tag, IEnumerable<int> seriesIds); Task<bool> AddTagToSeries(CollectionTag? tag, IEnumerable<int> seriesIds);
Task<bool> RemoveTagFromSeries(CollectionTag? tag, IEnumerable<int> seriesIds); Task<bool> RemoveTagFromSeries(CollectionTag? tag, IEnumerable<int> seriesIds);
@ -49,6 +50,12 @@ public class CollectionTagService : ICollectionTagService
return await _unitOfWork.CollectionTagRepository.TagExists(name); return await _unitOfWork.CollectionTagRepository.TagExists(name);
} }
public async Task<bool> DeleteTag(CollectionTag tag)
{
_unitOfWork.CollectionTagRepository.Remove(tag);
return await _unitOfWork.CommitAsync();
}
public async Task<bool> UpdateTag(CollectionTagDto dto) public async Task<bool> UpdateTag(CollectionTagDto dto)
{ {
var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(dto.Id); var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(dto.Id);
@ -130,6 +137,7 @@ public class CollectionTagService : ICollectionTagService
public async Task<bool> RemoveTagFromSeries(CollectionTag? tag, IEnumerable<int> seriesIds) public async Task<bool> RemoveTagFromSeries(CollectionTag? tag, IEnumerable<int> seriesIds)
{ {
if (tag == null) return false; if (tag == null) return false;
tag.SeriesMetadatas ??= new List<SeriesMetadata>();
foreach (var seriesIdToRemove in seriesIds) foreach (var seriesIdToRemove in seriesIds)
{ {
tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove)); tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove));

View File

@ -23,7 +23,7 @@
"@iplab/ngx-file-upload": "^16.0.1", "@iplab/ngx-file-upload": "^16.0.1",
"@microsoft/signalr": "^7.0.10", "@microsoft/signalr": "^7.0.10",
"@ng-bootstrap/ng-bootstrap": "^15.1.1", "@ng-bootstrap/ng-bootstrap": "^15.1.1",
"@ngneat/transloco": "^5.0.6", "@ngneat/transloco": "^5.0.7",
"@ngneat/transloco-locale": "^5.1.1", "@ngneat/transloco-locale": "^5.1.1",
"@ngneat/transloco-persist-lang": "^5.0.0", "@ngneat/transloco-persist-lang": "^5.0.0",
"@ngneat/transloco-persist-translations": "^5.0.0", "@ngneat/transloco-persist-translations": "^5.0.0",
@ -3170,9 +3170,9 @@
} }
}, },
"node_modules/@ngneat/transloco": { "node_modules/@ngneat/transloco": {
"version": "5.0.6", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/@ngneat/transloco/-/transloco-5.0.6.tgz", "resolved": "https://registry.npmjs.org/@ngneat/transloco/-/transloco-5.0.7.tgz",
"integrity": "sha512-pt0jiU0co0nT72bhodT9ervBvSgl1jVUrTbLsHwjtP3WoJZxfOmXN21j5MSA/GJFRkolceI8+yWqtG7jux+WDg==", "integrity": "sha512-x1c2e+7cOYPPVFPgqGcN3R6d7f18a4sMHzxsCamcxS2w7vWXcEzWKZ8JcI1TdpxrM+RKuj2NRfEEcr1HjAI/4w==",
"dependencies": { "dependencies": {
"@ngneat/transloco-utils": "^5.0.0", "@ngneat/transloco-utils": "^5.0.0",
"flat": "5.0.2", "flat": "5.0.2",

View File

@ -28,7 +28,7 @@
"@iplab/ngx-file-upload": "^16.0.1", "@iplab/ngx-file-upload": "^16.0.1",
"@microsoft/signalr": "^7.0.10", "@microsoft/signalr": "^7.0.10",
"@ng-bootstrap/ng-bootstrap": "^15.1.1", "@ng-bootstrap/ng-bootstrap": "^15.1.1",
"@ngneat/transloco": "^5.0.6", "@ngneat/transloco": "^5.0.7",
"@ngneat/transloco-locale": "^5.1.1", "@ngneat/transloco-locale": "^5.1.1",
"@ngneat/transloco-persist-lang": "^5.0.0", "@ngneat/transloco-persist-lang": "^5.0.0",
"@ngneat/transloco-persist-translations": "^5.0.0", "@ngneat/transloco-persist-translations": "^5.0.0",

View File

@ -9,11 +9,12 @@ export enum FilterComparison {
/// </summary> /// </summary>
/// <remarks>Only works with IList</remarks> /// <remarks>Only works with IList</remarks>
Contains = 5, Contains = 5,
MustContains = 6,
/// <summary> /// <summary>
/// Performs a LIKE %value% /// Performs a LIKE %value%
/// </summary> /// </summary>
Matches = 6, Matches = 7,
NotContains = 7, NotContains = 8,
/// <summary> /// <summary>
/// Not Equal to /// Not Equal to
/// </summary> /// </summary>
@ -42,4 +43,4 @@ export enum FilterComparison {
/// Is Date not between now and X seconds ago /// Is Date not between now and X seconds ago
/// </summary> /// </summary>
IsNotInLast = 15, IsNotInLast = 15,
} }

View File

@ -247,6 +247,14 @@ export class ActionFactoryService {
requiresAdmin: true, requiresAdmin: true,
children: [], children: [],
}, },
{
action: Action.Delete,
title: 'delete',
callback: this.dummyCallback,
requiresAdmin: false,
class: 'danger',
children: [],
},
]; ];
this.seriesActions = [ this.seriesActions = [

View File

@ -41,4 +41,8 @@ export class CollectionTagService {
tagNameExists(name: string) { tagNameExists(name: string) {
return this.httpClient.get<boolean>(this.baseUrl + 'collection/name-exists?name=' + name); return this.httpClient.get<boolean>(this.baseUrl + 'collection/name-exists?name=' + name);
} }
deleteTag(tagId: number) {
return this.httpClient.delete<string>(this.baseUrl + 'collection?tagId=' + tagId, TextResonse);
}
} }

View File

@ -166,8 +166,8 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
} }
hasCustomSort() { hasCustomSort() {
return this.filter.sortOptions?.sortField != SortField.SortName || !this.filter.sortOptions.isAscending return this.filter?.sortOptions?.sortField != SortField.SortName || !this.filter?.sortOptions.isAscending
|| this.filterSettings.presetsV2?.sortOptions?.sortField != SortField.SortName || !this.filterSettings.presetsV2?.sortOptions?.isAscending; || this.filterSettings?.presetsV2?.sortOptions?.sortField != SortField.SortName || !this.filterSettings?.presetsV2?.sortOptions?.isAscending;
} }
performAction(action: ActionItem<any>) { performAction(action: ActionItem<any>) {

View File

@ -12,7 +12,7 @@
> >
<ng-template #cardItem let-item let-position="idx"> <ng-template #cardItem let-item let-position="idx">
<app-card-item [title]="item.title" [entity]="item" [actions]="collectionTagActions" <app-card-item [title]="item.title" [entity]="item" [actions]="collectionTagActions"
[imageUrl]="imageSerivce.getCollectionCoverImage(item.id)" [imageUrl]="imageService.getCollectionCoverImage(item.id)"
(clicked)="loadCollection(item)"></app-card-item> (clicked)="loadCollection(item)"></app-card-item>
</ng-template> </ng-template>

View File

@ -1,31 +1,35 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component,
DestroyRef,
EventEmitter, EventEmitter,
inject, inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { Title } from '@angular/platform-browser'; import {Title} from '@angular/platform-browser';
import { Router } from '@angular/router'; import {Router} from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import { map, of} from 'rxjs'; import {map, of} from 'rxjs';
import { Observable } from 'rxjs/internal/Observable'; import {Observable} from 'rxjs/internal/Observable';
import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component'; import {EditCollectionTagsComponent} from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
import { CollectionTag } from 'src/app/_models/collection-tag'; import {CollectionTag} from 'src/app/_models/collection-tag';
import { JumpKey } from 'src/app/_models/jumpbar/jump-key'; import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
import { Tag } from 'src/app/_models/tag'; import {Tag} from 'src/app/_models/tag';
import { AccountService } from 'src/app/_services/account.service'; import {AccountService} from 'src/app/_services/account.service';
import { ActionItem, ActionFactoryService, Action } from 'src/app/_services/action-factory.service'; import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
import { CollectionTagService } from 'src/app/_services/collection-tag.service'; import {CollectionTagService} from 'src/app/_services/collection-tag.service';
import { ImageService } from 'src/app/_services/image.service'; import {ImageService} from 'src/app/_services/image.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service'; import {JumpbarService} from 'src/app/_services/jumpbar.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { NgIf, AsyncPipe, DecimalPipe } from '@angular/common'; import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common';
import { CardItemComponent } from '../../../cards/card-item/card-item.component'; import {CardItemComponent} from '../../../cards/card-item/card-item.component';
import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component'; import {CardDetailLayoutComponent} from '../../../cards/card-detail-layout/card-detail-layout.component';
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import {
SideNavCompanionBarComponent
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {ToastrService} from "ngx-toastr";
@Component({ @Component({
@ -49,11 +53,12 @@ export class AllCollectionsComponent implements OnInit {
filterOpen: EventEmitter<boolean> = new EventEmitter(); filterOpen: EventEmitter<boolean> = new EventEmitter();
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly translocoService = inject(TranslocoService); private readonly translocoService = inject(TranslocoService);
private readonly toastr = inject(ToastrService);
constructor(private collectionService: CollectionTagService, private router: Router, constructor(private collectionService: CollectionTagService, private router: Router,
private actionFactoryService: ActionFactoryService, private modalService: NgbModal, private actionFactoryService: ActionFactoryService, private modalService: NgbModal,
private titleService: Title, private jumpbarService: JumpbarService, private titleService: Title, private jumpbarService: JumpbarService,
private readonly cdRef: ChangeDetectorRef, public imageSerivce: ImageService, private readonly cdRef: ChangeDetectorRef, public imageService: ImageService,
public accountService: AccountService) { public accountService: AccountService) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.titleService.setTitle('Kavita - ' + this.translocoService.translate('all-collections.title')); this.titleService.setTitle('Kavita - ' + this.translocoService.translate('all-collections.title'));
@ -87,6 +92,11 @@ export class AllCollectionsComponent implements OnInit {
handleCollectionActionCallback(action: ActionItem<CollectionTag>, collectionTag: CollectionTag) { handleCollectionActionCallback(action: ActionItem<CollectionTag>, collectionTag: CollectionTag) {
switch (action.action) { switch (action.action) {
case(Action.Delete):
this.collectionService.deleteTag(collectionTag.id).subscribe(res => {
this.toastr.success(res);
});
break;
case(Action.Edit): case(Action.Edit):
const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true }); const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true });
modalRef.componentInstance.tag = collectionTag; modalRef.componentInstance.tag = collectionTag;

View File

@ -3,23 +3,15 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
EventEmitter, inject, EventEmitter,
inject,
Input, Input,
OnInit, OnInit,
Output, Output,
} from '@angular/core'; } from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {FilterStatement} from '../../../_models/metadata/v2/filter-statement'; import {FilterStatement} from '../../../_models/metadata/v2/filter-statement';
import { import {BehaviorSubject, distinctUntilChanged, filter, map, Observable, of, startWith, switchMap, tap} from 'rxjs';
BehaviorSubject,
distinctUntilChanged, filter,
map,
Observable,
of,
startWith,
switchMap,
tap
} from 'rxjs';
import {MetadataService} from 'src/app/_services/metadata.service'; import {MetadataService} from 'src/app/_services/metadata.service';
import {mangaFormatFilters} from 'src/app/_models/metadata/series-filter'; import {mangaFormatFilters} from 'src/app/_models/metadata/series-filter';
import {PersonRole} from 'src/app/_models/metadata/person'; import {PersonRole} from 'src/app/_models/metadata/person';
@ -50,6 +42,16 @@ const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, Fi
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags FilterField.Formats, FilterField.CollectionTags, FilterField.Tags
]; ];
const DropdownFieldsWithoutMustContains = [
FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus
];
const DropdownFieldsThatIncludeNumberComparisons = [
FilterField.AgeRating
];
const NumberFieldsThatIncludeDateComparisons = [
FilterField.ReleaseYear
];
const StringComparisons = [FilterComparison.Equal, const StringComparisons = [FilterComparison.Equal,
FilterComparison.NotEqual, FilterComparison.NotEqual,
FilterComparison.BeginsWith, FilterComparison.BeginsWith,
@ -65,7 +67,8 @@ const NumberComparisons = [FilterComparison.Equal,
const DropdownComparisons = [FilterComparison.Equal, const DropdownComparisons = [FilterComparison.Equal,
FilterComparison.NotEqual, FilterComparison.NotEqual,
FilterComparison.Contains, FilterComparison.Contains,
FilterComparison.NotContains]; FilterComparison.NotContains,
FilterComparison.MustContains];
@Component({ @Component({
selector: 'app-metadata-row-filter', selector: 'app-metadata-row-filter',
@ -113,7 +116,7 @@ export class MetadataFilterRowComponent implements OnInit {
get MultipleDropdownAllowed() { get MultipleDropdownAllowed() {
const comp = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison; const comp = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison;
return comp === FilterComparison.Contains || comp === FilterComparison.NotContains; return comp === FilterComparison.Contains || comp === FilterComparison.NotContains || comp === FilterComparison.MustContains;
} }
constructor(private readonly metadataService: MetadataService, private readonly libraryService: LibraryService, constructor(private readonly metadataService: MetadataService, private readonly libraryService: LibraryService,
@ -148,6 +151,12 @@ export class MetadataFilterRowComponent implements OnInit {
value: this.formGroup.get('filterValue')?.value! value: this.formGroup.get('filterValue')?.value!
}; };
// Some ids can get through and be numbers, convert them to strings for the backend
if (typeof stmt.value === 'number' && !Number.isNaN(stmt.value)) {
stmt.value = stmt.value + '';
}
console.log('Trying to update parent with new stmt: ', stmt.value);
if (!stmt.value && stmt.field !== FilterField.SeriesName) return; if (!stmt.value && stmt.field !== FilterField.SeriesName) return;
console.log('updating parent with new statement: ', stmt.value) console.log('updating parent with new statement: ', stmt.value)
this.filterStatement.emit(stmt); this.filterStatement.emit(stmt);
@ -188,7 +197,6 @@ export class MetadataFilterRowComponent implements OnInit {
getDropdownObservable(): Observable<Select2Option[]> { getDropdownObservable(): Observable<Select2Option[]> {
const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField; const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField;
console.log('Getting dropdown observable: ', filterField);
switch (filterField) { switch (filterField) {
case FilterField.PublicationStatus: case FilterField.PublicationStatus:
return this.metadataService.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => { return this.metadataService.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => {
@ -252,15 +260,15 @@ export class MetadataFilterRowComponent implements OnInit {
this.predicateType$.next(PredicateType.Text); this.predicateType$.next(PredicateType.Text);
if (this.loaded) { if (this.loaded) {
this.formGroup.get('filterValue')?.patchValue('',{emitEvent: false}); this.formGroup.get('filterValue')?.patchValue('');
console.log('setting filterValue to empty string', this.formGroup.get('filterValue')?.value) console.log('setting filterValue to empty string', this.formGroup.get('filterValue')?.value)
} // BUG: undefined is getting set and the input value isn't updating and emitting to the backend }
return; return;
} }
if (NumberFields.includes(inputVal)) { if (NumberFields.includes(inputVal)) {
const comps = [...NumberComparisons]; const comps = [...NumberComparisons];
if (inputVal === FilterField.ReleaseYear) { if (NumberFieldsThatIncludeDateComparisons.includes(inputVal)) {
comps.push(...DateComparisons); comps.push(...DateComparisons);
} }
this.validComparisons$.next(comps); this.validComparisons$.next(comps);
@ -270,12 +278,16 @@ export class MetadataFilterRowComponent implements OnInit {
} }
if (DropdownFields.includes(inputVal)) { if (DropdownFields.includes(inputVal)) {
const comps = [...DropdownComparisons]; let comps = [...DropdownComparisons];
if (inputVal === FilterField.AgeRating) { if (DropdownFieldsThatIncludeNumberComparisons.includes(inputVal)) {
comps.push(...NumberComparisons); comps.push(...NumberComparisons);
} }
if (DropdownFieldsWithoutMustContains.includes(inputVal)) {
comps = comps.filter(c => c !== FilterComparison.MustContains);
}
this.validComparisons$.next(comps); this.validComparisons$.next(comps);
this.predicateType$.next(PredicateType.Dropdown); this.predicateType$.next(PredicateType.Dropdown);
if (this.loaded) this.formGroup.get('filterValue')?.patchValue(0);
return; return;
} }
} }

View File

@ -40,6 +40,8 @@ export class FilterComparisonPipe implements PipeTransform {
return translate('filter-comparison-pipe.is-in-last'); return translate('filter-comparison-pipe.is-in-last');
case FilterComparison.IsNotInLast: case FilterComparison.IsNotInLast:
return translate('filter-comparison-pipe.is-not-in-last'); return translate('filter-comparison-pipe.is-not-in-last');
case FilterComparison.MustContains:
return translate('filter-comparison-pipe.must-contains');
default: default:
throw new Error(`Invalid FilterComparison value: ${value}`); throw new Error(`Invalid FilterComparison value: ${value}`);
} }

View File

@ -1751,7 +1751,8 @@
"is-before": "Is before", "is-before": "Is before",
"is-after": "Is after", "is-after": "Is after",
"is-in-last": "Is in last", "is-in-last": "Is in last",
"is-not-in-last": "Is not in last" "is-not-in-last": "Is not in last",
"must-contains": "Must Contains"
}, },
"toasts": { "toasts": {

View File

@ -66,7 +66,3 @@
/* hint */ /* hint */
--select2-hint-text-color: #888; --select2-hint-text-color: #888;
} }
//.select2-selection__rendered {
// padding-top: 4px;
//}

View File

@ -1,19 +0,0 @@
version: '3'
services:
kavita:
image: kizaing/kavita:latest
container_name: kavita
volumes:
- ./manga:/manga
- ./config:/kavita/config
ports:
- "5000:5000"
restart: unless-stopped
#Uncomment if you want to implement healthchecks
#healthcheck:
# test: curl --fail http://localhost:5000 || exit 1
# interval: 300s
# retries: 3
# start_period: 30s
# timeout: 15s

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.7.7.14" "version": "0.7.7.17"
}, },
"servers": [ "servers": [
{ {
@ -1268,7 +1268,7 @@
"tags": [ "tags": [
"Collection" "Collection"
], ],
"summary": "Return a list of all collection tags on the server", "summary": "Return a list of all collection tags on the server for the logged in user.",
"responses": { "responses": {
"200": { "200": {
"description": "Success", "description": "Success",
@ -1300,6 +1300,28 @@
} }
} }
} }
},
"delete": {
"tags": [
"Collection"
],
"summary": "Removes the collection tag from all Series it was attached to",
"parameters": [
{
"name": "tagId",
"in": "query",
"description": "",
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Success"
}
}
} }
}, },
"/api/Collection/search": { "/api/Collection/search": {
@ -13726,6 +13748,7 @@
5, 5,
6, 6,
7, 7,
8,
9, 9,
10, 10,
11, 11,
@ -18400,4 +18423,4 @@
"description": "Responsible for all things Want To Read" "description": "Responsible for all things Want To Read"
} }
] ]
} }