Smart Filter Polish & New Filters (#2283)

This commit is contained in:
Joe Milazzo 2023-09-15 09:39:06 -07:00 committed by GitHub
parent 0d8c081093
commit 45f6fb67d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 375 additions and 181 deletions

View File

@ -40,6 +40,10 @@ public enum FilterField
/// <summary>
/// On Want To Read or Not
/// </summary>
WantToRead = 26
WantToRead = 26,
/// <summary>
/// Last time User Read
/// </summary>
ReadingDate = 27
}

View File

@ -868,8 +868,6 @@ public class SeriesRepository : ISeriesRepository
.HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres)
.HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!)
.HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0)
// TODO: This needs different treatment
.HasPeople(hasPeopleFilter, FilterComparison.Contains, allPeopleIds)
.WhereIf(onlyParentSeries,
@ -917,6 +915,7 @@ public class SeriesRepository : ISeriesRepository
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
SortField.ReleaseYear => query.OrderBy(s => s.Metadata.ReleaseYear),
SortField.ReadProgress => query.OrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id).Select(p => p.LastModified).Max()),
_ => query
};
}
@ -930,6 +929,7 @@ public class SeriesRepository : ISeriesRepository
SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead),
SortField.ReleaseYear => query.OrderByDescending(s => s.Metadata.ReleaseYear),
SortField.ReadProgress => query.OrderByDescending(s => s.Progress.Where(p => p.SeriesId == s.Id).Select(p => p.LastModified).Max()),
_ => query
};
}
@ -1089,6 +1089,7 @@ public class SeriesRepository : ISeriesRepository
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),
FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId),
_ => throw new ArgumentOutOfRangeException()
};
}

View File

@ -439,8 +439,8 @@ public class UserRepository : IUserRepository
var filterSeriesQuery = query.Join(_context.Series, b => b.SeriesId, s => s.Id,
(bookmark, series) => new BookmarkSeriesPair()
{
bookmark = bookmark,
series = series
Bookmark = bookmark,
Series = series
});
var filterStatement = filter.Statements.FirstOrDefault(f => f.Field == FilterField.SeriesName);
@ -457,34 +457,34 @@ public class UserRepository : IUserRepository
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));
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}%"));
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}"));
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}%"));
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);
filterSeriesQuery = filterSeriesQuery.Where(s => s.Series.Name != queryString
|| s.Series.OriginalName != queryString
|| s.Series.LocalizedName != queryString
|| s.Series.SortName != queryString);
break;
case FilterComparison.MustContains:
case FilterComparison.NotContains:
@ -504,7 +504,7 @@ public class UserRepository : IUserRepository
return await ApplyLimit(filterSeriesQuery
.Sort(filter.SortOptions)
.AsSplitQuery(), filter.LimitTo)
.Select(o => o.bookmark)
.Select(o => o.Bookmark)
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}

View File

