diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.ts b/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.ts index fcd8d63c5..ae24f4351 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.ts @@ -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'; diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-filter-orchestration.service.ts b/booklore-ui/src/app/features/book/components/book-browser/book-filter-orchestration.service.ts index 0b96484ef..b168aec65 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-filter-orchestration.service.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/book-filter-orchestration.service.ts @@ -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): HeaderFilter { - return new HeaderFilter(searchTerm$); - } - - createSideBarFilter( - selectedFilter$: BehaviorSubject | null>, - selectedFilterMode$: BehaviorSubject - ): SideBarFilter { - return new SideBarFilter(selectedFilter$, selectedFilterMode$); + return (!!filterParam && filterParam.split(',').some(pair => pair.trim().startsWith('series:'))); } } diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.component.ts b/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.component.ts index 493e48c05..7876682b4 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.component.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.component.ts @@ -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 { - 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.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 | null>(); + @Input() entity$: Observable | undefined; + @Input() entityType$: Observable | undefined; + @Input() resetFilter$!: Subject; + @Input() showFilter = false; @Output() filterSelected = new EventEmitter | null>(); @Output() filterModeChanged = new EventEmitter(); - @Input() entity$!: Observable | undefined; - @Input() entityType$!: Observable | undefined; - @Input() resetFilter$!: Subject; - @Input() showFilter: boolean = false; + private readonly filterService = inject(BookFilterService); + private readonly destroy$ = new Subject(); activeFilters: Record = {}; filterStreams: Record> = {} as Record>; - truncatedFilters: Record = {}; filterTypes: FilterType[] = []; - filterModeOptions = [ + expandedPanels: number[] = [0]; + truncatedFilters: Record = {}; + + 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 = { - 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(); - - bookService = inject(BookService); - userService = inject(UserService); - bookRuleEvaluatorService = inject(BookRuleEvaluatorService); - userData$: Observable = 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( - extractor: (book: Book) => T[] | undefined, - idKey: keyof T, - nameKey: keyof T, - sortMode: FilterSortingMode | 'sortIndex' = this.filterSortingMode - ): Observable[]> { - 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>(); - - 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; - const bValue = b.value as Record; - 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[]; - }), - 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) { - 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): 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): 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): 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): 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]; + } } diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.config.ts b/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.config.ts new file mode 100644 index 000000000..c2ba95963 --- /dev/null +++ b/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.config.ts @@ -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 { + 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> = { + [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([ + 'personalRating', 'matchScore', 'fileSize', 'amazonRating', + 'goodreadsRating', 'hardcoverRating', 'pageCount', 'library', 'shelf' +]); + +export const FILTER_LABELS: Readonly> = { + 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, (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, Omit>> = { + 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'} +}; diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.service.ts b/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.service.ts new file mode 100644 index 000000000..a2e90e814 --- /dev/null +++ b/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.service.ts @@ -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, + entityType$: Observable + ): Record> { + const filteredBooks$ = this.createFilteredBooksStream(entity$, entityType$); + + const streams = {} as Record>; + + for (const [type, config] of Object.entries(FILTER_CONFIGS)) { + const filterType = type as Exclude; + 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, + entityType$: Observable + ): Observable { + 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, + extractor: (book: Book) => FilterValue[], + sortMode: SortMode + ): Observable { + return books$.pipe( + map(books => this.buildAndSortFilters(books, extractor, sortMode)), + shareReplay({bufferSize: 1, refCount: true}) + ); + } + + private createLibraryFilterStream(books$: Observable): Observable { + 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(); + + 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(); + + 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 []; + } + } +} diff --git a/booklore-ui/src/app/features/book/components/book-browser/filter-label.helper.ts b/booklore-ui/src/app/features/book/components/book-browser/filter-label.helper.ts index 6392943ca..42a7c98ff 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/filter-label.helper.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/filter-label.helper.ts @@ -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 = { @@ -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); } } diff --git a/booklore-ui/src/app/features/book/components/book-browser/filters/SidebarFilter.ts b/booklore-ui/src/app/features/book/components/book-browser/filters/SidebarFilter.ts index 45060eece..249171b75 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/filters/SidebarFilter.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/filters/SidebarFilter.ts @@ -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 { 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; } diff --git a/booklore-ui/src/app/features/book/service/book-menu.service.ts b/booklore-ui/src/app/features/book/service/book-menu.service.ts index b34462a63..d7b7f1566 100644 --- a/booklore-ui/src/app/features/book/service/book-menu.service.ts +++ b/booklore-ui/src/app/features/book/service/book-menu.service.ts @@ -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'; diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.ts b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.ts index a22de61a6..155b319d9 100644 --- a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.ts +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.ts @@ -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()); } } diff --git a/booklore-ui/src/app/features/settings/user-management/user.service.ts b/booklore-ui/src/app/features/settings/user-management/user.service.ts index c2151ea5c..80adfc35e 100644 --- a/booklore-ui/src/app/features/settings/user-management/user.service.ts +++ b/booklore-ui/src/app/features/settings/user-management/user.service.ts @@ -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;