From e77db363d510d3b23ebd7797d54394ea4df22153 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:02:54 +0100 Subject: [PATCH] feat(ui): book browse field registry, facet and sort query helpers --- frontend/src/app/core/data/browse-response.ts | 23 +- frontend/src/app/core/data/browse.models.ts | 6 +- .../book/browse/book-browse-columns.ts | 146 ++++++ .../book-browse-facet-definitions.spec.ts | 59 +++ .../browse/book-browse-facet-definitions.ts | 71 +++ .../book/browse/book-browse-fields.ts | 473 ++++++++++++++++++ .../book/browse/book-browse-sort.spec.ts | 19 + .../features/book/browse/book-browse-sort.ts | 67 +++ .../book/data/book-query-params.spec.ts | 11 + .../features/book/data/book-query-params.ts | 112 ++++- .../features/book/data/book-query.models.ts | 3 +- .../book/data/book-query.service.spec.ts | 22 +- .../features/book/data/book-query.service.ts | 31 +- .../book/data/book-response.models.ts | 3 + .../features/book/model/book-value-labels.ts | 15 + frontend/src/app/shared/util/date-format.ts | 13 + frontend/src/i18n/en.json | 47 ++ 17 files changed, 1071 insertions(+), 50 deletions(-) create mode 100644 frontend/src/app/features/book/browse/book-browse-columns.ts create mode 100644 frontend/src/app/features/book/browse/book-browse-facet-definitions.spec.ts create mode 100644 frontend/src/app/features/book/browse/book-browse-facet-definitions.ts create mode 100644 frontend/src/app/features/book/browse/book-browse-fields.ts create mode 100644 frontend/src/app/features/book/browse/book-browse-sort.spec.ts create mode 100644 frontend/src/app/features/book/browse/book-browse-sort.ts create mode 100644 frontend/src/app/features/book/model/book-value-labels.ts create mode 100644 frontend/src/app/shared/util/date-format.ts diff --git a/frontend/src/app/core/data/browse-response.ts b/frontend/src/app/core/data/browse-response.ts index 886aa3fce2..356d5a6755 100644 --- a/frontend/src/app/core/data/browse-response.ts +++ b/frontend/src/app/core/data/browse-response.ts @@ -1,5 +1,6 @@ import { BrowseFacetGroup, + BrowseFacetResult, BrowseFacetValue, BrowseLink, BrowsePage, @@ -41,13 +42,21 @@ export function mapBrowsePage(response: RawBrowsePage): BrowsePage { }; } -export function mapBrowseFacetGroups(response: RawFacetResponse): BrowseFacetGroup[] { - return response.facets.map(group => ({ - rel: group.metadata.rel, - key: group.metadata.key, - title: group.metadata.title, - values: group.links.map(mapBrowseFacetValue), - })); +export function mapBrowseFacetResult(response: RawFacetResponse): BrowseFacetResult { + const facets: BrowseFacetGroup[] = []; + const sortTokens: string[] = []; + for (const group of response.facets) { + if (group.metadata.rel === 'facet') { + facets.push({ + key: group.metadata.key, + title: group.metadata.title, + values: group.links.map(mapBrowseFacetValue), + }); + } else if (group.metadata.rel === 'sort') { + sortTokens.push(...group.links.map(link => link.value)); + } + } + return {facets, sortTokens}; } function mapBrowseLink(raw: RawLink): BrowseLink { diff --git a/frontend/src/app/core/data/browse.models.ts b/frontend/src/app/core/data/browse.models.ts index 3c3959682a..e9406a80a0 100644 --- a/frontend/src/app/core/data/browse.models.ts +++ b/frontend/src/app/core/data/browse.models.ts @@ -26,12 +26,16 @@ export interface BrowseFacetValue { } export interface BrowseFacetGroup { - rel: string; key: string; title: string; values: BrowseFacetValue[]; } +export interface BrowseFacetResult { + facets: BrowseFacetGroup[]; + sortTokens: string[]; +} + export type BrowseFacetLogic = 'and' | 'or' | 'not'; export type BrowseSortDirection = 'asc' | 'desc'; diff --git a/frontend/src/app/features/book/browse/book-browse-columns.ts b/frontend/src/app/features/book/browse/book-browse-columns.ts new file mode 100644 index 0000000000..9f2da7ad7f --- /dev/null +++ b/frontend/src/app/features/book/browse/book-browse-columns.ts @@ -0,0 +1,146 @@ +import {formatMediumDate} from '../../../shared/util/date-format'; +import {formatFileSizeKb} from '../../../shared/util/file-size'; +import {type TableColumnPreference} from '../../settings/user-management/user.service'; +import {type BookQuerySortKey} from '../data/book-query-params'; +import {type BookSummary} from '../data/book-response.models'; +import { + COLUMNS, + COLUMNS_BY_KEY, + COLUMN_GROUP_ORDER, + bookReadStatusLabelKey, + type BookBrowseColumnGroupId, + type BookColumnKey, + type BookColumnKind, + type BookColumnValue, +} from './book-browse-fields'; + +export const BOOK_EMPTY_VALUE = '—'; + +const DEFAULT_COLUMN_MIN_WIDTH = 84; +const DEFAULT_COLUMN_MAX_WIDTH = 420; +const NUMBER_FORMAT = new Intl.NumberFormat(); + +export interface BookColumnSizing { + readonly size: number; + readonly minSize: number; + readonly maxSize: number; +} + +export interface BookColumnOption { + readonly field: BookColumnKey; + readonly labelKey: string; + readonly visible: boolean; + readonly hideable: boolean; +} + +export interface BookBrowseColumnVisibilityChange { + readonly field: string; + readonly visible: boolean; +} + +export interface BookColumnSection { + readonly id: BookBrowseColumnGroupId; + readonly columns: readonly BookColumnOption[]; +} + +export function normalizeBookColumnPreferences( + saved: readonly TableColumnPreference[] | undefined, +): TableColumnPreference[] { + const savedByField = new Map(saved?.map(preference => [preference.field, preference])); + return COLUMNS.map(({key, column}, order) => { + const preference = savedByField.get(key); + return { + field: key, + visible: column.hideable === false || (preference?.visible ?? column.defaultVisible), + order: preference?.order ?? order, + }; + }); +} + +export function bookColumnOptions( + preferences: readonly TableColumnPreference[], +): BookColumnOption[] { + const visibleByField = new Map( + preferences.map(preference => [preference.field, preference.visible]), + ); + return COLUMNS.map(({key, labelKey, column}) => ({ + field: key, + labelKey, + visible: visibleByField.get(key) === true, + hideable: column.hideable !== false, + })); +} + +export function bookVisibleColumnOptions( + preferences: readonly TableColumnPreference[], +): BookColumnOption[] { + const orderByField = new Map(preferences.map(preference => [preference.field, preference.order])); + const orderOf = (option: BookColumnOption) => + option.field === 'title' ? -1 : orderByField.get(option.field) ?? Number.MAX_SAFE_INTEGER; + return bookColumnOptions(preferences) + .filter(option => option.visible) + .sort((first, second) => orderOf(first) - orderOf(second)); +} + +export function bookColumnSections( + options: readonly BookColumnOption[], +): BookColumnSection[] { + const optionsByField = new Map(options.map(option => [option.field, option])); + return COLUMN_GROUP_ORDER.map(id => ({ + id, + columns: COLUMNS.flatMap(({key, column}) => { + const option = column.group === id ? optionsByField.get(key) : undefined; + return option ? [option] : []; + }), + })); +} + +export function bookColumnSortKey(field: string): BookQuerySortKey | undefined { + return COLUMNS_BY_KEY.get(field)?.sort?.key; +} + +export function bookColumnKind(field: string): BookColumnKind { + return COLUMNS_BY_KEY.get(field)?.column.kind ?? 'text'; +} + +export function bookColumnSizing(field: BookColumnKey): BookColumnSizing { + const {defaultWidth, minWidth, maxWidth} = COLUMNS_BY_KEY.get(field)!.column; + return { + size: defaultWidth, + minSize: minWidth ?? DEFAULT_COLUMN_MIN_WIDTH, + maxSize: maxWidth ?? DEFAULT_COLUMN_MAX_WIDTH, + }; +} + +export function bookColumnValue( + book: BookSummary, + field: string, +): BookColumnValue { + return COLUMNS_BY_KEY.get(field)?.column.value(book); +} + +export function formatBookValue( + kind: BookColumnKind, + value: BookColumnValue, + translate: (key: string) => string, +): string { + if (value == null || value === '') { + return BOOK_EMPTY_VALUE; + } + switch (kind) { + case 'date': + return formatMediumDate(String(value)) || BOOK_EMPTY_VALUE; + case 'fileSize': + return formatFileSizeKb(Number(value)); + case 'rating': + return Number(value).toFixed(1); + case 'number': + return NUMBER_FORMAT.format(Number(value)); + case 'readStatus': { + const labelKey = typeof value === 'string' ? bookReadStatusLabelKey(value) : null; + return labelKey ? translate(labelKey) : BOOK_EMPTY_VALUE; + } + case 'text': + return String(value); + } +} diff --git a/frontend/src/app/features/book/browse/book-browse-facet-definitions.spec.ts b/frontend/src/app/features/book/browse/book-browse-facet-definitions.spec.ts new file mode 100644 index 0000000000..d363bdb919 --- /dev/null +++ b/frontend/src/app/features/book/browse/book-browse-facet-definitions.spec.ts @@ -0,0 +1,59 @@ +import {describe, expect, it} from 'vitest'; + +import {browseFilterGroups, browseFrozenFacetOrders, withBrowseFacetRange} from '../../../shared/browse/facets'; +import {type BrowseFacetGroup} from '../../../core/data/browse.models'; +import {bookFacetDefinitions} from './book-browse-facet-definitions'; + +function group(key: string, values: [string, number][]): BrowseFacetGroup { + return { + key, + title: key, + values: values.map(([value, count]) => ({value, title: value, count, selected: false})), + }; +} + +const definitions = bookFacetDefinitions({ + shelf: () => undefined, + library: () => undefined, + translate: (key: string) => key, +}); + +describe('book browse facets', () => { + it('keeps the frozen value order when the server re-ranks, narrows or grows a group', () => { + const frozen = browseFrozenFacetOrders([group('genre', [['Gothic', 40], ['Comedy', 30], ['Drama', 20]])], definitions); + + const reranked = browseFilterGroups( + [group('genre', [['Drama', 90], ['Gothic', 5], ['Comedy', 2]])], + frozen, + definitions, + {}, + ); + expect(reranked[0].values.map(item => [item.value, item.count])) + .toEqual([['Gothic', 5], ['Comedy', 2], ['Drama', 90]]); + + const narrowed = browseFilterGroups([group('genre', [['Comedy', 7]])], frozen, definitions, {}); + expect(narrowed[0].values.map(item => [item.value, item.count])) + .toEqual([['Comedy', 7], ['Gothic', 0], ['Drama', 0]]); + + const selectedAtZero = browseFilterGroups([group('genre', [['Comedy', 7], ['Gothic', 0]])], frozen, definitions, {genre: ['Gothic']}); + expect(selectedAtZero[0].values.map(item => item.value)).toEqual(['Gothic', 'Comedy', 'Drama']); + + const grown = browseFilterGroups( + [group('genre', [['Farce', 3], ['Gothic', 40], ['Comedy', 30], ['Drama', 20]])], + frozen, + definitions, + {}, + ); + expect(grown[0].values.map(item => item.value)).toEqual(['Gothic', 'Comedy', 'Drama', 'Farce']); + }); + + it('replaces a numeric range token rather than stacking it, and keeps band selections', () => { + let selection = withBrowseFacetRange({}, 'page_count', 100, 400, definitions); + expect(selection).toEqual({page_count: ['100..400']}); + selection = withBrowseFacetRange(selection, 'page_count', null, 200, definitions); + expect(selection).toEqual({page_count: ['*..200']}); + expect(withBrowseFacetRange(selection, 'page_count', null, null, definitions)).toEqual({}); + expect(withBrowseFacetRange({match_score: ['70..80']}, 'match_score', 30, 90, definitions)) + .toEqual({match_score: ['70..80', '30..90']}); + }); +}); diff --git a/frontend/src/app/features/book/browse/book-browse-facet-definitions.ts b/frontend/src/app/features/book/browse/book-browse-facet-definitions.ts new file mode 100644 index 0000000000..974405c5f4 --- /dev/null +++ b/frontend/src/app/features/book/browse/book-browse-facet-definitions.ts @@ -0,0 +1,71 @@ +import {type BrowseFacetDefinitions} from '../../../shared/browse/facets'; +import { + BOOK_QUERY_FACET_KEYS, + isBookQueryFacetKey, + type BookQueryFacetKey, +} from '../data/book-query-params'; +import {type BookSummary} from '../data/book-response.models'; +import { + COLUMNS_BY_KEY, + FACET_FIELDS, + OPEN_RAIL_FACETS, + type BookFacetEntity, + type BookFacetLabelDeps, +} from './book-browse-fields'; + +export interface BookFacetLink { + readonly key: BookQueryFacetKey; + readonly value: string; +} + +interface BookFacetEntityWithId extends BookFacetEntity { + readonly id?: string | number; +} + +export function bookFacetLabelDeps( + shelves: readonly BookFacetEntityWithId[], + libraries: readonly BookFacetEntityWithId[], + translate: (key: string) => string, +): BookFacetLabelDeps { + const byId = (entities: readonly BookFacetEntityWithId[]) => + new Map(entities.map(entity => [`${entity.id}`, entity])); + const shelvesById = byId(shelves); + const librariesById = byId(libraries); + return { + shelf: id => shelvesById.get(id), + library: id => librariesById.get(id), + translate, + }; +} + +function registeredFacetLabelKey(key: BookQueryFacetKey): string { + return FACET_FIELDS.get(key)!.labelKey; +} + +export function bookFacetDefinitions(deps: BookFacetLabelDeps): BrowseFacetDefinitions { + return { + order: BOOK_QUERY_FACET_KEYS, + isKey: isBookQueryFacetKey, + labelKey: registeredFacetLabelKey, + openByDefault: OPEN_RAIL_FACETS, + kind: key => FACET_FIELDS.get(key)?.facet.kind, + valueOrder: key => FACET_FIELDS.get(key)?.facet.order, + valueDomain: key => FACET_FIELDS.get(key)?.facet.domain, + valueBuckets: key => FACET_FIELDS.get(key)?.facet.buckets, + fileSize: key => FACET_FIELDS.get(key)?.facet.fileSize ?? false, + valueLabel: (key, value) => FACET_FIELDS.get(key)?.facet.valueLabel?.(value, deps) ?? null, + valueIcon: (key, value) => FACET_FIELDS.get(key)?.facet.valueIcon?.(value, deps) ?? null, + }; +} + +export function bookFacetLinks( + book: BookSummary, + columnField: string, +): readonly BookFacetLink[] { + const facet = COLUMNS_BY_KEY.get(columnField)?.facet; + if (!facet?.bookValues) { + return []; + } + const key = facet.key; + return facet.bookValues(book).map(value => ({key, value})); +} diff --git a/frontend/src/app/features/book/browse/book-browse-fields.ts b/frontend/src/app/features/book/browse/book-browse-fields.ts new file mode 100644 index 0000000000..0a1e92819b --- /dev/null +++ b/frontend/src/app/features/book/browse/book-browse-fields.ts @@ -0,0 +1,473 @@ +import { + formatRangeLabel, + formatRangeToken, + parseRangeToken, + type BrowseFacetBucket, +} from '../../../shared/browse/facet-ranges'; +import { + type BrowseFacetKind, + type BrowseFacetValueOrder, +} from '../../../shared/browse/facets'; +import {type BrowseSortKind} from '../../../shared/browse/sort'; +import {toIconSelection, type IconSelection, type IconType} from '../../../shared/icons/icon-selection'; +import {formatFileSizeKb} from '../../../shared/util/file-size'; +import { + type BookQueryFacetKey, + type BookQuerySortKey, + type SortDirection, +} from '../data/book-query-params'; +import {type BookSummary, type KnownBookReadStatus} from '../data/book-response.models'; +import {MATCH_SCORE_BANDS} from '../model/book-value-labels'; + +export type BookColumnKind = 'text' | 'number' | 'rating' | 'date' | 'fileSize' | 'readStatus'; +export type BookColumnValue = string | number | undefined; + +export interface BookFacetEntity { + readonly name: string; + readonly icon?: string | null; + readonly iconType?: IconType | null; +} + +export interface BookFacetLabelDeps { + readonly shelf: (id: string) => BookFacetEntity | undefined; + readonly library: (id: string) => BookFacetEntity | undefined; + readonly translate: (key: string) => string; +} + +function entityIcon(entity: BookFacetEntity | undefined): IconSelection | null { + return entity?.icon ? toIconSelection(entity.icon, entity.iconType) : null; +} + +interface FacetField { + readonly key: BookQueryFacetKey; + readonly kind?: BrowseFacetKind; + readonly order?: BrowseFacetValueOrder; + readonly domain?: readonly string[]; + readonly buckets?: readonly BrowseFacetBucket[]; + readonly fileSize?: true; + readonly openByDefault?: true; + readonly bookValues?: (book: BookSummary) => readonly string[]; + readonly valueLabel?: (value: string, deps: BookFacetLabelDeps) => string | null; + readonly valueIcon?: (value: string, deps: BookFacetLabelDeps) => IconSelection | null; +} + +interface SortField { + readonly key: BookQuerySortKey; + readonly group: 'common' | 'more'; + readonly defaultDirection: SortDirection; + readonly kind: BrowseSortKind; + readonly directions?: readonly SortDirection[]; + readonly detailLine?: false; +} + +interface ColumnField { + readonly key: string; + readonly group: BookBrowseColumnGroupId; + readonly defaultVisible: boolean; + readonly defaultWidth: number; + readonly minWidth?: number; + readonly maxWidth?: number; + readonly hideable?: false; + readonly kind?: Exclude; + readonly value: (book: BookSummary) => BookColumnValue; +} + +interface BookBrowseField { + readonly labelKey: string; + readonly facet?: FacetField; + readonly sort?: SortField; + readonly column?: ColumnField; +} + +const RATING_BUCKETS: readonly BrowseFacetBucket[] = [ + {min: 0, max: 1, stars: 1}, + {min: 1, max: 2, stars: 2}, + {min: 2, max: 3, stars: 3}, + {min: 3, max: 4, stars: 4}, + {min: 4, max: 4.5, stars: 4.5}, + {min: 4.5, stars: 5}, +]; + +const MATCH_SCORE_LABELS = new Map( + MATCH_SCORE_BANDS.flatMap(band => { + const token = formatRangeToken(band); + return token == null ? [] : [[token, band.labelKey] as const]; + }), +); + +const READ_STATUS_LABEL_KEYS = new Map(Object.entries({ + UNREAD: 'unread', + READING: 'reading', + RE_READING: 'reReading', + READ: 'read', + PARTIALLY_READ: 'partiallyRead', + PAUSED: 'paused', + WONT_READ: 'wontRead', + ABANDONED: 'abandoned', + UNSET: 'unset', +} satisfies Record)); + +const CONTENT_RATING_LABEL_KEYS = new Map([ + ['EVERYONE', 'everyone'], + ['TEEN', 'teen'], + ['MATURE', 'mature'], + ['ADULT', 'adult'], + ['EXPLICIT', 'explicit'], +]); + +function rangeTokenLabel(token: string): string | null { + const range = parseRangeToken(token); + return range ? formatRangeLabel(range) : null; +} + +function fileSizeRangeLabel(token: string): string | null { + const range = parseRangeToken(token); + if (!range) { + return null; + } + return formatRangeLabel({ + min: range.min == null ? null : formatFileSizeKb(range.min), + max: range.max == null ? null : formatFileSizeKb(range.max), + }); +} + +export function bookTitle(book: BookSummary): string { + return book.metadata?.title || book.primaryFile?.fileName || ''; +} + +const FIELDS = [ + { + labelKey: 'book.fields.random', + sort: {key: 'random', group: 'common', defaultDirection: 'asc', kind: 'random', directions: ['asc'], detailLine: false}, + }, + { + labelKey: 'book.fields.title', + sort: {key: 'title', group: 'common', defaultDirection: 'asc', kind: 'alphabetical', detailLine: false}, + column: {key: 'title', group: 'publishing', defaultVisible: true, + defaultWidth: 320, minWidth: 220, maxWidth: 520, hideable: false, + value: bookTitle}, + }, + { + labelKey: 'book.fields.author', + facet: {key: 'author', openByDefault: true, bookValues: book => book.metadata?.authors ?? []}, + sort: {key: 'authorName', group: 'common', defaultDirection: 'asc', kind: 'alphabetical', detailLine: false}, + column: {key: 'authorName', group: 'publishing', defaultVisible: true, + defaultWidth: 220, value: book => book.metadata?.authors?.join(', ') ?? ''}, + }, + { + labelKey: 'book.fields.authorSurname', + sort: {key: 'authorSortName', group: 'common', defaultDirection: 'asc', kind: 'alphabetical', detailLine: false}, + }, + { + labelKey: 'book.fields.series', + facet: {key: 'series', bookValues: book => book.metadata?.seriesName ? [book.metadata.seriesName] : []}, + sort: {key: 'seriesName', group: 'common', defaultDirection: 'asc', kind: 'alphabetical'}, + column: {key: 'seriesName', group: 'publishing', defaultVisible: true, + defaultWidth: 190, value: book => book.metadata?.seriesName ?? ''}, + }, + { + labelKey: 'book.fields.seriesNumber', + sort: {key: 'seriesNumber', group: 'more', defaultDirection: 'asc', kind: 'numeric', detailLine: false}, + column: {key: 'seriesNumber', group: 'publishing', defaultVisible: true, + defaultWidth: 168, kind: 'number', value: book => book.metadata?.seriesNumber}, + }, + { + labelKey: 'book.fields.publisher', + facet: {key: 'publisher', bookValues: book => book.metadata?.publisher ? [book.metadata.publisher] : []}, + sort: {key: 'publisher', group: 'more', defaultDirection: 'asc', kind: 'alphabetical'}, + column: {key: 'publisher', group: 'publishing', defaultVisible: false, + defaultWidth: 180, value: book => book.metadata?.publisher ?? ''}, + }, + { + labelKey: 'book.fields.genre', + facet: {key: 'genre', openByDefault: true, bookValues: book => book.metadata?.categories ?? []}, + column: {key: 'categories', group: 'categorization', defaultVisible: true, + defaultWidth: 220, value: book => book.metadata?.categories?.join(', ') ?? ''}, + }, + { + labelKey: 'book.fields.tag', + facet: {key: 'tag', openByDefault: true}, + }, + { + labelKey: 'book.fields.mood', + facet: {key: 'mood'}, + }, + { + labelKey: 'book.fields.language', + facet: {key: 'language', bookValues: book => book.metadata?.language ? [book.metadata.language] : []}, + sort: {key: 'language', group: 'more', defaultDirection: 'asc', kind: 'alphabetical'}, + column: {key: 'language', group: 'publishing', defaultVisible: true, + defaultWidth: 112, value: book => book.metadata?.language ?? ''}, + }, + { + labelKey: 'book.fields.narrator', + facet: {key: 'narrator'}, + sort: {key: 'narrator', group: 'more', defaultDirection: 'asc', kind: 'alphabetical'}, + }, + { + labelKey: 'book.fields.fileType', + facet: {key: 'file_type'}, + }, + { + labelKey: 'book.fields.readStatus', + facet: { + key: 'read_status', + valueLabel: (value, deps) => { + const labelKey = READ_STATUS_LABEL_KEYS.get(value); + return labelKey ? deps.translate(`book.filter.readStatus.${labelKey}`) : null; + }, + }, + sort: {key: 'readStatus', group: 'more', defaultDirection: 'asc', kind: 'alphabetical'}, + column: {key: 'readStatus', group: 'reading', defaultVisible: true, + defaultWidth: 132, kind: 'readStatus', value: book => book.readStatus}, + }, + { + labelKey: 'book.fields.personalRating', + facet: {key: 'personal_rating', domain: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']}, + sort: {key: 'personalRating', group: 'common', defaultDirection: 'desc', kind: 'numeric'}, + }, + { + labelKey: 'book.fields.amazonRating', + facet: {key: 'amazon_rating', buckets: RATING_BUCKETS}, + sort: {key: 'amazonRating', group: 'more', defaultDirection: 'desc', kind: 'numeric'}, + column: {key: 'amazonRating', group: 'ratings', defaultVisible: false, + defaultWidth: 124, kind: 'rating', value: book => book.metadata?.amazonRating}, + }, + { + labelKey: 'book.fields.amazonReviewCount', + sort: {key: 'amazonReviewCount', group: 'more', defaultDirection: 'desc', kind: 'numeric'}, + column: {key: 'amazonReviewCount', group: 'ratings', defaultVisible: false, + defaultWidth: 132, kind: 'number', value: book => book.metadata?.amazonReviewCount}, + }, + { + labelKey: 'book.fields.goodreadsRating', + facet: {key: 'goodreads_rating', buckets: RATING_BUCKETS}, + sort: {key: 'goodreadsRating', group: 'more', defaultDirection: 'desc', kind: 'numeric'}, + column: {key: 'goodreadsRating', group: 'ratings', defaultVisible: false, + defaultWidth: 124, kind: 'rating', value: book => book.metadata?.goodreadsRating}, + }, + { + labelKey: 'book.fields.goodreadsReviewCount', + sort: {key: 'goodreadsReviewCount', group: 'more', defaultDirection: 'desc', kind: 'numeric'}, + column: {key: 'goodreadsReviewCount', group: 'ratings', defaultVisible: false, + defaultWidth: 132, kind: 'number', value: book => book.metadata?.goodreadsReviewCount}, + }, + { + labelKey: 'book.fields.hardcoverRating', + facet: {key: 'hardcover_rating', buckets: RATING_BUCKETS}, + sort: {key: 'hardcoverRating', group: 'more', defaultDirection: 'desc', kind: 'numeric'}, + column: {key: 'hardcoverRating', group: 'ratings', defaultVisible: false, + defaultWidth: 124, kind: 'rating', value: book => book.metadata?.hardcoverRating}, + }, + { + labelKey: 'book.fields.hardcoverReviewCount', + sort: {key: 'hardcoverReviewCount', group: 'more', defaultDirection: 'desc', kind: 'numeric'}, + column: {key: 'hardcoverReviewCount', group: 'ratings', defaultVisible: false, + defaultWidth: 132, kind: 'number', value: book => book.metadata?.hardcoverReviewCount}, + }, + { + labelKey: 'book.fields.ranobedbRating', + facet: {key: 'ranobedb_rating', buckets: RATING_BUCKETS}, + sort: {key: 'ranobedbRating', group: 'more', defaultDirection: 'desc', kind: 'numeric'}, + column: {key: 'ranobedbRating', group: 'ratings', defaultVisible: false, + defaultWidth: 124, kind: 'rating', value: book => book.metadata?.ranobedbRating}, + }, + { + labelKey: 'book.fields.lubimyczytacRating', + facet: {key: 'lubimyczytac_rating', buckets: RATING_BUCKETS}, + sort: {key: 'lubimyczytacRating', group: 'more', defaultDirection: 'desc', kind: 'numeric'}, + column: {key: 'lubimyczytacRating', group: 'ratings', defaultVisible: false, + defaultWidth: 124, kind: 'rating', value: book => book.metadata?.lubimyczytacRating}, + }, + { + labelKey: 'book.fields.audibleRating', + facet: {key: 'audible_rating', buckets: RATING_BUCKETS}, + sort: {key: 'audibleRating', group: 'more', defaultDirection: 'desc', kind: 'numeric'}, + column: {key: 'audibleRating', group: 'ratings', defaultVisible: false, + defaultWidth: 124, kind: 'rating', value: book => book.metadata?.audibleRating}, + }, + { + labelKey: 'book.fields.audibleReviewCount', + sort: {key: 'audibleReviewCount', group: 'more', defaultDirection: 'desc', kind: 'numeric'}, + column: {key: 'audibleReviewCount', group: 'ratings', defaultVisible: false, + defaultWidth: 132, kind: 'number', value: book => book.metadata?.audibleReviewCount}, + }, + { + labelKey: 'book.fields.pageCount', + facet: {key: 'page_count', kind: 'range', valueLabel: value => rangeTokenLabel(value)}, + sort: {key: 'pageCount', group: 'more', defaultDirection: 'desc', kind: 'numeric'}, + column: {key: 'pageCount', group: 'publishing', defaultVisible: true, + defaultWidth: 104, kind: 'number', value: book => book.metadata?.pageCount}, + }, + { + labelKey: 'book.fields.publishedDate', + sort: {key: 'publishedDate', group: 'common', defaultDirection: 'desc', kind: 'calendar'}, + column: {key: 'publishedDate', group: 'publishing', defaultVisible: true, + defaultWidth: 132, kind: 'date', value: book => book.metadata?.publishedDate}, + }, + { + labelKey: 'book.fields.publishedYear', + facet: {key: 'published_year', kind: 'range', valueLabel: value => rangeTokenLabel(value)}, + }, + { + labelKey: 'book.fields.addedOn', + sort: {key: 'addedOn', group: 'common', defaultDirection: 'desc', kind: 'calendar'}, + column: {key: 'addedOn', group: 'reading', defaultVisible: false, + defaultWidth: 132, kind: 'date', value: book => book.addedOn}, + }, + { + labelKey: 'book.fields.lastReadTime', + sort: {key: 'lastReadTime', group: 'common', defaultDirection: 'desc', kind: 'clock'}, + column: {key: 'lastReadTime', group: 'reading', defaultVisible: false, + defaultWidth: 132, kind: 'date', value: book => book.lastReadTime}, + }, + { + labelKey: 'book.fields.dateFinished', + sort: {key: 'dateFinished', group: 'more', defaultDirection: 'desc', kind: 'calendar'}, + }, + { + labelKey: 'book.fields.readingProgress', + sort: {key: 'readingProgress', group: 'common', defaultDirection: 'desc', kind: 'numeric'}, + }, + { + labelKey: 'book.fields.fileName', + column: {key: 'fileName', group: 'file', defaultVisible: false, + defaultWidth: 240, value: book => book.primaryFile?.fileName ?? ''}, + }, + { + labelKey: 'book.fields.fileSize', + facet: {key: 'file_size', kind: 'range', fileSize: true, valueLabel: value => fileSizeRangeLabel(value)}, + column: {key: 'fileSizeKb', group: 'file', defaultVisible: false, + defaultWidth: 112, kind: 'fileSize', value: book => book.primaryFile?.fileSizeKb}, + }, + { + labelKey: 'book.fields.isbn', + column: {key: 'isbn', group: 'publishing', defaultVisible: false, + defaultWidth: 150, value: book => book.metadata?.isbn13 ?? book.metadata?.isbn10 ?? ''}, + }, + { + labelKey: 'book.fields.library', + facet: { + key: 'library', + valueLabel: (value, deps) => deps.library(value)?.name ?? null, + valueIcon: (value, deps) => entityIcon(deps.library(value)), + }, + }, + { + labelKey: 'book.fields.shelf', + facet: { + key: 'shelf', + valueLabel: (value, deps) => deps.shelf(value)?.name ?? null, + valueIcon: (value, deps) => entityIcon(deps.shelf(value)), + }, + }, + {labelKey: 'book.fields.ageRating', facet: {key: 'age_rating', order: 'asc'}}, + { + labelKey: 'book.fields.contentRating', + facet: { + key: 'content_rating', + order: ['EVERYONE', 'TEEN', 'MATURE', 'ADULT', 'EXPLICIT'], + valueLabel: (value, deps) => { + const labelKey = CONTENT_RATING_LABEL_KEYS.get(value.toUpperCase()); + return labelKey ? deps.translate(`book.filter.contentRating.${labelKey}`) : null; + }, + }, + }, + { + labelKey: 'book.fields.matchScore', + facet: { + key: 'match_score', + kind: 'range', + buckets: MATCH_SCORE_BANDS, + valueLabel: (value, deps) => { + const labelKey = MATCH_SCORE_LABELS.get(value); + return labelKey ? deps.translate(`book.filter.matchScore.${labelKey}`) : rangeTokenLabel(value); + }, + }, + }, + { + labelKey: 'book.fields.shelfStatus', + facet: { + key: 'shelf_status', + valueLabel: (value, deps) => + value === 'shelved' || value === 'unshelved' + ? deps.translate(`book.filter.shelfStatus.${value}`) + : null, + }, + }, + {labelKey: 'book.fields.comicCharacter', facet: {key: 'comic_character'}}, + {labelKey: 'book.fields.comicTeam', facet: {key: 'comic_team'}}, + {labelKey: 'book.fields.comicLocation', facet: {key: 'comic_location'}}, + {labelKey: 'book.fields.comicCreator', facet: {key: 'comic_creator'}}, +] as const satisfies readonly BookBrowseField[]; + +const COLUMN_ORDER = [ + 'title', + 'authorName', + 'readStatus', + 'seriesName', + 'seriesNumber', + 'publishedDate', + 'pageCount', + 'language', + 'lastReadTime', + 'addedOn', + 'publisher', + 'categories', + 'fileName', + 'fileSizeKb', + 'isbn', + 'amazonRating', + 'amazonReviewCount', + 'goodreadsRating', + 'goodreadsReviewCount', + 'hardcoverRating', + 'hardcoverReviewCount', + 'ranobedbRating', + 'lubimyczytacRating', + 'audibleRating', + 'audibleReviewCount', +] as const satisfies readonly BookColumnKey[]; + +export const COLUMN_GROUP_ORDER = ['reading', 'publishing', 'file', 'categorization', 'ratings'] as const; + +type RegisteredField = (typeof FIELDS)[number]; +type SortKeyOf = Field extends {readonly sort: {readonly key: infer Key}} ? Key : never; +type FacetKeyOf = Field extends {readonly facet: {readonly key: infer Key}} ? Key : never; +type ColumnKeyOf = Field extends {readonly column: {readonly key: infer Key}} ? Key : never; +export type BookColumnKey = ColumnKeyOf; +type NoDetailLineSortKey = SortKeyOf>; +export type BookDetailLineSortKey = Exclude; +export type BookBrowseColumnGroupId = (typeof COLUMN_GROUP_ORDER)[number]; +type AssertNever = Value; +export type BookFieldsComplete = [ + AssertNever>>, + AssertNever>>, + AssertNever>, +]; + +type SortEntry = BookBrowseField & {readonly sort: SortField}; +type FacetEntry = BookBrowseField & {readonly facet: FacetField}; +interface ColumnEntry extends BookBrowseField { + readonly key: BookColumnKey; + readonly column: ColumnField; +} + +export const SORT_FIELDS = new Map( + FIELDS.flatMap(field => 'sort' in field ? [[field.sort.key, field] as const] : []), +); +export const FACET_FIELDS = new Map( + FIELDS.flatMap(field => 'facet' in field ? [[field.facet.key, field] as const] : []), +); +export const COLUMNS_BY_KEY = new Map( + FIELDS.flatMap(field => 'column' in field ? [[field.column.key, {key: field.column.key, ...field}] as const] : []), +); +export const OPEN_RAIL_FACETS: ReadonlySet = new Set( + [...FACET_FIELDS.values()].flatMap(field => field.facet.openByDefault ? [field.facet.key] : []), +); +export const COLUMNS: readonly ColumnEntry[] = COLUMN_ORDER.map(key => COLUMNS_BY_KEY.get(key)!); + +export function bookReadStatusLabelKey(status: BookSummary['readStatus']): string | null { + const key = status && status !== 'UNSET' ? READ_STATUS_LABEL_KEYS.get(status) : undefined; + return key ? `book.filter.readStatus.${key}` : null; +} diff --git a/frontend/src/app/features/book/browse/book-browse-sort.spec.ts b/frontend/src/app/features/book/browse/book-browse-sort.spec.ts new file mode 100644 index 0000000000..9eb8768090 --- /dev/null +++ b/frontend/src/app/features/book/browse/book-browse-sort.spec.ts @@ -0,0 +1,19 @@ +import {describe, expect, it} from 'vitest'; + +import {bookSortOptions} from './book-browse-sort'; + +describe('book browse sort options', () => { + it('offers only the sorts the server advertises, in the browser order', () => { + const options = bookSortOptions([ + 'pageCount', '-pageCount', + 'title', '-title', + 'seriesName', '-seriesName', + 'amazonReviewCount', '-amazonReviewCount', + ]); + + expect(options.map(option => option.id)).toEqual(['title', 'seriesName', 'pageCount', 'amazonReviewCount']); + expect(options[2]).toMatchObject({group: 'more', defaultDirection: 'desc', directions: ['asc', 'desc']}); + expect(bookSortOptions(['futureScore', '-title']).map(option => option.id)).toEqual(['title']); + expect(bookSortOptions(['random', '-random'])).toMatchObject([{id: 'random', directions: ['asc']}]); + }); +}); diff --git a/frontend/src/app/features/book/browse/book-browse-sort.ts b/frontend/src/app/features/book/browse/book-browse-sort.ts new file mode 100644 index 0000000000..1502d9db68 --- /dev/null +++ b/frontend/src/app/features/book/browse/book-browse-sort.ts @@ -0,0 +1,67 @@ +import {type LucideIconData} from '@lucide/angular'; + +import { + browseSortDirectionIcon, + browseSortOptions, + browseSortTermsFromCriteria, + type BrowseSortCriterion, + type BrowseSortField, + type BrowseSortOption, + type BrowseSortDefinitions, +} from '../../../shared/browse/sort'; +import { + BOOK_QUERY_SORT_KEYS, + isBookQuerySortKey, + parseSortTermsToken, + type BookQuerySortKey, + type BookSortTerm, + type SortDirection, +} from '../data/book-query-params'; +import {SORT_FIELDS, type BookDetailLineSortKey} from './book-browse-fields'; + +export type BookSortOption = BrowseSortOption; + +export function bookSortField(key: BookQuerySortKey): BrowseSortField { + const {labelKey, sort} = SORT_FIELDS.get(key)!; + return { + labelKey, + group: sort.group, + defaultDirection: sort.defaultDirection, + kind: sort.kind, + directions: sort.directions, + }; +} + +const BOOK_SORT_DEFINITIONS: BrowseSortDefinitions = { + order: BOOK_QUERY_SORT_KEYS, + field: bookSortField, + parseToken: parseSortTermsToken, +}; + +export function bookSortOptions(serverSortTokens: readonly string[]): BookSortOption[] { + return browseSortOptions(serverSortTokens, BOOK_SORT_DEFINITIONS); +} + +const LEGACY_SORT_KEYS: Readonly> = { + author: 'authorName', + authorSurnameVorname: 'authorSortName', +}; + +export function bookSortTermsFromCriteria(criteria: readonly BrowseSortCriterion[]): BookSortTerm[] { + return browseSortTermsFromCriteria( + criteria.map(criterion => ({...criterion, field: LEGACY_SORT_KEYS[criterion.field] ?? criterion.field})), + isBookQuerySortKey, + ); +} + +export function bookSortDirectionIcon( + key: BookQuerySortKey, + direction: SortDirection, +): LucideIconData { + return browseSortDirectionIcon(bookSortField(key).kind, direction); +} + +export function bookSortHasDetailLine(key: BookQuerySortKey): key is BookDetailLineSortKey { + const field = SORT_FIELDS.get(key); + return field !== undefined && field.sort.detailLine !== false; +} diff --git a/frontend/src/app/features/book/data/book-query-params.spec.ts b/frontend/src/app/features/book/data/book-query-params.spec.ts index fd8a9ed7e5..f1ecaf7d9f 100644 --- a/frontend/src/app/features/book/data/book-query-params.spec.ts +++ b/frontend/src/app/features/book/data/book-query-params.spec.ts @@ -1,9 +1,11 @@ import {describe, expect, it} from 'vitest'; import { + bookFacetQueryParams, normalizeBookCollectionFilterParams, normalizeBookQueryParams, normalizeBookPageParams, + parseFacetParams, toCollectionHttpParams, toIdsHttpParams, toPageHttpParams, @@ -39,6 +41,15 @@ describe('book query parameters', () => { }); }); + it('round-trips a selection through route params and drops unknown keys', () => { + const selection = parseFacetParams([ + 'genre:Comedy', 'genre:Drama', 'tag:Anthology', 'future_group:a:b', '__proto__:READ', + ]); + expect(selection).toEqual({genre: ['Comedy', 'Drama'], tag: ['Anthology']}); + expect(bookFacetQueryParams(selection)).toEqual({facet: ['genre:Comedy', 'genre:Drama', 'tag:Anthology']}); + expect(bookFacetQueryParams({})).toEqual({facet: null}); + }); + it('passes an empty sort through without imposing a default', () => { const normalized = normalizeBookQueryParams({ facets: {}, diff --git a/frontend/src/app/features/book/data/book-query-params.ts b/frontend/src/app/features/book/data/book-query-params.ts index a28c6a7b55..b63af27e95 100644 --- a/frontend/src/app/features/book/data/book-query-params.ts +++ b/frontend/src/app/features/book/data/book-query-params.ts @@ -1,3 +1,4 @@ +import {type BrowseFacetSelection} from '../../../shared/browse/facets'; import {HttpParams} from '@angular/common/http'; import { @@ -7,21 +8,18 @@ import { } from '../../../core/data/browse.models'; export const BOOK_QUERY_FACET_KEYS = [ 'author', - 'series', 'genre', 'tag', 'mood', - 'language', + 'series', 'publisher', - 'library', - 'shelf', + 'language', + 'narrator', 'file_type', 'read_status', 'personal_rating', - 'amazon_rating', - 'goodreads_rating', - 'hardcover_rating', - 'ranobedb_rating', + 'library', + 'shelf', 'age_rating', 'content_rating', 'match_score', @@ -29,6 +27,12 @@ export const BOOK_QUERY_FACET_KEYS = [ 'file_size', 'page_count', 'shelf_status', + 'amazon_rating', + 'goodreads_rating', + 'hardcover_rating', + 'ranobedb_rating', + 'lubimyczytac_rating', + 'audible_rating', 'comic_character', 'comic_team', 'comic_location', @@ -36,12 +40,23 @@ export const BOOK_QUERY_FACET_KEYS = [ ] as const; export const BOOK_QUERY_SORT_KEYS = [ - 'addedOn', + 'random', 'title', + 'authorName', + 'authorSortName', 'seriesName', - 'seriesNumber', - 'publisher', + 'addedOn', + 'lastReadTime', 'publishedDate', + 'personalRating', + 'readingProgress', + 'publisher', + 'seriesNumber', + 'pageCount', + 'narrator', + 'language', + 'readStatus', + 'dateFinished', 'amazonRating', 'amazonReviewCount', 'goodreadsRating', @@ -49,20 +64,15 @@ export const BOOK_QUERY_SORT_KEYS = [ 'hardcoverRating', 'hardcoverReviewCount', 'ranobedbRating', - 'narrator', - 'pageCount', - 'language', - 'personalRating', - 'lastReadTime', - 'readStatus', - 'dateFinished', - 'readingProgress', + 'lubimyczytacRating', + 'audibleRating', + 'audibleReviewCount', ] as const; export type BookQueryFacetKey = typeof BOOK_QUERY_FACET_KEYS[number]; export type BookQuerySortKey = typeof BOOK_QUERY_SORT_KEYS[number]; export type FacetLogic = BrowseFacetLogic; -export type FacetValueMap = Readonly>>; +export type FacetValueMap = BrowseFacetSelection; export type SortDirection = BrowseSortDirection; export const EMPTY_FACET_SELECTION: FacetValueMap = {}; @@ -99,6 +109,60 @@ export function isBookQuerySortKey(value: string): value is BookQuerySortKey { return BOOK_QUERY_SORT_KEY_SET.has(value); } +export function parseFacetParams(tokens: readonly string[]): FacetValueMap { + const facets = new Map(); + for (const token of tokens) { + const separator = token.indexOf(':'); + if (separator <= 0) { + continue; + } + const key = token.slice(0, separator); + if (!isBookQueryFacetKey(key)) { + continue; + } + const values = facets.get(key) ?? []; + values.push(token.slice(separator + 1)); + facets.set(key, values); + } + return normalizeFacetValueMap(Object.fromEntries(facets)); +} + +export function bookFacetQueryParams(facets: FacetValueMap) { + const tokens = Object.entries(facets).flatMap(([key, values]) => + values.map(value => `${key}:${value}`), + ); + return {facet: tokens.length > 0 ? tokens : null}; +} + +export function parseSortTermsToken(token: string | null): BookSortTerm[] { + if (!token) { + return []; + } + + const seen = new Set(); + const terms: BookSortTerm[] = []; + for (const rawTerm of token.split(',')) { + const term = rawTerm.trim(); + if (!term) { + continue; + } + const descending = term.startsWith('-'); + const key = (descending ? term.slice(1) : term).trim(); + if (!isBookQuerySortKey(key) || seen.has(key)) { + continue; + } + seen.add(key); + terms.push({key, direction: descending ? 'desc' : 'asc'}); + } + return terms; +} + +export function sortTermsToken(terms: readonly BookSortTerm[]): string { + return terms + .map(term => term.direction === 'desc' ? `-${term.key}` : term.key) + .join(','); +} + export function normalizeBookPageParams(params: BookPageParams): BookPageParams { return { ...normalizeBookQueryParams(params), @@ -116,7 +180,7 @@ export function toIdsHttpParams(params: BookQueryParams): HttpParams { } function appendSortParam(httpParams: HttpParams, sort: readonly BookSortTerm[]): HttpParams { - return sort.length === 0 ? httpParams : httpParams.set('sort', serializeSort(sort)); + return sort.length === 0 ? httpParams : httpParams.set('sort', sortTermsToken(sort)); } export function normalizeBookQueryParams(params: BookQueryParams): BookQueryParams { @@ -174,9 +238,3 @@ function appendFacetParams(httpParams: HttpParams, facets: FacetValueMap): HttpP return result; } - -function serializeSort(sort: readonly BookSortTerm[]): string { - return sort - .map(term => `${term.direction === 'desc' ? '-' : ''}${term.key}`) - .join(','); -} diff --git a/frontend/src/app/features/book/data/book-query.models.ts b/frontend/src/app/features/book/data/book-query.models.ts index 90a6a7321a..e412f1c45e 100644 --- a/frontend/src/app/features/book/data/book-query.models.ts +++ b/frontend/src/app/features/book/data/book-query.models.ts @@ -1,5 +1,4 @@ -import {BrowseFacetGroup, BrowsePage} from '../../../core/data/browse.models'; +import {BrowsePage} from '../../../core/data/browse.models'; import {BookSummary} from './book-response.models'; export type BookPage = BrowsePage; -export type BookFacetGroup = BrowseFacetGroup; diff --git a/frontend/src/app/features/book/data/book-query.service.spec.ts b/frontend/src/app/features/book/data/book-query.service.spec.ts index 4fa8b15207..16c3e9ae0f 100644 --- a/frontend/src/app/features/book/data/book-query.service.spec.ts +++ b/frontend/src/app/features/book/data/book-query.service.spec.ts @@ -113,7 +113,7 @@ describe('BookQueryService', () => { await expect(resultPromise).resolves.toMatchObject({content: [{id: 1}]}); }); - it('fetches facets without sort or size', async () => { + it('fetches facets without sort or size, splitting the sort tokens out', async () => { const resultPromise = queryClient.fetchQuery(service.facets(PARAMS)); const request = http.expectOne(`${API_CONFIG.BASE_URL}/api/v1/books/facets?facet_logic=or&query=dune&facet=genre:Science%20Fiction`); request.flush({ @@ -128,15 +128,23 @@ describe('BookQueryService', () => { value: 'Fantasy', properties: {numberOfItems: 4}, }], + }, { + metadata: {rel: 'sort', key: 'sort', title: 'Sort'}, + links: [ + {rel: 'sort', href: '', type: '', title: 'title ascending', value: 'title'}, + {rel: 'sort', href: '', type: '', title: 'title descending', value: '-title'}, + ], }], }); - await expect(resultPromise).resolves.toEqual([{ - rel: 'facet', - key: 'genre', - title: 'Genre', - values: [{value: 'Fantasy', title: 'Fantasy', count: 4, selected: true}], - }]); + await expect(resultPromise).resolves.toEqual({ + facets: [{ + key: 'genre', + title: 'Genre', + values: [{value: 'Fantasy', title: 'Fantasy', count: 4, selected: true}], + }], + sortTokens: ['title', '-title'], + }); }); it('fetches matching IDs with sort but no size', async () => { diff --git a/frontend/src/app/features/book/data/book-query.service.ts b/frontend/src/app/features/book/data/book-query.service.ts index 42ca9d858f..f973652ff3 100644 --- a/frontend/src/app/features/book/data/book-query.service.ts +++ b/frontend/src/app/features/book/data/book-query.service.ts @@ -1,15 +1,17 @@ import {HttpClient, HttpParams} from '@angular/common/http'; import {effect, inject, Injectable} from '@angular/core'; import { + type InfiniteData, infiniteQueryOptions, + type QueryKey, queryOptions, QueryClient, } from '@tanstack/angular-query-experimental'; import {lastValueFrom, Observable, map, takeUntil} from 'rxjs'; import {API_CONFIG} from '../../../core/config/api-config'; -import {findBrowsePageLink} from '../../../core/data/browse.models'; -import {mapBrowseFacetGroups, mapBrowsePage} from '../../../core/data/browse-response'; +import {BrowseFacetResult, findBrowsePageLink} from '../../../core/data/browse.models'; +import {mapBrowseFacetResult, mapBrowsePage} from '../../../core/data/browse-response'; import {bookQueryKeys} from './book-query-keys'; import { BookCollectionFilterParams, @@ -23,7 +25,7 @@ import { toIdsHttpParams, toPageHttpParams, } from './book-query-params'; -import {BookFacetGroup, BookPage} from './book-query.models'; +import {BookPage} from './book-query.models'; import {BookDetail, BookRecommendation, BookSummary} from './book-response.models'; import {abortSignal, QUERY_DEFAULTS} from '../../../core/data/query-transport'; import {AuthService} from '../../../shared/service/auth.service'; @@ -58,22 +60,39 @@ export class BookQueryService { return infiniteQueryOptions({ queryKey: bookQueryKeys.infinitePage(normalized), - queryFn: ({pageParam, signal}) => this.fetchPage(normalized, pageParam, signal), + queryFn: ({pageParam, queryKey, signal}) => this.fetchPage( + normalized, + pageParam ?? this.seededFirstPageHref(queryKey), + signal, + ), initialPageParam: null as string | null, getNextPageParam: page => findBrowsePageLink(page, 'next')?.href, ...QUERY_DEFAULTS, }); } + private seededFirstPageHref(queryKey: QueryKey): string | null { + const firstPage = this.queryClient.getQueryData>(queryKey)?.pages[0]; + return firstPage ? findBrowsePageLink(firstPage, 'self')?.href ?? null : null; + } + + restartInfinitePage(params: BookPageParams): Promise { + const normalized = normalizeBookPageParams(params); + return this.queryClient.resetQueries({ + queryKey: bookQueryKeys.infinitePage(normalized), + exact: true, + }); + } + facets(params: BookCollectionFilterParams) { const normalized = normalizeBookCollectionFilterParams(params); return queryOptions({ queryKey: bookQueryKeys.facets(normalized), - queryFn: ({signal}): Promise => this.getMapped( + queryFn: ({signal}): Promise => this.getMapped( `${this.baseUrl}/facets`, signal, - mapBrowseFacetGroups, + mapBrowseFacetResult, toCollectionHttpParams(normalized), ), ...QUERY_DEFAULTS, diff --git a/frontend/src/app/features/book/data/book-response.models.ts b/frontend/src/app/features/book/data/book-response.models.ts index 253f2d12c5..4aaee2b19a 100644 --- a/frontend/src/app/features/book/data/book-response.models.ts +++ b/frontend/src/app/features/book/data/book-response.models.ts @@ -144,6 +144,9 @@ interface BookMetadataFields { hardcoverRating?: number; hardcoverReviewCount?: number; ranobedbRating?: number; + lubimyczytacRating?: number; + audibleRating?: number; + audibleReviewCount?: number; coverUpdatedOn?: string; audiobookCoverUpdatedOn?: string; authors?: string[]; diff --git a/frontend/src/app/features/book/model/book-value-labels.ts b/frontend/src/app/features/book/model/book-value-labels.ts new file mode 100644 index 0000000000..d67e852fdb --- /dev/null +++ b/frontend/src/app/features/book/model/book-value-labels.ts @@ -0,0 +1,15 @@ +import {type BrowseFacetBucket} from '../../../shared/browse/facet-ranges'; + +export interface MatchScoreBand extends BrowseFacetBucket { + readonly labelKey: string; +} + +export const MATCH_SCORE_BANDS: readonly MatchScoreBand[] = [ + {min: 95, labelKey: 'outstanding'}, + {min: 90, max: 95, labelKey: 'excellent'}, + {min: 80, max: 90, labelKey: 'great'}, + {min: 70, max: 80, labelKey: 'good'}, + {min: 50, max: 70, labelKey: 'fair'}, + {min: 30, max: 50, labelKey: 'weak'}, + {min: 0, max: 30, labelKey: 'poor'}, +]; diff --git a/frontend/src/app/shared/util/date-format.ts b/frontend/src/app/shared/util/date-format.ts new file mode 100644 index 0000000000..3366b0fbd1 --- /dev/null +++ b/frontend/src/app/shared/util/date-format.ts @@ -0,0 +1,13 @@ +const mediumDate = new Intl.DateTimeFormat(undefined, {dateStyle: 'medium'}); +const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/; + +export function formatMediumDate(value: string | null | undefined): string { + if (!value) { + return ''; + } + const parts = DATE_ONLY.exec(value); + const date = parts + ? new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3])) + : new Date(value); + return Number.isNaN(date.getTime()) ? '' : mediumDate.format(date); +} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 8b7a13e647..09bcef7c35 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -3786,6 +3786,53 @@ "mergeFailedDetail": "Could not merge this group.", "mergePartialDetail": "{{ success }} merged, {{ failed }} failed." } + }, + "fields": { + "random": "Random", + "title": "Title", + "author": "Author", + "authorSurname": "Author (Surname)", + "genre": "Genre", + "series": "Series", + "seriesNumber": "Series position", + "publisher": "Publisher", + "tag": "Tag", + "mood": "Mood", + "language": "Language", + "narrator": "Narrator", + "fileType": "File format", + "readStatus": "Read status", + "personalRating": "Personal rating", + "amazonRating": "Amazon rating", + "amazonReviewCount": "Amazon rating count", + "goodreadsRating": "Goodreads rating", + "goodreadsReviewCount": "Goodreads rating count", + "hardcoverRating": "Hardcover rating", + "hardcoverReviewCount": "Hardcover rating count", + "ranobedbRating": "RanobeDB rating", + "pageCount": "Pages", + "publishedDate": "Published date", + "publishedYear": "Published year", + "addedOn": "Date added", + "lastReadTime": "Last read", + "dateFinished": "Date finished", + "readingProgress": "Reading progress", + "fileName": "File name", + "fileSize": "File size", + "isbn": "ISBN", + "library": "Library", + "shelf": "Shelf", + "ageRating": "Age rating", + "contentRating": "Content rating", + "matchScore": "Metadata match score", + "shelfStatus": "Shelf status", + "comicCharacter": "Comic character", + "comicTeam": "Comic team", + "comicLocation": "Comic location", + "comicCreator": "Comic creator", + "lubimyczytacRating": "Lubimyczytac rating", + "audibleReviewCount": "Audible rating count", + "audibleRating": "Audible rating" } }, "readerAudiobook": {