@ -6,8 +6,8 @@ namespace API.Extensions.QueryExtensions.Filtering;
public class BookmarkSeriesPair
{
public AppUserBookmark bookmark { get; set; }
public Series series { get; set; }
public AppUserBookmark Bookmark { get; set; }
public Series Series { get; set; }
}
public static class BookmarkSort
@ -31,12 +31,13 @@ public static class BookmarkSort
{
query = sortOptions.SortField switch
{
SortField.SortName => query.OrderBy(s => s.series.SortName.ToLower()),
SortField.CreatedDate => query.OrderBy(s => s.series.Created),
SortField.LastModifiedDate => query.OrderBy(s => s.series.LastModified),
SortField.LastChapterAdded => query.OrderBy(s => s.series.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.series.AvgHoursToRead),
SortField.ReleaseYear => query.OrderBy(s => s.series.Metadata.ReleaseYear),
SortField.SortName => query.OrderBy(s => s.Series.SortName.ToLower()),
SortField.CreatedDate => query.OrderBy(s => s.Series.Created),
SortField.LastModifiedDate => query.OrderBy(s => s.Series.LastModified),
SortField.LastChapterAdded => query.OrderBy(s => s.Series.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.Series.AvgHoursToRead),
SortField.ReleaseYear => query.OrderBy(s => s.Series.Metadata.ReleaseYear),
SortField.ReadProgress => query.OrderBy(s => s.Series.Progress.Where(p => p.SeriesId == s.Series.Id).Select(p => p.LastModified).Max()),
_ => query
};
}
@ -44,12 +45,13 @@ public static class BookmarkSort
{
query = sortOptions.SortField switch
{
SortField.SortName => query.OrderByDescending(s => s.series.SortName.ToLower()),
SortField.CreatedDate => query.OrderByDescending(s => s.series.Created),
SortField.LastModifiedDate => query.OrderByDescending(s => s.series.LastModified),
SortField.LastChapterAdded => query.OrderByDescending(s => s.series.LastChapterAdded),
SortField.TimeToRead => query.OrderByDescending(s => s.series.AvgHoursToRead),
SortField.ReleaseYear => query.OrderByDescending(s => s.series.Metadata.ReleaseYear),
SortField.SortName => query.OrderByDescending(s => s.Series.SortName.ToLower()),
SortField.CreatedDate => query.OrderByDescending(s => s.Series.Created),
SortField.LastModifiedDate => query.OrderByDescending(s => s.Series.LastModified),
SortField.LastChapterAdded => query.OrderByDescending(s => s.Series.LastChapterAdded),
SortField.TimeToRead => query.OrderByDescending(s => s.Series.AvgHoursToRead),
SortField.ReleaseYear => query.OrderByDescending(s => s.Series.Metadata.ReleaseYear),
SortField.ReadProgress => query.OrderByDescending(s => s.Series.Progress.Where(p => p.SeriesId == s.Series.Id).Select(p => p.LastModified).Max()),
_ => query
};
}

View File

@ -288,6 +288,64 @@ public static class SeriesFilter
return queryable.Where(s => ids.Contains(s.Id));
}
public static IQueryable<Series> HasReadingDate(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, DateTime? date, int userId)
{
if (!condition || !date.HasValue) return queryable;
var subQuery = queryable
.Include(s => s.Progress)
.Where(s => s.Progress != null)
.Select(s => new
{
Series = s,
MaxDate = s.Progress.Where(p => p != null && p.AppUserId == userId)
.Select(p => (DateTime?) p.LastModified)
.DefaultIfEmpty()
.Max()
})
.Where(s => s.MaxDate != null)
.AsEnumerable();
switch (comparison)
{
case FilterComparison.Equal:
subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate.Equals(date));
break;
case FilterComparison.IsAfter:
case FilterComparison.GreaterThan:
subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate > date);
break;
case FilterComparison.GreaterThanEqual:
subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate >= date);
break;
case FilterComparison.IsBefore:
case FilterComparison.LessThan:
subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate < date);
break;
case FilterComparison.LessThanEqual:
subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate <= date);
break;
case FilterComparison.NotEqual:
subQuery = subQuery.Where(s => s.MaxDate != null && !s.MaxDate.Equals(date));
break;
case FilterComparison.Matches:
case FilterComparison.Contains:
case FilterComparison.NotContains:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
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)
{

View File

@ -31,7 +31,7 @@ public static class SeriesSort
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
SortField.ReleaseYear => query.OrderBy(s => s.Metadata.ReleaseYear),
//SortField.ReadProgress => query.OrderBy()
SortField.ReadProgress => query.OrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id).Select(p => p.LastModified).Max()),
_ => query
};
}
@ -45,6 +45,7 @@ public static class SeriesSort
SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead),
SortField.ReleaseYear => query.OrderByDescending(s => s.Metadata.ReleaseYear),
SortField.ReadProgress => query.OrderByDescending(s => s.Progress.Where(p => p.SeriesId == s.Id).Select(p => p.LastModified).Max()),
_ => query
};
}

View File

