mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
Refactor sidebar filter
This commit is contained in:
@@ -18,7 +18,7 @@ import {Router} from '@angular/router';
|
||||
import {RouterLink} from '@angular/router';
|
||||
import {ProgressBar} from 'primeng/progressbar';
|
||||
import {take, takeUntil} from 'rxjs/operators';
|
||||
import {readStatusLabels} from '../book-filter/book-filter.component';
|
||||
import {readStatusLabels} from '../book-filter/book-filter.config';
|
||||
import {ResetProgressTypes} from '../../../../../shared/constants/reset-progress-type';
|
||||
import {ReadStatusHelper} from '../../../helpers/read-status.helper';
|
||||
import {BookDialogHelperService} from '../book-dialog-helper.service';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Injectable, inject} from '@angular/core';
|
||||
import {Observable, BehaviorSubject} from 'rxjs';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {Observable} from 'rxjs';
|
||||
import {map, switchMap} from 'rxjs/operators';
|
||||
import {BookState} from '../../model/state/book-state.model';
|
||||
import {SortOption} from '../../model/sort.model';
|
||||
@@ -7,7 +7,6 @@ import {SortService} from '../../service/sort.service';
|
||||
import {HeaderFilter} from './filters/HeaderFilter';
|
||||
import {SideBarFilter} from './filters/SidebarFilter';
|
||||
import {SeriesCollapseFilter} from './filters/SeriesCollapseFilter';
|
||||
import {BookFilterMode} from '../../../settings/user-management/user.service';
|
||||
import {ParamMap} from '@angular/router';
|
||||
import {QUERY_PARAMS} from './book-browser-query-params.service';
|
||||
|
||||
@@ -39,21 +38,6 @@ export class BookFilterOrchestrationService {
|
||||
|
||||
shouldForceExpandSeries(queryParamMap: ParamMap): boolean {
|
||||
const filterParam = queryParamMap.get(QUERY_PARAMS.FILTER);
|
||||
return (
|
||||
!!filterParam &&
|
||||
typeof filterParam === 'string' &&
|
||||
filterParam.split(',').some(pair => pair.trim().startsWith('series:'))
|
||||
);
|
||||
}
|
||||
|
||||
createHeaderFilter(searchTerm$: BehaviorSubject<string>): HeaderFilter {
|
||||
return new HeaderFilter(searchTerm$);
|
||||
}
|
||||
|
||||
createSideBarFilter(
|
||||
selectedFilter$: BehaviorSubject<Record<string, string[]> | null>,
|
||||
selectedFilterMode$: BehaviorSubject<BookFilterMode>
|
||||
): SideBarFilter {
|
||||
return new SideBarFilter(selectedFilter$, selectedFilterMode$);
|
||||
return (!!filterParam && filterParam.split(',').some(pair => pair.trim().startsWith('series:')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +1,20 @@
|
||||
import {ChangeDetectionStrategy, Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output} from '@angular/core';
|
||||
import {combineLatest, filter, Observable, of, shareReplay, Subject, takeUntil} from 'rxjs';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {BookService} from '../../../service/book.service';
|
||||
import {Observable, of, Subject, takeUntil} from 'rxjs';
|
||||
import {Library} from '../../../model/library.model';
|
||||
import {Shelf} from '../../../model/shelf.model';
|
||||
import {EntityType} from '../book-browser.component';
|
||||
import {Book, ReadStatus} from '../../../model/book.model';
|
||||
import {Accordion, AccordionContent, AccordionHeader, AccordionPanel} from 'primeng/accordion';
|
||||
import {CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport} from '@angular/cdk/scrolling';
|
||||
import {AsyncPipe, NgClass, TitleCasePipe} from '@angular/common';
|
||||
import {Badge} from 'primeng/badge';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {SelectButton} from 'primeng/selectbutton';
|
||||
import {BookFilterMode, FilterSortingMode, UserService, UserState} from '../../../../settings/user-management/user.service';
|
||||
import {BookFilterMode} from '../../../../settings/user-management/user.service';
|
||||
import {MagicShelf} from '../../../../magic-shelf/service/magic-shelf.service';
|
||||
import {BookRuleEvaluatorService} from '../../../../magic-shelf/service/book-rule-evaluator.service';
|
||||
import {GroupRule} from '../../../../magic-shelf/component/magic-shelf-component';
|
||||
import {Filter, FILTER_LABELS, FilterType} from './book-filter.config';
|
||||
import {BookFilterService} from './book-filter.service';
|
||||
|
||||
export interface FilterValue {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
sortIndex?: number;
|
||||
}
|
||||
|
||||
export interface Filter<T extends FilterValue = FilterValue> {
|
||||
value: T;
|
||||
bookCount: number;
|
||||
}
|
||||
|
||||
export type FilterType =
|
||||
| 'author'
|
||||
| 'category'
|
||||
| 'series'
|
||||
| 'publisher'
|
||||
| 'readStatus'
|
||||
| 'personalRating'
|
||||
| 'publishedDate'
|
||||
| 'matchScore'
|
||||
| 'mood'
|
||||
| 'tag'
|
||||
| 'language'
|
||||
| 'bookType'
|
||||
| 'fileSize'
|
||||
| 'pageCount'
|
||||
| 'amazonRating'
|
||||
| 'goodreadsRating';
|
||||
|
||||
export const ratingRanges = [
|
||||
{id: '0to1', label: '0 to 1', min: 0, max: 1, sortIndex: 0},
|
||||
{id: '1to2', label: '1 to 2', min: 1, max: 2, sortIndex: 1},
|
||||
{id: '2to3', label: '2 to 3', min: 2, max: 3, sortIndex: 2},
|
||||
{id: '3to4', label: '3 to 4', min: 3, max: 4, sortIndex: 3},
|
||||
{id: '4to4.5', label: '4 to 4.5', min: 4, max: 4.5, sortIndex: 4},
|
||||
{id: '4.5plus', label: '4.5+', min: 4.5, max: Infinity, sortIndex: 5}
|
||||
];
|
||||
|
||||
export const ratingOptions10 = Array.from({length: 10}, (_, i) => ({
|
||||
id: `${i + 1}`,
|
||||
label: `${i + 1}`,
|
||||
value: i + 1,
|
||||
sortIndex: i
|
||||
}));
|
||||
|
||||
export const fileSizeRanges = [
|
||||
{id: '<1mb', label: '< 1 MB', min: 0, max: 1024, sortIndex: 0},
|
||||
{id: '1to10mb', label: '1–10 MB', min: 1024, max: 10240, sortIndex: 1},
|
||||
{id: '10to50mb', label: '10–50 MB', min: 10240, max: 51200, sortIndex: 2},
|
||||
{id: '50to100mb', label: '50–100 MB', min: 51200, max: 102400, sortIndex: 3},
|
||||
{id: '250to500mb', label: '250–500 MB', min: 256000, max: 512000, sortIndex: 4},
|
||||
{id: '500mbto1gb', label: '0.5–1 GB', min: 512000, max: 1048576, sortIndex: 5},
|
||||
{id: '1to2gb', label: '1–2 GB', min: 1048576, max: 2097152, sortIndex: 6},
|
||||
{id: '5plusgb', label: '5+ GB', min: 5242880, max: Infinity, sortIndex: 7}
|
||||
];
|
||||
|
||||
export const pageCountRanges = [
|
||||
{id: '<50', label: '< 50 pages', min: 0, max: 50, sortIndex: 0},
|
||||
{id: '50to100', label: '50–100 pages', min: 50, max: 100, sortIndex: 1},
|
||||
{id: '100to200', label: '100–200 pages', min: 100, max: 200, sortIndex: 2},
|
||||
{id: '200to400', label: '200–400 pages', min: 200, max: 400, sortIndex: 3},
|
||||
{id: '400to600', label: '400–600 pages', min: 400, max: 600, sortIndex: 4},
|
||||
{id: '600to1000', label: '600–1000 pages', min: 600, max: 1000, sortIndex: 5},
|
||||
{id: '1000plus', label: '1000+ pages', min: 1000, max: Infinity, sortIndex: 6}
|
||||
];
|
||||
|
||||
export const matchScoreRanges = [
|
||||
{id: '0.95-1.0', min: 0.95, max: 1.01, label: 'Outstanding (95–100%)', sortIndex: 0},
|
||||
{id: '0.90-0.94', min: 0.90, max: 0.95, label: 'Excellent (90–94%)', sortIndex: 1},
|
||||
{id: '0.80-0.89', min: 0.80, max: 0.90, label: 'Great (80–89%)', sortIndex: 2},
|
||||
{id: '0.70-0.79', min: 0.70, max: 0.80, label: 'Good (70–79%)', sortIndex: 3},
|
||||
{id: '0.50-0.69', min: 0.50, max: 0.70, label: 'Fair (50–69%)', sortIndex: 4},
|
||||
{id: '0.30-0.49', min: 0.30, max: 0.50, label: 'Weak (30–49%)', sortIndex: 5},
|
||||
{id: '0.00-0.29', min: 0.00, max: 0.30, label: 'Poor (0–29%)', sortIndex: 6}
|
||||
];
|
||||
|
||||
function getLanguageFilter(book: Book): { id: string; name: string }[] {
|
||||
const lang = book.metadata?.language;
|
||||
return lang ? [{id: lang, name: lang}] : [];
|
||||
}
|
||||
|
||||
function getFileSizeRangeFilters(sizeKb?: number): { id: string; name: string; sortIndex?: number }[] {
|
||||
if (sizeKb == null) return [];
|
||||
const match = fileSizeRanges.find(r => sizeKb >= r.min && sizeKb < r.max);
|
||||
return match ? [{id: match.id, name: match.label, sortIndex: match.sortIndex}] : [];
|
||||
}
|
||||
|
||||
function getRatingRangeFilters(rating?: number): { id: string; name: string; sortIndex?: number }[] {
|
||||
if (rating == null) return [];
|
||||
const match = ratingRanges.find(r => rating >= r.min && rating < r.max);
|
||||
return match ? [{id: match.id, name: match.label, sortIndex: match.sortIndex}] : [];
|
||||
}
|
||||
|
||||
function getRatingRangeFilters10(rating?: number): { id: string; name: string; sortIndex?: number }[] {
|
||||
if (!rating || rating < 1 || rating > 10) return [];
|
||||
const idx = ratingOptions10.find(r => r.value === rating || +r.id === rating);
|
||||
return idx ? [{id: idx.id, name: idx.label, sortIndex: idx.sortIndex}] : [];
|
||||
}
|
||||
|
||||
function extractPublishedYearFilter(book: Book): { id: string; name: string }[] {
|
||||
const date = book.metadata?.publishedDate;
|
||||
if (!date) return [];
|
||||
const year = new Date(date).getFullYear();
|
||||
return [{id: year.toString(), name: year.toString()}];
|
||||
}
|
||||
|
||||
function getShelfStatusFilter(book: Book): { id: string; name: string }[] {
|
||||
const isShelved = (book.shelves?.length ?? 0) > 0;
|
||||
return [{id: isShelved ? 'shelved' : 'unshelved', name: isShelved ? 'Shelved' : 'Unshelved'}];
|
||||
}
|
||||
|
||||
function getPageCountRangeFilters(pageCount?: number): { id: string; name: string; sortIndex?: number }[] {
|
||||
if (pageCount == null) return [];
|
||||
const match = pageCountRanges.find(r => pageCount >= r.min && pageCount < r.max);
|
||||
return match ? [{id: match.id, name: match.label, sortIndex: match.sortIndex}] : [];
|
||||
}
|
||||
|
||||
function getMatchScoreRangeFilters(score?: number | null): { id: string; name: string; sortIndex?: number }[] {
|
||||
if (score == null) return [];
|
||||
const normalizedScore = score > 1 ? score / 100 : score;
|
||||
const match = matchScoreRanges.find(r => normalizedScore >= r.min && normalizedScore < r.max);
|
||||
return match ? [{id: match.id, name: match.label, sortIndex: match.sortIndex}] : [];
|
||||
}
|
||||
|
||||
function getBookTypeFilter(book: Book): { id: string; name: string }[] {
|
||||
return book.primaryFile?.bookType ? [{id: book.primaryFile.bookType, name: book.primaryFile.bookType}] : [];
|
||||
}
|
||||
|
||||
export const readStatusLabels: Record<ReadStatus, string> = {
|
||||
[ReadStatus.UNREAD]: 'Unread',
|
||||
[ReadStatus.READING]: 'Reading',
|
||||
[ReadStatus.RE_READING]: 'Re-reading',
|
||||
[ReadStatus.PARTIALLY_READ]: 'Partially Read',
|
||||
[ReadStatus.PAUSED]: 'Paused',
|
||||
[ReadStatus.READ]: 'Read',
|
||||
[ReadStatus.WONT_READ]: 'Won’t Read',
|
||||
[ReadStatus.ABANDONED]: 'Abandoned',
|
||||
[ReadStatus.UNSET]: 'Unset'
|
||||
};
|
||||
|
||||
function getReadStatusName(status?: ReadStatus | null): string {
|
||||
return status != null ? readStatusLabels[status] ?? 'Unset' : 'Unset';
|
||||
}
|
||||
type FilterModeOption = { label: string; value: BookFilterMode };
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-filter',
|
||||
@@ -168,333 +23,172 @@ function getReadStatusName(status?: ReadStatus | null): string {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
Accordion,
|
||||
AccordionPanel,
|
||||
AccordionHeader,
|
||||
AccordionContent,
|
||||
CdkVirtualScrollViewport,
|
||||
CdkFixedSizeVirtualScroll,
|
||||
CdkVirtualForOf,
|
||||
NgClass,
|
||||
Badge,
|
||||
AsyncPipe,
|
||||
TitleCasePipe,
|
||||
FormsModule,
|
||||
SelectButton
|
||||
Accordion, AccordionPanel, AccordionHeader, AccordionContent,
|
||||
CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf,
|
||||
NgClass, Badge, AsyncPipe, TitleCasePipe, FormsModule, SelectButton
|
||||
]
|
||||
})
|
||||
export class BookFilterComponent implements OnInit, OnDestroy {
|
||||
private filterChangeSubject = new Subject<Record<string, unknown> | null>();
|
||||
@Input() entity$: Observable<Library | Shelf | MagicShelf | null> | undefined;
|
||||
@Input() entityType$: Observable<EntityType> | undefined;
|
||||
@Input() resetFilter$!: Subject<void>;
|
||||
@Input() showFilter = false;
|
||||
|
||||
@Output() filterSelected = new EventEmitter<Record<string, unknown> | null>();
|
||||
@Output() filterModeChanged = new EventEmitter<BookFilterMode>();
|
||||
|
||||
@Input() entity$!: Observable<Library | Shelf | MagicShelf | null> | undefined;
|
||||
@Input() entityType$!: Observable<EntityType> | undefined;
|
||||
@Input() resetFilter$!: Subject<void>;
|
||||
@Input() showFilter: boolean = false;
|
||||
private readonly filterService = inject(BookFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
activeFilters: Record<string, unknown[]> = {};
|
||||
filterStreams: Record<FilterType, Observable<Filter[]>> = {} as Record<FilterType, Observable<Filter[]>>;
|
||||
truncatedFilters: Record<string, boolean> = {};
|
||||
filterTypes: FilterType[] = [];
|
||||
filterModeOptions = [
|
||||
expandedPanels: number[] = [0];
|
||||
truncatedFilters: Record<string, boolean> = {};
|
||||
|
||||
private _selectedFilterMode: BookFilterMode = 'and';
|
||||
|
||||
readonly filterLabels = FILTER_LABELS;
|
||||
readonly filterModeOptions: FilterModeOption[] = [
|
||||
{label: 'AND', value: 'and'},
|
||||
{label: 'OR', value: 'or'},
|
||||
{label: '1', value: 'single'},
|
||||
{label: '1', value: 'single'}
|
||||
];
|
||||
private _selectedFilterMode: BookFilterMode = 'and';
|
||||
expandedPanels: number[] = [0];
|
||||
readonly filterLabels: Record<FilterType, string> = {
|
||||
author: 'Author',
|
||||
category: 'Genre',
|
||||
series: 'Series',
|
||||
publisher: 'Publisher',
|
||||
readStatus: 'Read Status',
|
||||
personalRating: 'Personal Rating',
|
||||
publishedDate: 'Published Year',
|
||||
matchScore: 'Metadata Match Score',
|
||||
mood: 'Mood',
|
||||
tag: 'Tag',
|
||||
language: 'Language',
|
||||
bookType: 'Book Type',
|
||||
fileSize: 'File Size',
|
||||
pageCount: 'Page Count',
|
||||
amazonRating: 'Amazon Rating',
|
||||
goodreadsRating: 'Goodreads Rating'
|
||||
};
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
bookService = inject(BookService);
|
||||
userService = inject(UserService);
|
||||
bookRuleEvaluatorService = inject(BookRuleEvaluatorService);
|
||||
userData$: Observable<UserState> = this.userService.userState$;
|
||||
filterSortingMode: FilterSortingMode = 'count';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.userData$.pipe(
|
||||
filter(userState => !!userState?.user && userState.loaded),
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(userState => {
|
||||
this.filterSortingMode = userState.user!.userSettings.filterSortingMode ?? 'count';
|
||||
});
|
||||
|
||||
combineLatest([
|
||||
this.entity$ ?? of(null),
|
||||
this.entityType$ ?? of(EntityType.ALL_BOOKS)
|
||||
])
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.filterStreams = {
|
||||
author: this.getFilterStream(
|
||||
(book: Book) => Array.isArray(book.metadata?.authors) ? book.metadata.authors.map(name => ({id: name, name})) : [],
|
||||
'id', 'name'
|
||||
),
|
||||
category: this.getFilterStream(
|
||||
(book: Book) => Array.isArray(book.metadata?.categories) ? book.metadata.categories.map(name => ({id: name, name})) : [],
|
||||
'id', 'name'
|
||||
),
|
||||
series: this.getFilterStream(
|
||||
(book) => (book.metadata?.seriesName ? [{id: book.metadata.seriesName, name: book.metadata.seriesName}] : []),
|
||||
'id', 'name'
|
||||
),
|
||||
publisher: this.getFilterStream(
|
||||
(book) => (book.metadata?.publisher ? [{id: book.metadata.publisher, name: book.metadata.publisher}] : []),
|
||||
'id', 'name'
|
||||
),
|
||||
readStatus: this.getFilterStream((book: Book) => {
|
||||
let status = book.readStatus;
|
||||
if (status == null || !(status in readStatusLabels)) {
|
||||
status = ReadStatus.UNSET;
|
||||
}
|
||||
return [{id: status, name: getReadStatusName(status)}];
|
||||
}, 'id', 'name'),
|
||||
personalRating: this.getFilterStream((book: Book) => getRatingRangeFilters10(book.personalRating!), 'id', 'name', 'sortIndex'),
|
||||
publishedDate: this.getFilterStream(extractPublishedYearFilter, 'id', 'name'),
|
||||
matchScore: this.getFilterStream((book: Book) => getMatchScoreRangeFilters(book.metadataMatchScore), 'id', 'name', 'sortIndex'),
|
||||
mood: this.getFilterStream(
|
||||
(book: Book) => Array.isArray(book.metadata?.moods) ? book.metadata.moods.map(name => ({id: name, name})) : [],
|
||||
'id', 'name'
|
||||
),
|
||||
tag: this.getFilterStream(
|
||||
(book: Book) => Array.isArray(book.metadata?.tags) ? book.metadata.tags.map(name => ({id: name, name})) : [],
|
||||
'id', 'name'
|
||||
),
|
||||
language: this.getFilterStream(getLanguageFilter, 'id', 'name'),
|
||||
bookType: this.getFilterStream(getBookTypeFilter, 'id', 'name'),
|
||||
fileSize: this.getFilterStream((book: Book) => getFileSizeRangeFilters(book.fileSizeKb), 'id', 'name', 'sortIndex'),
|
||||
pageCount: this.getFilterStream((book: Book) => getPageCountRangeFilters(book.metadata?.pageCount ?? undefined), 'id', 'name', 'sortIndex'),
|
||||
amazonRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.amazonRating ?? undefined), 'id', 'name', 'sortIndex'),
|
||||
goodreadsRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.goodreadsRating ?? undefined), 'id', 'name', 'sortIndex')
|
||||
};
|
||||
|
||||
this.filterTypes = Object.keys(this.filterStreams) as FilterType[];
|
||||
this.setExpandedPanels();
|
||||
});
|
||||
|
||||
this.filterChangeSubject.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(value => this.filterSelected.emit(value));
|
||||
|
||||
if (this.resetFilter$) {
|
||||
this.resetFilter$.pipe(takeUntil(this.destroy$)).subscribe(() => this.clearActiveFilter());
|
||||
}
|
||||
}
|
||||
|
||||
private getFilterStream<T extends FilterValue>(
|
||||
extractor: (book: Book) => T[] | undefined,
|
||||
idKey: keyof T,
|
||||
nameKey: keyof T,
|
||||
sortMode: FilterSortingMode | 'sortIndex' = this.filterSortingMode
|
||||
): Observable<Filter<T>[]> {
|
||||
return combineLatest([
|
||||
this.bookService.bookState$,
|
||||
this.entity$ ?? of(null),
|
||||
this.entityType$ ?? of(EntityType.ALL_BOOKS)
|
||||
]).pipe(
|
||||
map(([state, entity, entityType]) => {
|
||||
const filteredBooks = this.filterBooksByEntityType(state.books || [], entity, entityType);
|
||||
const filterMap = new Map<unknown, Filter<T>>();
|
||||
|
||||
filteredBooks.forEach((book) => {
|
||||
(extractor(book) || []).forEach((item) => {
|
||||
const id = item[idKey];
|
||||
if (!filterMap.has(id)) {
|
||||
filterMap.set(id, {value: item, bookCount: 0});
|
||||
}
|
||||
filterMap.get(id)!.bookCount += 1;
|
||||
});
|
||||
});
|
||||
|
||||
const result = Array.from(filterMap.values());
|
||||
|
||||
const sorted = result.sort((a, b) => {
|
||||
if (sortMode === 'sortIndex') {
|
||||
const aValue = a.value as { sortIndex?: number };
|
||||
const bValue = b.value as { sortIndex?: number };
|
||||
return (aValue.sortIndex ?? 999) - (bValue.sortIndex ?? 999);
|
||||
} else if (sortMode === 'count' && b.bookCount !== a.bookCount) {
|
||||
return b.bookCount - a.bookCount;
|
||||
}
|
||||
const aValue = a.value as Record<string, unknown>;
|
||||
const bValue = b.value as Record<string, unknown>;
|
||||
const aKey = String(aValue[String(nameKey)] ?? '');
|
||||
const bKey = String(bValue[String(nameKey)] ?? '');
|
||||
return aKey.localeCompare(bKey);
|
||||
});
|
||||
|
||||
const isTruncated = sorted.length > 100;
|
||||
const truncated = sorted.slice(0, 100);
|
||||
|
||||
return {items: truncated, isTruncated};
|
||||
}),
|
||||
map(({items, isTruncated}) => {
|
||||
setTimeout(() => {
|
||||
const filterType = Object.keys(this.filterStreams).find(key =>
|
||||
this.filterStreams[key as FilterType] === this.getFilterStream(extractor, idKey, nameKey)
|
||||
);
|
||||
if (filterType) {
|
||||
this.truncatedFilters[filterType] = isTruncated;
|
||||
}
|
||||
});
|
||||
return items as Filter<T>[];
|
||||
}),
|
||||
shareReplay({bufferSize: 1, refCount: true})
|
||||
);
|
||||
}
|
||||
|
||||
get selectedFilterMode(): BookFilterMode {
|
||||
return this._selectedFilterMode;
|
||||
}
|
||||
|
||||
set selectedFilterMode(mode: BookFilterMode) {
|
||||
if (mode !== this._selectedFilterMode) {
|
||||
this._selectedFilterMode = mode;
|
||||
this.filterModeChanged.emit(mode);
|
||||
this.filterChangeSubject.next(
|
||||
Object.keys(this.activeFilters).length ? {...this.activeFilters} : null
|
||||
);
|
||||
}
|
||||
if (mode === this._selectedFilterMode) return;
|
||||
this._selectedFilterMode = mode;
|
||||
this.filterModeChanged.emit(mode);
|
||||
this.emitFilters();
|
||||
}
|
||||
|
||||
private filterBooksByEntityType(books: Book[], entity: unknown, entityType: EntityType): Book[] {
|
||||
if (entityType === EntityType.LIBRARY && entity !== null && typeof entity === 'object' && 'id' in entity) {
|
||||
const libraryEntity = entity as { id: number };
|
||||
return books.filter((book) => book.libraryId === libraryEntity.id);
|
||||
}
|
||||
|
||||
if (entityType === EntityType.SHELF && entity !== null && typeof entity === 'object' && 'id' in entity) {
|
||||
const shelfEntity = entity as { id: number };
|
||||
return books.filter((book) => book.shelves?.some((shelf) => shelf.id === shelfEntity.id));
|
||||
}
|
||||
|
||||
if (entityType === EntityType.MAGIC_SHELF && entity !== null && typeof entity === 'object' && 'filterJson' in entity) {
|
||||
try {
|
||||
const magicShelfEntity = entity as { filterJson: string };
|
||||
const groupRule = JSON.parse(magicShelfEntity.filterJson) as GroupRule;
|
||||
return books.filter((book) => this.bookRuleEvaluatorService.evaluateGroup(book, groupRule));
|
||||
} catch (e) {
|
||||
console.warn('Invalid filterJson for MagicShelf:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return books;
|
||||
}
|
||||
|
||||
handleFilterClick(filterType: string, value: unknown): void {
|
||||
if (!this.activeFilters[filterType]) {
|
||||
this.activeFilters[filterType] = [];
|
||||
}
|
||||
|
||||
const filterArray = this.activeFilters[filterType];
|
||||
const index = filterArray.indexOf(value);
|
||||
if (index > -1) {
|
||||
if (this._selectedFilterMode == 'single') {
|
||||
this.activeFilters = {};
|
||||
} else {
|
||||
filterArray.splice(index, 1);
|
||||
if (filterArray.length === 0) {
|
||||
delete this.activeFilters[filterType];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this._selectedFilterMode == 'single') {
|
||||
this.activeFilters = {[filterType]: []};
|
||||
}
|
||||
this.activeFilters[filterType].push(value);
|
||||
}
|
||||
this.filterChangeSubject.next(Object.keys(this.activeFilters).length ? {...this.activeFilters} : null);
|
||||
}
|
||||
|
||||
setFilters(filters: Record<string, unknown>) {
|
||||
this.activeFilters = {};
|
||||
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (Array.isArray(value)) {
|
||||
this.activeFilters[key] = [...value];
|
||||
} else {
|
||||
this.activeFilters[key] = [value];
|
||||
}
|
||||
}
|
||||
this.filterChangeSubject.next({...this.activeFilters});
|
||||
}
|
||||
|
||||
clearActiveFilter() {
|
||||
this.activeFilters = {};
|
||||
this.expandedPanels = [0];
|
||||
this.filterChangeSubject.next(null);
|
||||
}
|
||||
|
||||
onExpandedPanelsChange(value: string | number | string[] | number[] | null | undefined): void {
|
||||
if (Array.isArray(value)) {
|
||||
this.expandedPanels = value.map(v => Number(v));
|
||||
}
|
||||
}
|
||||
|
||||
getVirtualScrollHeight(itemCount: number): number {
|
||||
return Math.min(itemCount * 28, 440);
|
||||
}
|
||||
|
||||
setExpandedPanels(): void {
|
||||
const current = new Set(this.expandedPanels);
|
||||
for (let i = 0; i < this.filterTypes.length; i++) {
|
||||
if (this.activeFilters[this.filterTypes[i]]?.length) {
|
||||
current.add(i);
|
||||
}
|
||||
}
|
||||
this.expandedPanels = current.size > 0 ? [...current] : [0];
|
||||
}
|
||||
|
||||
onFiltersChanged(): void {
|
||||
this.setExpandedPanels();
|
||||
}
|
||||
|
||||
trackByFilterType(_: number, type: FilterType): string {
|
||||
return type;
|
||||
}
|
||||
|
||||
trackByFilter(_: number, filter: Filter<FilterValue>): unknown {
|
||||
const value = filter.value as { id?: unknown } | unknown;
|
||||
return (typeof value === 'object' && value !== null && 'id' in value) ? (value as { id: unknown }).id : filter.value;
|
||||
}
|
||||
|
||||
getFilterValueId(filter: Filter<FilterValue>): unknown {
|
||||
const value = filter.value as { id?: unknown } | unknown;
|
||||
return (typeof value === 'object' && value !== null && 'id' in value) ? (value as { id: unknown }).id : filter.value;
|
||||
}
|
||||
|
||||
getFilterValueDisplay(filter: Filter<FilterValue>): string {
|
||||
const value = filter.value as { name?: string } | string | unknown;
|
||||
if (typeof value === 'object' && value !== null && 'name' in value) {
|
||||
return String((value as { name: string }).name ?? '');
|
||||
}
|
||||
return String(value ?? '');
|
||||
ngOnInit(): void {
|
||||
this.initializeFilterStreams();
|
||||
this.subscribeToReset();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
handleFilterClick(filterType: string, value: unknown): void {
|
||||
this._selectedFilterMode === 'single'
|
||||
? this.handleSingleMode(filterType, value)
|
||||
: this.handleMultiMode(filterType, value);
|
||||
this.emitFilters();
|
||||
}
|
||||
|
||||
setFilters(filters: Record<string, unknown>): void {
|
||||
this.activeFilters = {};
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
this.activeFilters[key] = values.map(v => this.filterService.processFilterValue(key, v));
|
||||
}
|
||||
this.emitFilters();
|
||||
}
|
||||
|
||||
clearActiveFilter(): void {
|
||||
this.activeFilters = {};
|
||||
this.expandedPanels = [0];
|
||||
this.filterSelected.emit(null);
|
||||
}
|
||||
|
||||
onExpandedPanelsChange(value: string | number | string[] | number[] | null | undefined): void {
|
||||
if (Array.isArray(value)) {
|
||||
this.expandedPanels = value.map(Number);
|
||||
}
|
||||
}
|
||||
|
||||
onFiltersChanged(): void {
|
||||
this.updateExpandedPanels();
|
||||
}
|
||||
|
||||
// Template Helpers
|
||||
getVirtualScrollHeight = (itemCount: number): number => Math.min(itemCount * 28, 440);
|
||||
|
||||
trackByFilterType = (_: number, type: FilterType): string => type;
|
||||
|
||||
trackByFilter = (_: number, filter: Filter): unknown => this.getFilterValueId(filter);
|
||||
|
||||
getFilterValueId(filter: Filter): unknown {
|
||||
const value = filter.value;
|
||||
return typeof value === 'object' && value !== null && 'id' in value
|
||||
? value.id
|
||||
: filter.value;
|
||||
}
|
||||
|
||||
getFilterValueDisplay(filter: Filter): string {
|
||||
const value = filter.value;
|
||||
if (typeof value === 'object' && value !== null && 'name' in value) {
|
||||
return String(value.name ?? '');
|
||||
}
|
||||
return String(value ?? '');
|
||||
}
|
||||
|
||||
private initializeFilterStreams(): void {
|
||||
const entity$ = this.entity$ ?? of(null);
|
||||
const entityType$ = this.entityType$ ?? of(EntityType.ALL_BOOKS);
|
||||
|
||||
this.filterStreams = this.filterService.createFilterStreams(entity$, entityType$);
|
||||
this.filterTypes = Object.keys(this.filterStreams) as FilterType[];
|
||||
this.updateExpandedPanels();
|
||||
}
|
||||
|
||||
private subscribeToReset(): void {
|
||||
this.resetFilter$?.pipe(takeUntil(this.destroy$)).subscribe(() => this.clearActiveFilter());
|
||||
}
|
||||
|
||||
private handleSingleMode(filterType: string, value: unknown): void {
|
||||
const id = this.extractId(value);
|
||||
const current = this.activeFilters[filterType];
|
||||
const isSame = current?.length === 1 && this.valuesMatch(current[0], id);
|
||||
|
||||
this.activeFilters = isSame ? {} : {[filterType]: [id]};
|
||||
}
|
||||
|
||||
private handleMultiMode(filterType: string, value: unknown): void {
|
||||
const id = this.extractId(value);
|
||||
|
||||
if (!this.activeFilters[filterType]) {
|
||||
this.activeFilters[filterType] = [];
|
||||
}
|
||||
|
||||
const arr = this.activeFilters[filterType];
|
||||
const index = arr.findIndex(v => this.valuesMatch(v, id));
|
||||
|
||||
if (index > -1) {
|
||||
arr.splice(index, 1);
|
||||
if (arr.length === 0) delete this.activeFilters[filterType];
|
||||
} else {
|
||||
arr.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
private extractId(value: unknown): unknown {
|
||||
return typeof value === 'object' && value !== null && 'id' in value
|
||||
? (value as { id: unknown }).id
|
||||
: value;
|
||||
}
|
||||
|
||||
private valuesMatch(a: unknown, b: unknown): boolean {
|
||||
return a === b || String(a) === String(b);
|
||||
}
|
||||
|
||||
private emitFilters(): void {
|
||||
const hasFilters = Object.keys(this.activeFilters).length > 0;
|
||||
this.filterSelected.emit(hasFilters ? {...this.activeFilters} : null);
|
||||
}
|
||||
|
||||
private updateExpandedPanels(): void {
|
||||
const panels = new Set(this.expandedPanels);
|
||||
this.filterTypes.forEach((type, i) => {
|
||||
if (this.activeFilters[type]?.length) panels.add(i);
|
||||
});
|
||||
this.expandedPanels = panels.size > 0 ? [...panels] : [0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import {Book, ReadStatus} from '../../../model/book.model';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface FilterValue {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
sortIndex?: number;
|
||||
}
|
||||
|
||||
export interface Filter<T extends FilterValue = FilterValue> {
|
||||
value: T;
|
||||
bookCount: number;
|
||||
}
|
||||
|
||||
export type FilterType =
|
||||
| 'author' | 'category' | 'series' | 'bookType' | 'readStatus'
|
||||
| 'personalRating' | 'publisher' | 'matchScore' | 'library' | 'shelf'
|
||||
| 'shelfStatus' | 'tag' | 'publishedDate' | 'fileSize' | 'amazonRating'
|
||||
| 'goodreadsRating' | 'hardcoverRating' | 'language' | 'pageCount' | 'mood';
|
||||
|
||||
export type SortMode = 'count' | 'sortIndex';
|
||||
|
||||
export interface RangeConfig {
|
||||
id: number;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
sortIndex: number;
|
||||
}
|
||||
|
||||
export interface FilterConfig {
|
||||
label: string;
|
||||
extractor: (book: Book) => FilterValue[];
|
||||
sortMode: SortMode;
|
||||
isNumericId?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
export const READ_STATUS_LABELS: Readonly<Record<ReadStatus, string>> = {
|
||||
[ReadStatus.UNREAD]: 'Unread',
|
||||
[ReadStatus.READING]: 'Reading',
|
||||
[ReadStatus.RE_READING]: 'Re-reading',
|
||||
[ReadStatus.PARTIALLY_READ]: 'Partially Read',
|
||||
[ReadStatus.PAUSED]: 'Paused',
|
||||
[ReadStatus.READ]: 'Read',
|
||||
[ReadStatus.WONT_READ]: 'Won\'t Read',
|
||||
[ReadStatus.ABANDONED]: 'Abandoned',
|
||||
[ReadStatus.UNSET]: 'Unset'
|
||||
};
|
||||
|
||||
export const RATING_RANGES_5: readonly RangeConfig[] = [
|
||||
{id: 0, label: '0 to 1', min: 0, max: 1, sortIndex: 0},
|
||||
{id: 1, label: '1 to 2', min: 1, max: 2, sortIndex: 1},
|
||||
{id: 2, label: '2 to 3', min: 2, max: 3, sortIndex: 2},
|
||||
{id: 3, label: '3 to 4', min: 3, max: 4, sortIndex: 3},
|
||||
{id: 4, label: '4 to 4.5', min: 4, max: 4.5, sortIndex: 4},
|
||||
{id: 5, label: '4.5+', min: 4.5, max: Infinity, sortIndex: 5}
|
||||
];
|
||||
|
||||
export const RATING_OPTIONS_10: readonly RangeConfig[] = Array.from({length: 10}, (_, i) => ({
|
||||
id: i + 1,
|
||||
label: `${i + 1}`,
|
||||
min: i + 1,
|
||||
max: i + 2,
|
||||
sortIndex: i
|
||||
}));
|
||||
|
||||
export const FILE_SIZE_RANGES: readonly RangeConfig[] = [
|
||||
{id: 0, label: '< 1 MB', min: 0, max: 1024, sortIndex: 0},
|
||||
{id: 1, label: '1–10 MB', min: 1024, max: 10240, sortIndex: 1},
|
||||
{id: 2, label: '10–50 MB', min: 10240, max: 51200, sortIndex: 2},
|
||||
{id: 3, label: '50–100 MB', min: 51200, max: 102400, sortIndex: 3},
|
||||
{id: 4, label: '250–500 MB', min: 256000, max: 512000, sortIndex: 4},
|
||||
{id: 5, label: '0.5–1 GB', min: 512000, max: 1048576, sortIndex: 5},
|
||||
{id: 6, label: '1–2 GB', min: 1048576, max: 2097152, sortIndex: 6},
|
||||
{id: 7, label: '5+ GB', min: 5242880, max: Infinity, sortIndex: 7}
|
||||
];
|
||||
|
||||
export const PAGE_COUNT_RANGES: readonly RangeConfig[] = [
|
||||
{id: 0, label: '< 50 pages', min: 0, max: 50, sortIndex: 0},
|
||||
{id: 1, label: '50–100 pages', min: 50, max: 100, sortIndex: 1},
|
||||
{id: 2, label: '100–200 pages', min: 100, max: 200, sortIndex: 2},
|
||||
{id: 3, label: '200–400 pages', min: 200, max: 400, sortIndex: 3},
|
||||
{id: 4, label: '400–600 pages', min: 400, max: 600, sortIndex: 4},
|
||||
{id: 5, label: '600–1000 pages', min: 600, max: 1000, sortIndex: 5},
|
||||
{id: 6, label: '1000+ pages', min: 1000, max: Infinity, sortIndex: 6}
|
||||
];
|
||||
|
||||
export const MATCH_SCORE_RANGES: readonly RangeConfig[] = [
|
||||
{id: 0, min: 0.95, max: 1.01, label: 'Outstanding (95–100%)', sortIndex: 0},
|
||||
{id: 1, min: 0.90, max: 0.95, label: 'Excellent (90–94%)', sortIndex: 1},
|
||||
{id: 2, min: 0.80, max: 0.90, label: 'Great (80–89%)', sortIndex: 2},
|
||||
{id: 3, min: 0.70, max: 0.80, label: 'Good (70–79%)', sortIndex: 3},
|
||||
{id: 4, min: 0.50, max: 0.70, label: 'Fair (50–69%)', sortIndex: 4},
|
||||
{id: 5, min: 0.30, max: 0.50, label: 'Weak (30–49%)', sortIndex: 5},
|
||||
{id: 6, min: 0.00, max: 0.30, label: 'Poor (0–29%)', sortIndex: 6}
|
||||
];
|
||||
|
||||
export const readStatusLabels = READ_STATUS_LABELS;
|
||||
export const ratingRanges = RATING_RANGES_5;
|
||||
export const ratingOptions10 = RATING_OPTIONS_10;
|
||||
export const fileSizeRanges = FILE_SIZE_RANGES;
|
||||
export const pageCountRanges = PAGE_COUNT_RANGES;
|
||||
export const matchScoreRanges = MATCH_SCORE_RANGES;
|
||||
|
||||
export const NUMERIC_ID_FILTER_TYPES = new Set<FilterType>([
|
||||
'personalRating', 'matchScore', 'fileSize', 'amazonRating',
|
||||
'goodreadsRating', 'hardcoverRating', 'pageCount', 'library', 'shelf'
|
||||
]);
|
||||
|
||||
export const FILTER_LABELS: Readonly<Record<FilterType, string>> = {
|
||||
author: 'Author',
|
||||
category: 'Genre',
|
||||
series: 'Series',
|
||||
bookType: 'Book Type',
|
||||
readStatus: 'Read Status',
|
||||
personalRating: 'Personal Rating',
|
||||
publisher: 'Publisher',
|
||||
matchScore: 'Metadata Match Score',
|
||||
library: 'Library',
|
||||
shelf: 'Shelf',
|
||||
shelfStatus: 'Shelf Status',
|
||||
tag: 'Tag',
|
||||
publishedDate: 'Published Year',
|
||||
fileSize: 'File Size',
|
||||
amazonRating: 'Amazon Rating',
|
||||
goodreadsRating: 'Goodreads Rating',
|
||||
hardcoverRating: 'Hardcover Rating',
|
||||
language: 'Language',
|
||||
pageCount: 'Page Count',
|
||||
mood: 'Mood'
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// FILTER EXTRACTORS
|
||||
// ============================================================================
|
||||
|
||||
const findInRange = (value: number | null | undefined, ranges: readonly RangeConfig[]): FilterValue[] => {
|
||||
if (value == null) return [];
|
||||
const match = ranges.find(r => value >= r.min && value < r.max);
|
||||
return match ? [{id: match.id, name: match.label, sortIndex: match.sortIndex}] : [];
|
||||
};
|
||||
|
||||
const findExactRating10 = (rating: number | undefined): FilterValue[] => {
|
||||
if (!rating || rating < 1 || rating > 10) return [];
|
||||
const match = RATING_OPTIONS_10.find(r => r.id === rating);
|
||||
return match ? [{id: match.id, name: match.label, sortIndex: match.sortIndex}] : [];
|
||||
};
|
||||
|
||||
const normalizeMatchScore = (score: number | null | undefined): number | null => {
|
||||
if (score == null) return null;
|
||||
return score > 1 ? score / 100 : score;
|
||||
};
|
||||
|
||||
const extractStringsAsFilters = (values: string[] | undefined): FilterValue[] =>
|
||||
values?.map(name => ({id: name, name})) ?? [];
|
||||
|
||||
const extractSingleString = (value: string | undefined | null): FilterValue[] =>
|
||||
value ? [{id: value, name: value}] : [];
|
||||
|
||||
export const FILTER_EXTRACTORS: Readonly<Record<Exclude<FilterType, 'library'>, (book: Book) => FilterValue[]>> = {
|
||||
author: (book) => extractStringsAsFilters(book.metadata?.authors),
|
||||
category: (book) => extractStringsAsFilters(book.metadata?.categories),
|
||||
series: (book) => extractSingleString(book.metadata?.seriesName),
|
||||
bookType: (book) => extractSingleString(book.primaryFile?.bookType),
|
||||
readStatus: (book) => {
|
||||
const status = book.readStatus ?? ReadStatus.UNSET;
|
||||
const validStatus = status in READ_STATUS_LABELS ? status : ReadStatus.UNSET;
|
||||
return [{id: validStatus, name: READ_STATUS_LABELS[validStatus]}];
|
||||
},
|
||||
personalRating: (book) => findExactRating10(book.personalRating ?? undefined),
|
||||
publisher: (book) => extractSingleString(book.metadata?.publisher),
|
||||
matchScore: (book) => findInRange(normalizeMatchScore(book.metadataMatchScore), MATCH_SCORE_RANGES),
|
||||
shelf: (book) => book.shelves?.map(s => ({id: s.id, name: s.name})) ?? [],
|
||||
shelfStatus: (book) => {
|
||||
const isShelved = (book.shelves?.length ?? 0) > 0;
|
||||
return [{id: isShelved ? 'shelved' : 'unshelved', name: isShelved ? 'Shelved' : 'Unshelved'}];
|
||||
},
|
||||
tag: (book) => extractStringsAsFilters(book.metadata?.tags),
|
||||
publishedDate: (book) => {
|
||||
const date = book.metadata?.publishedDate;
|
||||
if (!date) return [];
|
||||
const year = new Date(date).getFullYear().toString();
|
||||
return [{id: year, name: year}];
|
||||
},
|
||||
fileSize: (book) => findInRange(book.fileSizeKb, FILE_SIZE_RANGES),
|
||||
amazonRating: (book) => findInRange(book.metadata?.amazonRating, RATING_RANGES_5),
|
||||
goodreadsRating: (book) => findInRange(book.metadata?.goodreadsRating, RATING_RANGES_5),
|
||||
hardcoverRating: (book) => findInRange(book.metadata?.hardcoverRating, RATING_RANGES_5),
|
||||
language: (book) => extractSingleString(book.metadata?.language),
|
||||
pageCount: (book) => findInRange(book.metadata?.pageCount, PAGE_COUNT_RANGES),
|
||||
mood: (book) => extractStringsAsFilters(book.metadata?.moods)
|
||||
};
|
||||
|
||||
export const FILTER_CONFIGS: Readonly<Record<Exclude<FilterType, 'library'>, Omit<FilterConfig, 'extractor'>>> = {
|
||||
author: {label: 'Author', sortMode: 'count'},
|
||||
category: {label: 'Genre', sortMode: 'count'},
|
||||
series: {label: 'Series', sortMode: 'count'},
|
||||
bookType: {label: 'Book Type', sortMode: 'count'},
|
||||
readStatus: {label: 'Read Status', sortMode: 'count'},
|
||||
personalRating: {label: 'Personal Rating', sortMode: 'sortIndex', isNumericId: true},
|
||||
publisher: {label: 'Publisher', sortMode: 'count'},
|
||||
matchScore: {label: 'Metadata Match Score', sortMode: 'sortIndex', isNumericId: true},
|
||||
shelf: {label: 'Shelf', sortMode: 'count', isNumericId: true},
|
||||
shelfStatus: {label: 'Shelf Status', sortMode: 'count'},
|
||||
tag: {label: 'Tag', sortMode: 'count'},
|
||||
publishedDate: {label: 'Published Year', sortMode: 'count'},
|
||||
fileSize: {label: 'File Size', sortMode: 'sortIndex', isNumericId: true},
|
||||
amazonRating: {label: 'Amazon Rating', sortMode: 'sortIndex', isNumericId: true},
|
||||
goodreadsRating: {label: 'Goodreads Rating', sortMode: 'sortIndex', isNumericId: true},
|
||||
hardcoverRating: {label: 'Hardcover Rating', sortMode: 'sortIndex', isNumericId: true},
|
||||
language: {label: 'Language', sortMode: 'count'},
|
||||
pageCount: {label: 'Page Count', sortMode: 'sortIndex', isNumericId: true},
|
||||
mood: {label: 'Mood', sortMode: 'count'}
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {combineLatest, map, Observable, shareReplay} from 'rxjs';
|
||||
import {Book} from '../../../model/book.model';
|
||||
import {Library} from '../../../model/library.model';
|
||||
import {Shelf} from '../../../model/shelf.model';
|
||||
import {MagicShelf} from '../../../../magic-shelf/service/magic-shelf.service';
|
||||
import {BookService} from '../../../service/book.service';
|
||||
import {LibraryService} from '../../../service/library.service';
|
||||
import {BookRuleEvaluatorService} from '../../../../magic-shelf/service/book-rule-evaluator.service';
|
||||
import {GroupRule} from '../../../../magic-shelf/component/magic-shelf-component';
|
||||
import {EntityType} from '../book-browser.component';
|
||||
import {Filter, FILTER_CONFIGS, FILTER_EXTRACTORS, FilterType, FilterValue, NUMERIC_ID_FILTER_TYPES, SortMode} from './book-filter.config';
|
||||
|
||||
const MAX_FILTER_ITEMS = 100;
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class BookFilterService {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
private readonly bookRuleEvaluatorService = inject(BookRuleEvaluatorService);
|
||||
|
||||
createFilterStreams(
|
||||
entity$: Observable<Library | Shelf | MagicShelf | null>,
|
||||
entityType$: Observable<EntityType>
|
||||
): Record<FilterType, Observable<Filter[]>> {
|
||||
const filteredBooks$ = this.createFilteredBooksStream(entity$, entityType$);
|
||||
|
||||
const streams = {} as Record<FilterType, Observable<Filter[]>>;
|
||||
|
||||
for (const [type, config] of Object.entries(FILTER_CONFIGS)) {
|
||||
const filterType = type as Exclude<FilterType, 'library'>;
|
||||
streams[filterType] = this.createFilterStream(
|
||||
filteredBooks$,
|
||||
FILTER_EXTRACTORS[filterType],
|
||||
config.sortMode
|
||||
);
|
||||
}
|
||||
|
||||
streams.library = this.createLibraryFilterStream(filteredBooks$);
|
||||
|
||||
return streams;
|
||||
}
|
||||
|
||||
filterBooksByEntity(
|
||||
books: Book[],
|
||||
entity: Library | Shelf | MagicShelf | null,
|
||||
entityType: EntityType
|
||||
): Book[] {
|
||||
if (!entity) return books;
|
||||
|
||||
switch (entityType) {
|
||||
case EntityType.LIBRARY:
|
||||
return books.filter(book => book.libraryId === (entity as Library).id);
|
||||
|
||||
case EntityType.SHELF:
|
||||
const shelfId = (entity as Shelf).id;
|
||||
return books.filter(book => book.shelves?.some(s => s.id === shelfId));
|
||||
|
||||
case EntityType.MAGIC_SHELF:
|
||||
return this.filterByMagicShelf(books, entity as MagicShelf);
|
||||
|
||||
default:
|
||||
return books;
|
||||
}
|
||||
}
|
||||
|
||||
processFilterValue(key: string, value: unknown): unknown {
|
||||
if (NUMERIC_ID_FILTER_TYPES.has(key as FilterType) && typeof value === 'string') {
|
||||
return Number(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
isNumericFilter(filterType: string): boolean {
|
||||
return NUMERIC_ID_FILTER_TYPES.has(filterType as FilterType);
|
||||
}
|
||||
|
||||
private createFilteredBooksStream(
|
||||
entity$: Observable<Library | Shelf | MagicShelf | null>,
|
||||
entityType$: Observable<EntityType>
|
||||
): Observable<Book[]> {
|
||||
return combineLatest([
|
||||
this.bookService.bookState$,
|
||||
entity$,
|
||||
entityType$
|
||||
]).pipe(
|
||||
map(([state, entity, entityType]) =>
|
||||
this.filterBooksByEntity(state.books || [], entity, entityType)
|
||||
),
|
||||
shareReplay({bufferSize: 1, refCount: true})
|
||||
);
|
||||
}
|
||||
|
||||
private createFilterStream(
|
||||
books$: Observable<Book[]>,
|
||||
extractor: (book: Book) => FilterValue[],
|
||||
sortMode: SortMode
|
||||
): Observable<Filter[]> {
|
||||
return books$.pipe(
|
||||
map(books => this.buildAndSortFilters(books, extractor, sortMode)),
|
||||
shareReplay({bufferSize: 1, refCount: true})
|
||||
);
|
||||
}
|
||||
|
||||
private createLibraryFilterStream(books$: Observable<Book[]>): Observable<Filter[]> {
|
||||
return combineLatest([books$, this.libraryService.libraryState$]).pipe(
|
||||
map(([books, libraryState]) => {
|
||||
const libraryMap = new Map(
|
||||
(libraryState.libraries || [])
|
||||
.filter(lib => lib.id !== undefined)
|
||||
.map(lib => [lib.id!, lib.name])
|
||||
);
|
||||
|
||||
const filterMap = new Map<number, Filter>();
|
||||
|
||||
for (const book of books) {
|
||||
if (book.libraryId == null) continue;
|
||||
|
||||
if (!filterMap.has(book.libraryId)) {
|
||||
filterMap.set(book.libraryId, {
|
||||
value: {
|
||||
id: book.libraryId,
|
||||
name: libraryMap.get(book.libraryId) || `Library ${book.libraryId}`
|
||||
},
|
||||
bookCount: 0
|
||||
});
|
||||
}
|
||||
filterMap.get(book.libraryId)!.bookCount++;
|
||||
}
|
||||
|
||||
return this.sortFiltersByCount(Array.from(filterMap.values()));
|
||||
}),
|
||||
shareReplay({bufferSize: 1, refCount: true})
|
||||
);
|
||||
}
|
||||
|
||||
private buildAndSortFilters(
|
||||
books: Book[],
|
||||
extractor: (book: Book) => FilterValue[],
|
||||
sortMode: SortMode
|
||||
): Filter[] {
|
||||
const filterMap = new Map<unknown, Filter>();
|
||||
|
||||
for (const book of books) {
|
||||
for (const item of extractor(book)) {
|
||||
const id = item.id;
|
||||
if (!filterMap.has(id)) {
|
||||
filterMap.set(id, {value: item, bookCount: 0});
|
||||
}
|
||||
filterMap.get(id)!.bookCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const filters = Array.from(filterMap.values());
|
||||
const sorted = sortMode === 'sortIndex'
|
||||
? this.sortFiltersBySortIndex(filters)
|
||||
: this.sortFiltersByCount(filters);
|
||||
|
||||
return sorted.slice(0, MAX_FILTER_ITEMS);
|
||||
}
|
||||
|
||||
private sortFiltersByCount(filters: Filter[]): Filter[] {
|
||||
return filters.sort((a, b) => {
|
||||
if (b.bookCount !== a.bookCount) return b.bookCount - a.bookCount;
|
||||
return this.compareNames(a, b);
|
||||
});
|
||||
}
|
||||
|
||||
private sortFiltersBySortIndex(filters: Filter[]): Filter[] {
|
||||
return filters.sort((a, b) => {
|
||||
const aIndex = (a.value as { sortIndex?: number }).sortIndex ?? 999;
|
||||
const bIndex = (b.value as { sortIndex?: number }).sortIndex ?? 999;
|
||||
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||
return this.compareNames(a, b);
|
||||
});
|
||||
}
|
||||
|
||||
private compareNames(a: Filter, b: Filter): number {
|
||||
const aName = String((a.value as { name?: string }).name ?? '');
|
||||
const bName = String((b.value as { name?: string }).name ?? '');
|
||||
return aName.localeCompare(bName);
|
||||
}
|
||||
|
||||
private filterByMagicShelf(books: Book[], magicShelf: MagicShelf): Book[] {
|
||||
if (!magicShelf.filterJson) return [];
|
||||
try {
|
||||
const groupRule = JSON.parse(magicShelf.filterJson) as GroupRule;
|
||||
return books.filter(book => this.bookRuleEvaluatorService.evaluateGroup(book, groupRule));
|
||||
} catch {
|
||||
console.warn('Invalid filterJson for MagicShelf');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
fileSizeRanges,
|
||||
matchScoreRanges,
|
||||
pageCountRanges,
|
||||
ratingOptions10,
|
||||
ratingRanges
|
||||
} from './book-filter/book-filter.component';
|
||||
import {fileSizeRanges, matchScoreRanges, pageCountRanges, ratingOptions10, ratingRanges} from './book-filter/book-filter.config';
|
||||
|
||||
export class FilterLabelHelper {
|
||||
private static readonly FILTER_TYPE_MAP: Record<string, string> = {
|
||||
@@ -33,55 +27,44 @@ export class FilterLabelHelper {
|
||||
return this.FILTER_TYPE_MAP[filterType] || this.capitalize(filterType);
|
||||
}
|
||||
|
||||
static getFilterDisplayValue(filterType: string, value: string): string {
|
||||
static getFilterDisplayValue(filterType: string, value: string | number): string {
|
||||
const numericValue = typeof value === 'string' ? Number(value) : value;
|
||||
|
||||
switch (filterType.toLowerCase()) {
|
||||
case 'filesize':
|
||||
// Try both lower and original case for id match
|
||||
{
|
||||
const fileSizeRange = fileSizeRanges.find(r => r.id === value);
|
||||
if (fileSizeRange) return fileSizeRange.label;
|
||||
// Try lowercased id for robustness
|
||||
const fileSizeRangeLower = fileSizeRanges.find(r => r.id === value.toLowerCase());
|
||||
if (fileSizeRangeLower) return fileSizeRangeLower.label;
|
||||
return value;
|
||||
}
|
||||
case 'pagecount':
|
||||
{
|
||||
const pageCountRange = pageCountRanges.find(r => r.id === value);
|
||||
if (pageCountRange) return pageCountRange.label;
|
||||
const pageCountRangeLower = pageCountRanges.find(r => r.id === value.toLowerCase());
|
||||
if (pageCountRangeLower) return pageCountRangeLower.label;
|
||||
return value;
|
||||
}
|
||||
case 'matchscore':
|
||||
{
|
||||
const matchScoreRange = matchScoreRanges.find(r => r.id === value);
|
||||
if (matchScoreRange) return matchScoreRange.label;
|
||||
const matchScoreRangeLower = matchScoreRanges.find(r => r.id === value.toLowerCase());
|
||||
if (matchScoreRangeLower) return matchScoreRangeLower.label;
|
||||
return value;
|
||||
}
|
||||
case 'personalrating':
|
||||
{
|
||||
const personalRating = ratingOptions10.find(r => r.id === value);
|
||||
if (personalRating) return personalRating.label;
|
||||
const personalRatingLower = ratingOptions10.find(r => r.id === value.toLowerCase());
|
||||
if (personalRatingLower) return personalRatingLower.label;
|
||||
return value;
|
||||
}
|
||||
case 'filesize': {
|
||||
const fileSizeRange = fileSizeRanges.find(r => r.id === numericValue);
|
||||
if (fileSizeRange) return fileSizeRange.label;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
case 'pagecount': {
|
||||
const pageCountRange = pageCountRanges.find(r => r.id === numericValue);
|
||||
if (pageCountRange) return pageCountRange.label;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
case 'matchscore': {
|
||||
const matchScoreRange = matchScoreRanges.find(r => r.id === numericValue);
|
||||
if (matchScoreRange) return matchScoreRange.label;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
case 'personalrating': {
|
||||
const personalRating = ratingOptions10.find(r => r.id === numericValue);
|
||||
if (personalRating) return personalRating.label;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
case 'amazonrating':
|
||||
case 'goodreadsrating':
|
||||
case 'hardcoverrating':
|
||||
case 'ranobedbrating':
|
||||
{
|
||||
const ratingRange = ratingRanges.find(r => r.id === value);
|
||||
if (ratingRange) return ratingRange.label;
|
||||
const ratingRangeLower = ratingRanges.find(r => r.id === value.toLowerCase());
|
||||
if (ratingRangeLower) return ratingRangeLower.label;
|
||||
return value;
|
||||
}
|
||||
case 'hardcoverrating': {
|
||||
const ratingRange = ratingRanges.find(r => r.id === numericValue);
|
||||
if (ratingRange) return ratingRange.label;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
default:
|
||||
return value;
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,46 @@
|
||||
import {Observable, combineLatest, of} from 'rxjs';
|
||||
import {combineLatest, Observable} from 'rxjs';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {BookFilter} from './BookFilter';
|
||||
import {BookState} from '../../../model/state/book-state.model';
|
||||
import {fileSizeRanges, matchScoreRanges, pageCountRanges, ratingRanges} from '../book-filter/book-filter.component';
|
||||
import {fileSizeRanges, matchScoreRanges, pageCountRanges, ratingRanges, RangeConfig} from '../book-filter/book-filter.config';
|
||||
import {Book, ReadStatus} from '../../../model/book.model';
|
||||
import {BookFilterMode} from '../../../../settings/user-management/user.service';
|
||||
|
||||
export function isRatingInRange(rating: number | undefined | null, rangeId: string): boolean {
|
||||
export function isRatingInRange(rating: number | undefined | null, rangeId: string | number): boolean {
|
||||
if (rating == null) return false;
|
||||
const range = ratingRanges.find(r => r.id === rangeId);
|
||||
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
|
||||
const range = ratingRanges.find(r => r.id === numericId);
|
||||
if (!range) return false;
|
||||
return rating >= range.min && rating < range.max;
|
||||
}
|
||||
|
||||
export function isRatingInRange10(rating: number | undefined | null, rangeId: string): boolean {
|
||||
export function isRatingInRange10(rating: number | undefined | null, rangeId: string | number): boolean {
|
||||
if (rating == null) return false;
|
||||
return `${Math.round(rating)}` === rangeId;
|
||||
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
|
||||
return Math.round(rating) === numericId;
|
||||
}
|
||||
|
||||
export function isFileSizeInRange(fileSizeKb: number | undefined, rangeId: string): boolean {
|
||||
export function isFileSizeInRange(fileSizeKb: number | undefined, rangeId: string | number): boolean {
|
||||
if (fileSizeKb == null) return false;
|
||||
const range = fileSizeRanges.find(r => r.id === rangeId);
|
||||
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
|
||||
const range = fileSizeRanges.find(r => r.id === numericId);
|
||||
if (!range) return false;
|
||||
return fileSizeKb >= range.min && fileSizeKb < range.max;
|
||||
}
|
||||
|
||||
export function isPageCountInRange(pageCount: number | undefined, rangeId: string): boolean {
|
||||
export function isPageCountInRange(pageCount: number | undefined, rangeId: string | number): boolean {
|
||||
if (pageCount == null) return false;
|
||||
const range = pageCountRanges.find(r => r.id === rangeId);
|
||||
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
|
||||
const range = pageCountRanges.find(r => r.id === numericId);
|
||||
if (!range) return false;
|
||||
return pageCount >= range.min && pageCount < range.max;
|
||||
}
|
||||
|
||||
export function isMatchScoreInRange(score: number | undefined | null, rangeId: string): boolean {
|
||||
export function isMatchScoreInRange(score: number | undefined | null, rangeId: string | number): boolean {
|
||||
if (score == null) return false;
|
||||
const normalizedScore = score > 1 ? score / 100 : score;
|
||||
const range = matchScoreRanges.find(r => r.id === rangeId);
|
||||
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
|
||||
const range = matchScoreRanges.find(r => r.id === numericId);
|
||||
if (!range) return false;
|
||||
return normalizedScore >= range.min && normalizedScore < range.max;
|
||||
}
|
||||
@@ -53,7 +58,6 @@ export class SideBarFilter implements BookFilter {
|
||||
filter(bookState: BookState): Observable<BookState> {
|
||||
return combineLatest([this.selectedFilter$, this.selectedFilterMode$]).pipe(
|
||||
map(([activeFilters, mode]) => {
|
||||
// Return original state if books is null or undefined
|
||||
if (bookState.books == null) return bookState;
|
||||
if (!activeFilters) return bookState;
|
||||
const filteredBooks = (bookState.books || []).filter(book => {
|
||||
@@ -70,41 +74,22 @@ export class SideBarFilter implements BookFilter {
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.categories?.includes(val))
|
||||
: filterValues.every(val => book.metadata?.categories?.includes(val));
|
||||
case 'mood':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.moods?.includes(val))
|
||||
: filterValues.every(val => book.metadata?.moods?.includes(val));
|
||||
case 'tag':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.tags?.includes(val))
|
||||
: filterValues.every(val => book.metadata?.tags?.includes(val));
|
||||
case 'publisher':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.publisher === val)
|
||||
: filterValues.every(val => book.metadata?.publisher === val);
|
||||
case 'series':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.seriesName === val)
|
||||
: filterValues.every(val => book.metadata?.seriesName === val);
|
||||
case 'bookType':
|
||||
return filterValues.includes(book.primaryFile?.bookType);
|
||||
case 'readStatus':
|
||||
return doesBookMatchReadStatus(book, filterValues);
|
||||
case 'amazonRating':
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.amazonRating, range));
|
||||
case 'goodreadsRating':
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.goodreadsRating, range));
|
||||
case 'hardcoverRating':
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.hardcoverRating, range));
|
||||
case 'ranobedbRating':
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.ranobedbRating, range));
|
||||
case 'personalRating':
|
||||
return filterValues.some(range => isRatingInRange10(book.personalRating, range));
|
||||
case 'publishedDate':
|
||||
const bookYear = book.metadata?.publishedDate
|
||||
? new Date(book.metadata.publishedDate).getFullYear()
|
||||
: null;
|
||||
return bookYear ? filterValues.some(val => val == bookYear || val == bookYear.toString()) : false;
|
||||
case 'fileSize':
|
||||
return filterValues.some(range => isFileSizeInRange(book.fileSizeKb, range));
|
||||
case 'publisher':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.publisher === val)
|
||||
: filterValues.every(val => book.metadata?.publisher === val);
|
||||
case 'matchScore':
|
||||
return filterValues.some(range => isMatchScoreInRange(book.metadataMatchScore, range));
|
||||
case 'library':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => val == book.libraryId)
|
||||
@@ -116,14 +101,31 @@ export class SideBarFilter implements BookFilter {
|
||||
case 'shelfStatus':
|
||||
const shelved = book.shelves && book.shelves.length > 0 ? 'shelved' : 'unshelved';
|
||||
return filterValues.includes(shelved);
|
||||
case 'pageCount':
|
||||
return filterValues.some(range => isPageCountInRange(book.metadata?.pageCount!, range));
|
||||
case 'tag':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.tags?.includes(val))
|
||||
: filterValues.every(val => book.metadata?.tags?.includes(val));
|
||||
case 'publishedDate':
|
||||
const bookYear = book.metadata?.publishedDate
|
||||
? new Date(book.metadata.publishedDate).getFullYear()
|
||||
: null;
|
||||
return bookYear ? filterValues.some(val => val == bookYear || val == bookYear.toString()) : false;
|
||||
case 'fileSize':
|
||||
return filterValues.some(range => isFileSizeInRange(book.fileSizeKb, range));
|
||||
case 'amazonRating':
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.amazonRating, range));
|
||||
case 'goodreadsRating':
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.goodreadsRating, range));
|
||||
case 'hardcoverRating':
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.hardcoverRating, range));
|
||||
case 'language':
|
||||
return filterValues.includes(book.metadata?.language);
|
||||
case 'matchScore':
|
||||
return filterValues.some(range => isMatchScoreInRange(book.metadataMatchScore, range));
|
||||
case 'bookType':
|
||||
return filterValues.includes(book.primaryFile?.bookType);
|
||||
case 'pageCount':
|
||||
return filterValues.some(range => isPageCountInRange(book.metadata?.pageCount!, range));
|
||||
case 'mood':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.moods?.includes(val))
|
||||
: filterValues.every(val => book.metadata?.moods?.includes(val));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {ConfirmationService, MenuItem, MessageService} from 'primeng/api';
|
||||
import {BookService} from './book.service';
|
||||
import {readStatusLabels} from '../components/book-browser/book-filter/book-filter.component';
|
||||
import {readStatusLabels} from '../components/book-browser/book-filter/book-filter.config';
|
||||
import {ReadStatus} from '../model/book.model';
|
||||
import {ResetProgressTypes} from '../../../shared/constants/reset-progress-type';
|
||||
import {finalize} from 'rxjs';
|
||||
|
||||
@@ -28,7 +28,7 @@ import {Image} from 'primeng/image';
|
||||
import {BookDialogHelperService} from '../../../../book/components/book-browser/book-dialog-helper.service';
|
||||
import {TagColor, TagComponent} from '../../../../../shared/components/tag/tag.component';
|
||||
import {TaskHelperService} from '../../../../settings/task-management/task-helper.service';
|
||||
import {fileSizeRanges, matchScoreRanges, pageCountRanges} from '../../../../book/components/book-browser/book-filter/book-filter.component';
|
||||
import {fileSizeRanges, matchScoreRanges, pageCountRanges} from '../../../../book/components/book-browser/book-filter/book-filter.config';
|
||||
import {BookNavigationService} from '../../../../book/service/book-navigation.service';
|
||||
import {Divider} from 'primeng/divider';
|
||||
import {BookMetadataHostService} from '../../../../../shared/service/book-metadata-host.service';
|
||||
@@ -813,14 +813,14 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
goToPageCountRange(pageCount: number): void {
|
||||
const range = pageCountRanges.find(r => pageCount >= r.min && pageCount < r.max);
|
||||
if (range) {
|
||||
this.handleMetadataClick('pageCount', range.id);
|
||||
this.handleMetadataClick('pageCount', range.id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
goToFileSizeRange(fileSizeKb: number): void {
|
||||
const range = fileSizeRanges.find(r => fileSizeKb >= r.min && fileSizeKb < r.max);
|
||||
if (range) {
|
||||
this.handleMetadataClick('fileSize', range.id);
|
||||
this.handleMetadataClick('fileSize', range.id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,7 +828,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
const normalizedScore = score > 1 ? score / 100 : score;
|
||||
const range = matchScoreRanges.find(r => normalizedScore >= r.min && normalizedScore < r.max);
|
||||
if (range) {
|
||||
this.handleMetadataClick('matchScore', range.id);
|
||||
this.handleMetadataClick('matchScore', range.id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ export interface PerBookSetting {
|
||||
|
||||
export type PageSpread = 'off' | 'even' | 'odd';
|
||||
export type BookFilterMode = 'and' | 'or' | 'single';
|
||||
export type FilterSortingMode = 'count';
|
||||
|
||||
|
||||
export enum CbxPageViewMode {
|
||||
@@ -178,7 +177,6 @@ export interface UserSettings {
|
||||
sidebarShelfSorting: SidebarShelfSorting;
|
||||
sidebarMagicShelfSorting: SidebarMagicShelfSorting;
|
||||
filterMode: BookFilterMode;
|
||||
filterSortingMode: FilterSortingMode;
|
||||
metadataCenterViewMode: 'route' | 'dialog';
|
||||
enableSeriesView: boolean;
|
||||
entityViewPreferences: EntityViewPreferences;
|
||||
|
||||
Reference in New Issue
Block a user