Refactor sidebar filter

This commit is contained in:
acx10
2026-01-30 13:00:58 -07:00
parent 2310681c66
commit 830bb5e9de
10 changed files with 652 additions and 576 deletions

View File

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

View File

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

View File

@@ -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: '110 MB', min: 1024, max: 10240, sortIndex: 1},
{id: '10to50mb', label: '1050 MB', min: 10240, max: 51200, sortIndex: 2},
{id: '50to100mb', label: '50100 MB', min: 51200, max: 102400, sortIndex: 3},
{id: '250to500mb', label: '250500 MB', min: 256000, max: 512000, sortIndex: 4},
{id: '500mbto1gb', label: '0.51 GB', min: 512000, max: 1048576, sortIndex: 5},
{id: '1to2gb', label: '12 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: '50100 pages', min: 50, max: 100, sortIndex: 1},
{id: '100to200', label: '100200 pages', min: 100, max: 200, sortIndex: 2},
{id: '200to400', label: '200400 pages', min: 200, max: 400, sortIndex: 3},
{id: '400to600', label: '400600 pages', min: 400, max: 600, sortIndex: 4},
{id: '600to1000', label: '6001000 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 (95100%)', sortIndex: 0},
{id: '0.90-0.94', min: 0.90, max: 0.95, label: 'Excellent (9094%)', sortIndex: 1},
{id: '0.80-0.89', min: 0.80, max: 0.90, label: 'Great (8089%)', sortIndex: 2},
{id: '0.70-0.79', min: 0.70, max: 0.80, label: 'Good (7079%)', sortIndex: 3},
{id: '0.50-0.69', min: 0.50, max: 0.70, label: 'Fair (5069%)', sortIndex: 4},
{id: '0.30-0.49', min: 0.30, max: 0.50, label: 'Weak (3049%)', sortIndex: 5},
{id: '0.00-0.29', min: 0.00, max: 0.30, label: 'Poor (029%)', 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]: 'Wont 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];
}
}

View File

@@ -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: '110 MB', min: 1024, max: 10240, sortIndex: 1},
{id: 2, label: '1050 MB', min: 10240, max: 51200, sortIndex: 2},
{id: 3, label: '50100 MB', min: 51200, max: 102400, sortIndex: 3},
{id: 4, label: '250500 MB', min: 256000, max: 512000, sortIndex: 4},
{id: 5, label: '0.51 GB', min: 512000, max: 1048576, sortIndex: 5},
{id: 6, label: '12 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: '50100 pages', min: 50, max: 100, sortIndex: 1},
{id: 2, label: '100200 pages', min: 100, max: 200, sortIndex: 2},
{id: 3, label: '200400 pages', min: 200, max: 400, sortIndex: 3},
{id: 4, label: '400600 pages', min: 400, max: 600, sortIndex: 4},
{id: 5, label: '6001000 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 (95100%)', sortIndex: 0},
{id: 1, min: 0.90, max: 0.95, label: 'Excellent (9094%)', sortIndex: 1},
{id: 2, min: 0.80, max: 0.90, label: 'Great (8089%)', sortIndex: 2},
{id: 3, min: 0.70, max: 0.80, label: 'Good (7079%)', sortIndex: 3},
{id: 4, min: 0.50, max: 0.70, label: 'Fair (5069%)', sortIndex: 4},
{id: 5, min: 0.30, max: 0.50, label: 'Weak (3049%)', sortIndex: 5},
{id: 6, min: 0.00, max: 0.30, label: 'Poor (029%)', 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'}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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