@ -36,12 +36,12 @@ public class AutoMapperProfiles : Profile
public AutoMapperProfiles()
{
CreateMap<BookmarkSeriesPair, BookmarkDto>()
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.bookmark.Id))
.ForMember(dest => dest.Page, opt => opt.MapFrom(src => src.bookmark.Page))
.ForMember(dest => dest.VolumeId, opt => opt.MapFrom(src => src.bookmark.VolumeId))
.ForMember(dest => dest.SeriesId, opt => opt.MapFrom(src => src.bookmark.SeriesId))
.ForMember(dest => dest.ChapterId, opt => opt.MapFrom(src => src.bookmark.ChapterId))
.ForMember(dest => dest.Series, opt => opt.MapFrom(src => src.series));
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Bookmark.Id))
.ForMember(dest => dest.Page, opt => opt.MapFrom(src => src.Bookmark.Page))
.ForMember(dest => dest.VolumeId, opt => opt.MapFrom(src => src.Bookmark.VolumeId))
.ForMember(dest => dest.SeriesId, opt => opt.MapFrom(src => src.Bookmark.SeriesId))
.ForMember(dest => dest.ChapterId, opt => opt.MapFrom(src => src.Bookmark.ChapterId))
.ForMember(dest => dest.Series, opt => opt.MapFrom(src => src.Series));
CreateMap<LibraryDto, Library>();
CreateMap<Volume, VolumeDto>();
CreateMap<MangaFile, MangaFileDto>();

View File

@ -69,6 +69,7 @@ public static class FilterFieldValueConverter
.ToList(), typeof(IList<int>)),
FilterField.WantToRead => (bool.Parse(value), typeof(bool)),
FilterField.ReadProgress => (int.Parse(value), typeof(int)),
FilterField.ReadingDate => (DateTime.Parse(value), typeof(DateTime?)),
FilterField.Formats => (value.Split(',')
.Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x))
.ToList(), typeof(IList<MangaFormat>)),

View File

@ -20,6 +20,7 @@ export enum SortField {
LastChapterAdded = 4,
TimeToRead = 5,
ReleaseYear = 6,
ReadProgress = 7,
}
export const allSortFields = Object.keys(SortField)

View File

@ -27,7 +27,8 @@ export enum FilterField
ReadTime = 23,
Path = 24,
FilePath = 25,
WantToRead = 26
WantToRead = 26,
ReadingDate = 27
}
export const allFields = Object.keys(FilterField)

View File

@ -205,7 +205,7 @@ export class ActionFactoryService {
action: Action.Scan,
title: 'scan-library',
callback: this.dummyCallback,
requiresAdmin: false,
requiresAdmin: true,
children: [],
},
{

View File

@ -1,47 +1,73 @@
<ng-container *transloco="let t; read: 'metadata-filter-row'">
<form [formGroup]="formGroup">
<div class="row g-0">
<div class="col-md-3 me-2 col-10 mb-2">
<select class="form-select me-2" formControlName="input">
<option *ngFor="let field of availableFields" [value]="field">{{field | filterField}}</option>
</select>
</div>
<form [formGroup]="formGroup">
<div class="row g-0">
<div class="col-md-3 me-2 col-10 mb-2">
<select class="form-select me-2" formControlName="input">
<option *ngFor="let field of availableFields" [value]="field">{{field | filterField}}</option>
</select>
</div>
<div class="col-md-2 me-2 col-10 mb-2">
<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-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" min="0">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Boolean">
<input type="checkbox" class="form-check-input mt-2 me-2" style="font-size: 1.5rem" formControlName="filterValue">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Date">
<div class="input-group">
<input
class="form-control"
placeholder="yyyy-mm-dd"
name="dp"
formControlName="filterValue"
(dateSelect)="onDateSelect($event)"
(blur)="updateIfDateFilled()"
ngbDatepicker
#d="ngbDatepicker"
/>
<button class="btn btn-outline-secondary fa-solid fa-calendar-days" (click)="d.toggle()" type="button"></button>
</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" min="0">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Boolean">
<input type="checkbox" class="form-check-input mt-2 me-2" style="font-size: 1.5rem" formControlName="filterValue">
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Dropdown">
<ng-container *ngIf="dropdownOptions$ | async as opts">
<ng-container *ngTemplateOutlet="dropdown; context: { options: opts, multipleAllowed: MultipleDropdownAllowed }"></ng-container>
<ng-template #dropdown let-options="options" let-multipleAllowed="multipleAllowed">
<select2 [data]="options"
formControlName="filterValue"
[multiple]="multipleAllowed"
[infiniteScroll]="true"
[resettable]="true">
</select2>
</ng-template>
</ng-container>
</ng-container>
<ng-container *ngSwitchCase="PredicateType.Dropdown">
<ng-container *ngIf="dropdownOptions$ | async as opts">
<ng-container *ngTemplateOutlet="dropdown; context: { options: opts, multipleAllowed: MultipleDropdownAllowed }"></ng-container>
<ng-template #dropdown let-options="options" let-multipleAllowed="multipleAllowed">
<select2 [data]="options"
formControlName="filterValue"
[hideSelectedItems]="true"
[multiple]="multipleAllowed"
[infiniteScroll]="true"
[resettable]="true">
</select2>
</ng-template>
</ng-container>
</ng-container>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
<ng-content #removeBtn></ng-content>
</div>
</form>
<div class="col pt-2 ms-2">
<ng-container *ngIf="UiLabel !== null">
<span class="text-muted">{{t(UiLabel.unit)}}</span>
<i *ngIf="UiLabel.tooltip" class="fa fa-info-circle ms-1" aria-hidden="true" [ngbTooltip]="t(UiLabel.tooltip)"></i>
</ng-container>
</div>
<ng-content #removeBtn></ng-content>
</div>
</form>
</ng-container>

View File

@ -1,3 +1,23 @@
::ng-deep .select2-selection__rendered {
padding-top: 4px !important;
}
::ng-deep .ngb-dp-content, ::ng-deep .ngb-dp-header, ::ng-deep .dropdown-menu{
background: var(--bs-body-bg);
color: var(--body-text-color);
}
::ng-deep .ngb-dp-header, ::ng-deep .ngb-dp-weekdays {
background-color: var(--bs-body-bg) !important;
}
::ng-deep .ngb-dp-day .btn-light, ::ng-deep .ngb-dp-weekday {
background: var(--bs-body-bg);
color: var(--body-text-color);
}
::ng-deep [ngbDatepickerDayView]:hover:not(.bg-primary), [ngbDatepickerDayView].active:not(.bg-primary) {
background: var(--primary-color-dark-shade) !important;
outline: 1px solid var(--primary-color-dark-shade) !important;
}

View File

@ -7,7 +7,7 @@ import {
inject,
Input,
OnInit,
Output,
Output, ViewChild,
} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {FilterStatement} from '../../../_models/metadata/v2/filter-statement';
@ -25,14 +25,38 @@ import {FilterComparisonPipe} from "../../_pipes/filter-comparison.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {Select2Module, Select2Option} from "ng-select2-component";
import {TagBadgeComponent} from "../../../shared/tag-badge/tag-badge.component";
import {
NgbDate,
NgbDateParserFormatter,
NgbDatepicker,
NgbDateStruct,
NgbInputDatepicker,
NgbTooltip
} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@ngneat/transloco";
enum PredicateType {
Text = 1,
Number = 2,
Dropdown = 3,
Boolean = 4
Boolean = 4,
Date = 5
}
class FilterRowUi {
unit = '';
tooltip = ''
constructor(unit: string = '', tooltip: string = '') {
this.unit = unit;
this.tooltip = tooltip;
}
}
const unitLabels: Map<FilterField, FilterRowUi> = new Map([
[FilterField.ReadingDate, new FilterRowUi('unit-reading-date')],
[FilterField.ReadProgress, new FilterRowUi('unit-reading-progress')],
]);
const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath];
const NumberFields = [FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, FilterField.UserRating];
const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating,
@ -42,7 +66,8 @@ const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, Fi
FilterField.Writers, FilterField.Genres, FilterField.Libraries,
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags
];
const BooleanFields = [FilterField.WantToRead]
const BooleanFields = [FilterField.WantToRead];
const DateFields = [FilterField.ReadingDate];
const DropdownFieldsWithoutMustContains = [
FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus
@ -59,7 +84,8 @@ const StringComparisons = [FilterComparison.Equal,
FilterComparison.BeginsWith,
FilterComparison.EndsWith,
FilterComparison.Matches];
const DateComparisons = [FilterComparison.IsBefore, FilterComparison.IsAfter, FilterComparison.IsInLast, FilterComparison.IsNotInLast];
const DateComparisons = [FilterComparison.IsBefore, FilterComparison.IsAfter, FilterComparison.Equal,
FilterComparison.NotEqual,];
const NumberComparisons = [FilterComparison.Equal,
FilterComparison.NotEqual,
FilterComparison.LessThan,
@ -91,7 +117,11 @@ const BooleanComparisons = [
NgIf,
Select2Module,
NgTemplateOutlet,
TagBadgeComponent
TagBadgeComponent,
NgbTooltip,
TranslocoDirective,
NgbDatepicker,
NgbInputDatepicker
],
changeDetection: ChangeDetectionStrategy.OnPush
})
@ -105,8 +135,10 @@ export class MetadataFilterRowComponent implements OnInit {
@Input() availableFields: Array<FilterField> = allFields;
@Output() filterStatement = new EventEmitter<FilterStatement>();
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly dateParser = inject(NgbDateParserFormatter);
formGroup: FormGroup = new FormGroup({
'comparison': new FormControl<FilterComparison>(FilterComparison.Equal, []),
@ -119,6 +151,12 @@ export class MetadataFilterRowComponent implements OnInit {
loaded: boolean = false;
protected readonly PredicateType = PredicateType;
get UiLabel(): FilterRowUi | null {
const field = parseInt(this.formGroup.get('input')!.value, 10) as FilterField;
if (!unitLabels.has(field)) return null;
return unitLabels.get(field) as FilterRowUi;
}
get MultipleDropdownAllowed() {
const comp = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison;
return comp === FilterComparison.Contains || comp === FilterComparison.NotContains || comp === FilterComparison.MustContains;
@ -149,30 +187,36 @@ export class MetadataFilterRowComponent implements OnInit {
this.formGroup!.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe(_ => {
const stmt = {
comparison: parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison,
field: parseInt(this.formGroup.get('input')?.value, 10) as FilterField,
value: this.formGroup.get('filterValue')?.value!
};
// 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 + '';
}
if (typeof stmt.value === 'boolean') {
stmt.value = stmt.value + '';
}
if (!stmt.value && (stmt.field !== FilterField.SeriesName && !BooleanFields.includes(stmt.field))) return;
this.filterStatement.emit(stmt);
this.propagateFilterUpdate();
});
this.loaded = true;
this.cdRef.markForCheck();
}
propagateFilterUpdate() {
const stmt = {
comparison: parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison,
field: parseInt(this.formGroup.get('input')?.value, 10) as FilterField,
value: this.formGroup.get('filterValue')?.value!
};
if (typeof stmt.value === 'object' && DateFields.includes(stmt.field)) {
stmt.value = this.dateParser.format(stmt.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 + '';
}
if (typeof stmt.value === 'boolean') {
stmt.value = stmt.value + '';
}
if (!stmt.value && (![FilterField.SeriesName, FilterField.Summary].includes(stmt.field) && !BooleanFields.includes(stmt.field))) return;
this.filterStatement.emit(stmt);
}
populateFromPreset() {
const val = this.preset.value === "undefined" || !this.preset.value ? '' : this.preset.value;
@ -183,7 +227,10 @@ export class MetadataFilterRowComponent implements OnInit {
this.formGroup.get('filterValue')?.patchValue(val);
} else if (BooleanFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(val);
} else if (DropdownFields.includes(this.preset.field)) {
} else if (DateFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(this.dateParser.parse(val)); // TODO: Figure out how this works
}
else if (DropdownFields.includes(this.preset.field)) {
if (this.MultipleDropdownAllowed || val.includes(',')) {
this.formGroup.get('filterValue')?.patchValue(val.split(',').map(d => parseInt(d, 10)));
} else {
@ -281,6 +328,16 @@ export class MetadataFilterRowComponent implements OnInit {
return;
}
if (DateFields.includes(inputVal)) {
this.validComparisons$.next(DateComparisons);
this.predicateType$.next(PredicateType.Date);
if (this.loaded) {
this.formGroup.get('filterValue')?.patchValue(false);
}
return;
}
if (BooleanFields.includes(inputVal)) {
this.validComparisons$.next(BooleanComparisons);
this.predicateType$.next(PredicateType.Boolean);
@ -306,4 +363,15 @@ export class MetadataFilterRowComponent implements OnInit {
}
}
onDateSelect(event: NgbDate) {
console.log('date selected: ', event);
this.propagateFilterUpdate();
}
updateIfDateFilled() {
console.log('date inputted: ', this.formGroup.get('filterValue')?.value);
this.propagateFilterUpdate();
}
}

View File

@ -64,6 +64,8 @@ export class FilterFieldPipe implements PipeTransform {
return translate('filter-field-pipe.file-path');
case FilterField.WantToRead:
return translate('filter-field-pipe.want-to-read');
case FilterField.ReadingDate:
return translate('filter-field-pipe.read-date');
default:
throw new Error(`Invalid FilterField value: ${value}`);
}

View File

@ -1,12 +1,12 @@
<ng-container *transloco="let t; read: 'metadata-filter'">
<ng-container *ngIf="toggleService.toggleState$ | async as isOpen">
<div class="phone-hidden" *ngIf="utilityService.getActiveBreakpoint() > Breakpoint.Tablet">
<div *ngIf="utilityService.getActiveBreakpoint() >= Breakpoint.Tablet">
<div #collapse="ngbCollapse" [ngbCollapse]="!isOpen" (ngbCollapseChange)="setToggle($event)">
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
</div>
</div>
<div class="not-phone-hidden" *ngIf="utilityService.getActiveBreakpoint() < Breakpoint.Desktop">
<div *ngIf="utilityService.getActiveBreakpoint() < Breakpoint.Desktop">
<app-drawer #commentDrawer="drawer" [isOpen]="isOpen" [options]="{topOffset: 56}" (drawerClosed)="toggleService.set(false)">
<h5 header>
{{t('filter-title')}}
@ -51,6 +51,16 @@
<div class="col-md-3 col-sm-10">
<label for="filter-name" class="form-label">{{t('filter-name-label')}}</label>
<input id="filter-name" type="text" class="form-control" formControlName="name">
<!-- <select2 [data]="smartFilters"-->
<!-- id="filter-name"-->
<!-- formControlName="name"-->
<!-- (update)="updateFilterValue($event)"-->
<!-- [autoCreate]="true"-->
<!-- [multiple]="false"-->
<!-- [infiniteScroll]="false"-->
<!-- [hideSelectedItems]="true"-->
<!-- [resettable]="true">-->
<!-- </select2>-->
</div>
<ng-container *ngIf="utilityService.getActiveBreakpoint() > Breakpoint.Tablet" [ngTemplateOutlet]="buttons"></ng-container>
@ -63,15 +73,13 @@
</ng-template>
<ng-template #buttons>
<!-- TODO: I might want to put a Clear button which blanks out the whole filter -->
<div class="col-md-1 col-sm-6 mt-4 pt-1">
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
<div class="col-md-2 col-sm-6 mt-4 pt-2 d-flex justify-content-between">
<button class="btn btn-secondary col-6 me-1" (click)="clear()"><i class="fa-solid fa-arrow-rotate-left me-1" aria-hidden="true"></i>{{t('reset')}}</button>
<button class="btn btn-primary col-6" (click)="apply()"><i class="fa-solid fa-play me-1" aria-hidden="true"></i>{{t('apply')}}</button>
</div>
<div class="col-md-1 col-sm-6 mt-4 pt-1">
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
</div>
<div class="col-md-1 col-sm-6 mt-4 pt-1">
<div class="col-md-1 col-sm-6 mt-4 pt-2">
<button class="btn btn-primary col-12" (click)="save()" [disabled]="filterSettings.saveDisabled || !this.sortGroup.get('name')?.value">
<!-- TODO: Icon here -->
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
{{t('save')}}
</button>
</div>

View File

@ -30,6 +30,8 @@ import {MetadataService} from "../_services/metadata.service";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
import {FilterService} from "../_services/filter.service";
import {ToastrService} from "ngx-toastr";
import {Select2Module, Select2Option, Select2UpdateEvent} from "ng-select2-component";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
@Component({
selector: 'app-metadata-filter',
@ -38,7 +40,7 @@ import {ToastrService} from "ngx-toastr";
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgbCollapse, NgTemplateOutlet, DrawerComponent, NgbTooltip, TypeaheadComponent,
ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe, MetadataBuilderComponent, NgForOf]
ReactiveFormsModule, FormsModule, NgbRating, AsyncPipe, TranslocoModule, SortFieldPipe, MetadataBuilderComponent, NgForOf, Select2Module]
})
export class MetadataFilterComponent implements OnInit {
@ -78,16 +80,22 @@ export class MetadataFilterComponent implements OnInit {
allSortFields = allSortFields;
allFilterFields = allFields;
handleFilters(filter: SeriesFilterV2) {
this.filterV2 = filter;
}
smartFilters!: Array<Select2Option>;
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
constructor(public toggleService: ToggleService, private filterService: FilterService) {}
constructor(public toggleService: ToggleService, private filterService: FilterService) {
this.filterService.getAllFilters().subscribe(res => {
this.smartFilters = res.map(r => {
return {
value: r,
label: r.name,
}
});
});
}
ngOnInit(): void {
if (this.filterSettings === undefined) {
@ -106,6 +114,11 @@ export class MetadataFilterComponent implements OnInit {
this.loadFromPresetsAndSetup();
}
updateFilterValue(event: Select2UpdateEvent<any>) {
console.log('event: ', event);
}
close() {
this.filterOpen.emit(false);
this.filteringCollapsed = true;
@ -137,6 +150,10 @@ export class MetadataFilterComponent implements OnInit {
return clonedObj;
}
handleFilters(filter: SeriesFilterV2) {
this.filterV2 = filter;
}
loadFromPresetsAndSetup() {
this.fullyLoaded = false;
@ -187,7 +204,7 @@ export class MetadataFilterComponent implements OnInit {
apply() {
this.applyFilter.emit({isFirst: this.updateApplied === 0, filterV2: this.filterV2!});
if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile && this.updateApplied !== 0) {
this.toggleSelected();
}

View File

@ -14,17 +14,19 @@ export class SortFieldPipe implements PipeTransform {
transform(value: SortField): string {
switch (value) {
case SortField.SortName:
return this.translocoService.translate('sort-field-pipe.sort-name')
return this.translocoService.translate('sort-field-pipe.sort-name');
case SortField.Created:
return this.translocoService.translate('sort-field-pipe.created')
return this.translocoService.translate('sort-field-pipe.created');
case SortField.LastModified:
return this.translocoService.translate('sort-field-pipe.last-modified')
return this.translocoService.translate('sort-field-pipe.last-modified');
case SortField.LastChapterAdded:
return this.translocoService.translate('sort-field-pipe.last-chapter-added')
return this.translocoService.translate('sort-field-pipe.last-chapter-added');
case SortField.TimeToRead:
return this.translocoService.translate('sort-field-pipe.time-to-read')
return this.translocoService.translate('sort-field-pipe.time-to-read');
case SortField.ReleaseYear:
return this.translocoService.translate('sort-field-pipe.release-year')
return this.translocoService.translate('sort-field-pipe.release-year');
case SortField.ReadProgress:
return this.translocoService.translate('sort-field-pipe.read-progress');
}
}

View File

@ -74,7 +74,7 @@
<button ngbDropdownItem (click)="read(true)">
<span>
<i class="fa fa-glasses" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue') : t('read')}} Incognito</span>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue-incognito') : t('read-incognito')}}</span>
</span>
</button>
</div>
@ -101,7 +101,7 @@
</div>
<div class="col-auto ms-2 d-none d-md-block" *ngIf="isAdmin || hasDownloadingRole">
<button class="btn btn-secondary" (click)="downloadSeries()" title="Download Series" [disabled]="downloadInProgress">
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="downloadInProgress">
<ng-container *ngIf="downloadInProgress; else notDownloading">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">{{t('downloading-status')}}</span>

View File

@ -681,6 +681,8 @@
"continue-from": "Continue {{title}}",
"read": "{{common.read}}",
"continue": "Continue",
"read-incognito": "Read Incognito",
"continue-incognito": "Continue Incognito",
"read-options-alt": "Read options",
"incognito": "Incognito",
"remove-from-want-to-read": "Remove from Want to Read",
@ -1509,7 +1511,7 @@
"reset": "{{common.reset}}",
"apply": "{{common.apply}}",
"save": "{{common.save}}",
"limit-label": "Limit To",
"limit-label": "Limit",
"format-label": "Format",
"libraries-label": "Libraries",
@ -1541,13 +1543,19 @@
"max": "Max"
},
"metadata-filter-row": {
"unit-reading-date": "Date",
"unit-reading-progress": "Percent"
},
"sort-field-pipe": {
"sort-name": "Sort Name",
"created": "Created",
"last-modified": "Last Modified",
"last-chapter-added": "Item Added",
"time-to-read": "Time to Read",
"release-year": "Release Year"
"release-year": "Release Year",
"read-progress": "Read Progress"
},
"edit-series-modal": {
@ -1751,7 +1759,8 @@
"writers": "Writers",
"path": "Path",
"file-path": "File Path",
"want-to-read": "Want to Read"
"want-to-read": "Want to Read",
"read-date": "Reading Date"
},
"filter-comparison-pipe": {

View File

@ -1,27 +0,0 @@
#!/bin/bash
mkdir Projects
cd Projects
git clone https://github.com/Kareadita/Kavita.git
git clone https://github.com/Kareadita/Kavita-webui.git
cd Kavita
chmod +x build.sh
#Builds program based on the target platform
if [ "$TARGETPLATFORM" == "linux/amd64" ]
then
./build.sh linux-x64
mv /Projects/Kavita/_output/linux-x64 /Projects/Kavita/_output/build
elif [ "$TARGETPLATFORM" == "linux/arm/v7" ]
then
./build.sh linux-arm
mv /Projects/Kavita/_output/linux-arm /Projects/Kavita/_output/build
elif [ "$TARGETPLATFORM" == "linux/arm64" ]
then
./build.sh linux-arm64
mv /Projects/Kavita/_output/linux-arm64 /Projects/Kavita/_output/build
fi

View File

@ -15,9 +15,9 @@ ProgressEnd()
Build()
{
local RID="$1"
local RID="$1"
ProgressStart 'Build for $RID'
ProgressStart "Build for $RID"
slnFile=Kavita.sln
@ -26,7 +26,7 @@ Build()
dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform="Any CPU" -p:RuntimeIdentifiers=$RID
ProgressEnd 'Build for $RID'
ProgressEnd "Build for $RID"
}
BuildUI()
@ -54,17 +54,16 @@ BuildUI()
Package()
{
local framework="$1"
local runtime="$2"
local runtime="$1"
local lOutputFolder=../_output/"$runtime"/Kavita
ProgressStart "Creating $runtime Package for $framework"
ProgressStart "Creating $runtime Package"
# TODO: Use no-restore? Because Build should have already done it for us
echo "Building"
cd API
echo dotnet publish -c Release --no-restore --self-contained --runtime $runtime -o "$lOutputFolder" --framework $framework
dotnet publish -c Release --no-restore --self-contained --runtime $runtime -o "$lOutputFolder" --framework $framework
echo dotnet publish -c Release --no-restore --self-contained --runtime $runtime -o "$lOutputFolder"
dotnet publish -c Release --no-restore --self-contained --runtime $runtime -o "$lOutputFolder"
echo "Copying Install information"
cp ../INSTALL.txt "$lOutputFolder"/README.txt
@ -79,7 +78,7 @@ Package()
cd ../$outputFolder/"$runtime"/
tar -czvf ../kavita-$runtime.tar.gz Kavita
ProgressEnd "Creating $runtime Package for $framework"
ProgressEnd "Creating $runtime Package"
}
@ -94,17 +93,17 @@ BuildUI
#Build for x64
Build "linux-x64"
Package "net6.0" "linux-x64"
Package "linux-x64"
cd "$dir"
#Build for arm
Build "linux-arm"
Package "net6.0" "linux-arm"
Package "linux-arm"
cd "$dir"
#Build for arm64
Build "linux-arm64"
Package "net6.0" "linux-arm64"
Package "linux-arm64"
cd "$dir"
#Builds Docker images

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.8.2"
"version": "0.7.8.4"
},
"servers": [
{
@ -14142,7 +14142,8 @@
23,
24,
25,
26
26,
27
],
"type": "integer",
"description": "Represents the field which will dictate the value type and the Extension used for filtering",