mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(books): filter read-along titles
Expose read-along EPUBs as an additive format filter while preserving ordinary EPUB matching and existing client compatibility. Closes #1583
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { BOOK_FORMATS, READ_ALONG_FORMAT_PRIORITY } from '@bookorbit/types'
|
||||||
|
import FilterFormatPicker from './FilterFormatPicker.vue'
|
||||||
|
|
||||||
|
describe('FilterFormatPicker', () => {
|
||||||
|
it('offers read-along beside the physical book formats', async () => {
|
||||||
|
const wrapper = mount(FilterFormatPicker, { props: { modelValue: [] } })
|
||||||
|
const buttons = wrapper.findAll('button')
|
||||||
|
const readAlong = buttons.find((button) => button.attributes('aria-label') === 'Read-along EPUB')
|
||||||
|
|
||||||
|
expect(buttons).toHaveLength(BOOK_FORMATS.length + 1)
|
||||||
|
expect(readAlong).toBeDefined()
|
||||||
|
await readAlong!.trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toEqual([[[READ_ALONG_FORMAT_PRIORITY]]])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { BOOK_FORMATS } from '@bookorbit/types'
|
import { Headphones } from '@lucide/vue'
|
||||||
|
import { BOOK_FORMATS, isReadAlongFormatKey, withReadAlongFormatPriority } from '@bookorbit/types'
|
||||||
|
import { formatKeyCode, formatKeyName } from '@/features/book/lib/book-formats'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string[]
|
modelValue: string[]
|
||||||
@@ -7,6 +9,8 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
|
||||||
|
|
||||||
|
const formatKeys = withReadAlongFormatPriority(BOOK_FORMATS)
|
||||||
|
|
||||||
function toggle(format: string) {
|
function toggle(format: string) {
|
||||||
if (props.modelValue.includes(format)) {
|
if (props.modelValue.includes(format)) {
|
||||||
emit(
|
emit(
|
||||||
@@ -22,9 +26,11 @@ function toggle(format: string) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap items-center gap-1">
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
<button
|
<button
|
||||||
v-for="format in BOOK_FORMATS"
|
v-for="format in formatKeys"
|
||||||
:key="format"
|
:key="format"
|
||||||
type="button"
|
type="button"
|
||||||
|
:aria-label="formatKeyName(format)"
|
||||||
|
:title="formatKeyName(format)"
|
||||||
class="h-7 px-2.5 rounded-md border text-xs font-medium uppercase tracking-wide transition-colors"
|
class="h-7 px-2.5 rounded-md border text-xs font-medium uppercase tracking-wide transition-colors"
|
||||||
:class="
|
:class="
|
||||||
modelValue.includes(format)
|
modelValue.includes(format)
|
||||||
@@ -33,7 +39,8 @@ function toggle(format: string) {
|
|||||||
"
|
"
|
||||||
@click="toggle(format)"
|
@click="toggle(format)"
|
||||||
>
|
>
|
||||||
{{ format }}
|
{{ formatKeyCode(format) }}
|
||||||
|
<Headphones v-if="isReadAlongFormatKey(format)" class="ms-1 inline size-3" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { GroupRule, Rule } from '@bookorbit/types'
|
import type { GroupRule, Rule } from '@bookorbit/types'
|
||||||
|
import { READ_ALONG_FORMAT_PRIORITY } from '@bookorbit/types'
|
||||||
import { useFilterTokens } from '../useFilterTokens'
|
import { useFilterTokens } from '../useFilterTokens'
|
||||||
import { ruleToTokenParts } from '../../lib/filter-labels'
|
import { ruleToTokenParts } from '../../lib/filter-labels'
|
||||||
|
|
||||||
@@ -95,6 +96,11 @@ describe('ruleToTokenParts', () => {
|
|||||||
value: 'Want to Read',
|
value: 'Want to Read',
|
||||||
})
|
})
|
||||||
expect(ruleToTokenParts(epub)).toEqual({ field: 'Format', operator: 'is', value: 'EPUB' })
|
expect(ruleToTokenParts(epub)).toEqual({ field: 'Format', operator: 'is', value: 'EPUB' })
|
||||||
|
expect(ruleToTokenParts({ ...epub, value: [READ_ALONG_FORMAT_PRIORITY] })).toEqual({
|
||||||
|
field: 'Format',
|
||||||
|
operator: 'is',
|
||||||
|
value: 'Read-along EPUB',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the full operator when several values are picked', () => {
|
it('keeps the full operator when several values are picked', () => {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { Rule, RuleField, RuleOperator, SortField } from '@bookorbit/types'
|
import type { Rule, RuleField, RuleOperator, SortField } from '@bookorbit/types'
|
||||||
import { parseCustomRuleFieldId, parseCustomSortFieldId } from '@bookorbit/types'
|
import { isReadAlongFormatKey, parseCustomRuleFieldId, parseCustomSortFieldId } from '@bookorbit/types'
|
||||||
import { i18n } from '@/i18n'
|
import { i18n } from '@/i18n'
|
||||||
import { activeCustomFieldLabel } from '@/features/book/composables/useActiveCustomFields'
|
import { activeCustomFieldLabel } from '@/features/book/composables/useActiveCustomFields'
|
||||||
import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
|
import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
|
||||||
import { formatBytes } from '@/lib/formatting'
|
import { formatBytes } from '@/lib/formatting'
|
||||||
|
import { formatKeyName } from './book-formats'
|
||||||
|
|
||||||
// Resolved through the global composer rather than useI18n() because these helpers also run
|
// Resolved through the global composer rather than useI18n() because these helpers also run
|
||||||
// outside a component setup (useViewSort, plain computeds). Composer.t tracks the active locale,
|
// outside a component setup (useViewSort, plain computeds). Composer.t tracks the active locale,
|
||||||
@@ -74,10 +75,14 @@ function tokenValueLabel(field: Rule['field'], value: string): string {
|
|||||||
const label = t(key)
|
const label = t(key)
|
||||||
return label === key ? value : label
|
return label === key ? value : label
|
||||||
}
|
}
|
||||||
if (field === 'format') return value.toUpperCase()
|
if (field === 'format') return formatFilterValueLabel(value)
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatFilterValueLabel(value: string): string {
|
||||||
|
return isReadAlongFormatKey(value) ? formatKeyName(value) : value.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The compact form a rule takes as a token in the browse search field. A single-value
|
* The compact form a rule takes as a token in the browse search field. A single-value
|
||||||
* "includes any of" reads as "is", read statuses use their translated names and formats
|
* "includes any of" reads as "is", read statuses use their translated names and formats
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import { useLibraryScan } from '@/features/scanner/composables/useLibraryScan'
|
|||||||
import { useFilterTokens } from '@/features/book/composables/useFilterTokens'
|
import { useFilterTokens } from '@/features/book/composables/useFilterTokens'
|
||||||
import { useLibraryFacets } from '@/features/library/composables/useLibraryFacets'
|
import { useLibraryFacets } from '@/features/library/composables/useLibraryFacets'
|
||||||
import { useLibraryHeaderFacts } from '@/features/library/composables/useLibraryHeaderFacts'
|
import { useLibraryHeaderFacts } from '@/features/library/composables/useLibraryHeaderFacts'
|
||||||
import { sortFieldLabel } from '@/features/book/lib/filter-labels'
|
import { formatFilterValueLabel, sortFieldLabel } from '@/features/book/lib/filter-labels'
|
||||||
import { formatBytes } from '@/lib/formatting'
|
import { formatBytes } from '@/lib/formatting'
|
||||||
import { formatRelativeTimeFromNow } from '@/i18n/formatters'
|
import { formatRelativeTimeFromNow } from '@/i18n/formatters'
|
||||||
import { useEffectiveViewMode } from '@/composables/useEffectiveViewMode'
|
import { useEffectiveViewMode } from '@/composables/useEffectiveViewMode'
|
||||||
@@ -343,7 +343,7 @@ const { tokens: filterTokens, selectedFormats, ruleCount: filterRuleCount, remov
|
|||||||
const { facets } = useLibraryFacets(libraryId, filter, debouncedQuery)
|
const { facets } = useLibraryFacets(libraryId, filter, debouncedQuery)
|
||||||
const formatFacets = computed(() => facets.value?.formats ?? [])
|
const formatFacets = computed(() => facets.value?.formats ?? [])
|
||||||
const showFormatFacets = computed(() => formatFacets.value.length > 1 || selectedFormats.value.length > 0)
|
const showFormatFacets = computed(() => formatFacets.value.length > 1 || selectedFormats.value.length > 0)
|
||||||
const selectedFormatsLabel = computed(() => (selectedFormats.value.length > 0 ? selectedFormats.value.map((f) => f.toUpperCase()).join(', ') : null))
|
const selectedFormatsLabel = computed(() => (selectedFormats.value.length > 0 ? selectedFormats.value.map(formatFilterValueLabel).join(', ') : null))
|
||||||
const isNarrowed = computed(() => filterRuleCount.value > 0 || debouncedQuery.value.trim().length > 0)
|
const isNarrowed = computed(() => filterRuleCount.value > 0 || debouncedQuery.value.trim().length > 0)
|
||||||
|
|
||||||
const canManageLibraries = computed(() => hasPermission('manage_libraries'))
|
const canManageLibraries = computed(() => hasPermission('manage_libraries'))
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const SERVER_FEATURES = [
|
|||||||
"metadata-search-results-per-provider",
|
"metadata-search-results-per-provider",
|
||||||
/** Book and custom date filters accept the `moreThanAgo` rolling-boundary operator. */
|
/** Book and custom date filters accept the `moreThanAgo` rolling-boundary operator. */
|
||||||
"relative-date-ranges",
|
"relative-date-ranges",
|
||||||
|
/** The `format` book filter accepts `epub:readalong` for EPUBs with media overlays. */
|
||||||
|
"read-along-filter",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ServerFeature = (typeof SERVER_FEATURES)[number];
|
export type ServerFeature = (typeof SERVER_FEATURES)[number];
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ import type { CoverAspectRatio } from "./library";
|
|||||||
import { DEFAULT_FORMAT_PRIORITY } from "./library";
|
import { DEFAULT_FORMAT_PRIORITY } from "./library";
|
||||||
import type { SeriesIndex } from "./series-index";
|
import type { SeriesIndex } from "./series-index";
|
||||||
import type { EpubMediaOverlayCapability } from "./epub";
|
import type { EpubMediaOverlayCapability } from "./epub";
|
||||||
|
import { READ_ALONG_FORMAT_PRIORITY } from "./format-priority";
|
||||||
|
|
||||||
// Derived rather than duplicated: these two lists describe the same set of formats,
|
// Derived rather than duplicated: these two lists describe the same set of formats,
|
||||||
// and maintaining them separately let BOOK_FORMATS fall behind on azw and kepub.
|
// and maintaining them separately let BOOK_FORMATS fall behind on azw and kepub.
|
||||||
export const BOOK_FORMATS = DEFAULT_FORMAT_PRIORITY;
|
export const BOOK_FORMATS = DEFAULT_FORMAT_PRIORITY;
|
||||||
export type BookFormat = (typeof BOOK_FORMATS)[number];
|
export type BookFormat = (typeof BOOK_FORMATS)[number];
|
||||||
|
|
||||||
|
/** Values accepted by the format filter. Read-along is an edition key, not a physical file format. */
|
||||||
|
export const BOOK_FILTER_FORMATS = [...BOOK_FORMATS, READ_ALONG_FORMAT_PRIORITY] as const;
|
||||||
|
export type BookFilterFormat = (typeof BOOK_FILTER_FORMATS)[number];
|
||||||
|
|
||||||
/** Exported as an ordered list too, so a form offering these cannot drift from what matches them. */
|
/** Exported as an ordered list too, so a form offering these cannot drift from what matches them. */
|
||||||
export const AUDIO_FORMAT_LIST = ["m4b", "mp3", "m4a", "opus", "ogg", "flac"] as const;
|
export const AUDIO_FORMAT_LIST = ["m4b", "mp3", "m4a", "opus", "ogg", "flac"] as const;
|
||||||
const AUDIO_FORMATS = new Set<string>(AUDIO_FORMAT_LIST);
|
const AUDIO_FORMATS = new Set<string>(AUDIO_FORMAT_LIST);
|
||||||
@@ -440,7 +445,7 @@ export type BooksPage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type BookFormatFacet = {
|
export type BookFormatFacet = {
|
||||||
format: BookFormat;
|
format: BookFilterFormat;
|
||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ describe('AppInfoService', () => {
|
|||||||
'book-journal',
|
'book-journal',
|
||||||
'bookmark-edit',
|
'bookmark-edit',
|
||||||
'author-metadata-match',
|
'author-metadata-match',
|
||||||
|
'read-along-filter',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
CUSTOM_FIELD_TYPE_OPERATORS,
|
CUSTOM_FIELD_TYPE_OPERATORS,
|
||||||
EMPTY_CONTENT_FILTER_RULES,
|
EMPTY_CONTENT_FILTER_RULES,
|
||||||
FIELD_OPERATORS,
|
FIELD_OPERATORS,
|
||||||
|
READ_ALONG_FORMAT_PRIORITY,
|
||||||
SORT_FIELDS,
|
SORT_FIELDS,
|
||||||
customRuleField,
|
customRuleField,
|
||||||
type CustomMetadataFieldType,
|
type CustomMetadataFieldType,
|
||||||
@@ -1266,6 +1267,72 @@ describe('formatRuleToSql', () => {
|
|||||||
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'format', operator: 'excludesAll', value: [] }) as never, USER_CTX) as any;
|
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'format', operator: 'excludesAll', value: [] }) as never, USER_CTX) as any;
|
||||||
expect(getRuleSql(where)).toMatchObject({ type: 'sql', text: '1 = 1' });
|
expect(getRuleSql(where)).toMatchObject({ type: 'sql', text: '1 = 1' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('matches the read-along edition key only on EPUBs with media overlays', () => {
|
||||||
|
const { builder } = makeBuilder();
|
||||||
|
const where = builder.buildWhere(
|
||||||
|
wrapRule({ type: 'rule', field: 'format', operator: 'includesAny', value: [READ_ALONG_FORMAT_PRIORITY] }) as never,
|
||||||
|
USER_CTX,
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
expect(getRuleSql(where)).toMatchObject({
|
||||||
|
type: 'sql',
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
whereClause: {
|
||||||
|
clauses: [
|
||||||
|
expect.anything(),
|
||||||
|
{
|
||||||
|
type: 'or',
|
||||||
|
clauses: [
|
||||||
|
{
|
||||||
|
type: 'and',
|
||||||
|
clauses: [
|
||||||
|
{ type: 'eq', right: 'epub' },
|
||||||
|
{ type: 'eq', right: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ORs physical formats with the read-along edition key', () => {
|
||||||
|
const { builder } = makeBuilder();
|
||||||
|
const where = builder.buildWhere(
|
||||||
|
wrapRule({ type: 'rule', field: 'format', operator: 'includesAny', value: ['pdf', READ_ALONG_FORMAT_PRIORITY] }) as never,
|
||||||
|
USER_CTX,
|
||||||
|
) as any;
|
||||||
|
const predicate = (getRuleSql(where) as any).values[0].whereClause.clauses[1];
|
||||||
|
|
||||||
|
expect(predicate).toMatchObject({
|
||||||
|
type: 'or',
|
||||||
|
clauses: [
|
||||||
|
{ type: 'inArray', right: ['pdf'] },
|
||||||
|
{
|
||||||
|
type: 'and',
|
||||||
|
clauses: [
|
||||||
|
{ type: 'eq', right: 'epub' },
|
||||||
|
{ type: 'eq', right: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes books holding any read-along EPUB', () => {
|
||||||
|
const { builder } = makeBuilder();
|
||||||
|
const where = builder.buildWhere(
|
||||||
|
wrapRule({ type: 'rule', field: 'format', operator: 'excludesAll', value: [READ_ALONG_FORMAT_PRIORITY] }) as never,
|
||||||
|
USER_CTX,
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
expect(getRuleSql(where)).toMatchObject({ type: 'not', value: { type: 'sql' } });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dateRuleToSql (addedAt)', () => {
|
describe('dateRuleToSql (addedAt)', () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
|||||||
import { AnyColumn, SQL, and, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, ne, not, or, sql } from 'drizzle-orm';
|
import { AnyColumn, SQL, and, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, ne, not, or, sql } from 'drizzle-orm';
|
||||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
|
|
||||||
import { parseCustomRuleFieldId, parseCustomSortFieldId, parseSeriesIndex } from '@bookorbit/types';
|
import { READ_ALONG_FORMAT_PRIORITY, parseCustomRuleFieldId, parseCustomSortFieldId, parseSeriesIndex } from '@bookorbit/types';
|
||||||
import type {
|
import type {
|
||||||
CommunityRatingProvider,
|
CommunityRatingProvider,
|
||||||
ContentFilterRules,
|
ContentFilterRules,
|
||||||
@@ -661,13 +661,23 @@ export class BookQueryBuilder {
|
|||||||
return sql`exists (${sq})`;
|
return sql`exists (${sq})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const matchesFormats = (): SQL | undefined => {
|
||||||
|
if (!values?.length) return undefined;
|
||||||
|
const physicalFormats = values.filter((value) => value !== READ_ALONG_FORMAT_PRIORITY);
|
||||||
|
const clauses: SQL[] = [];
|
||||||
|
if (physicalFormats.length > 0) clauses.push(inArray(bookFiles.format, physicalFormats));
|
||||||
|
if (values.includes(READ_ALONG_FORMAT_PRIORITY)) {
|
||||||
|
clauses.push(and(eq(bookFiles.format, 'epub'), eq(bookFiles.mediaOverlayAvailable, true))!);
|
||||||
|
}
|
||||||
|
return or(...clauses);
|
||||||
|
};
|
||||||
|
const match = matchesFormats();
|
||||||
|
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
case 'includesAny':
|
case 'includesAny':
|
||||||
if (!values?.length) return sql`1 = 0`;
|
return match ? existsFormat(match) : sql`1 = 0`;
|
||||||
return existsFormat(inArray(bookFiles.format, values));
|
|
||||||
case 'excludesAll':
|
case 'excludesAll':
|
||||||
if (!values?.length) return sql`1 = 1`;
|
return match ? not(existsFormat(match)) : sql`1 = 1`;
|
||||||
return not(existsFormat(inArray(bookFiles.format, values)));
|
|
||||||
default:
|
default:
|
||||||
throw new BadRequestException(`Invalid operator '${operator}' for format field`);
|
throw new BadRequestException(`Invalid operator '${operator}' for format field`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1268,11 +1268,13 @@ describe('BookRepository', () => {
|
|||||||
expect(normalized).toContain('count(DISTINCT "books"."id")');
|
expect(normalized).toContain('count(DISTINCT "books"."id")');
|
||||||
expect(normalized).toContain('LEFT JOIN "book_metadata" ON "book_metadata"."book_id" = "books"."id"');
|
expect(normalized).toContain('LEFT JOIN "book_metadata" ON "book_metadata"."book_id" = "books"."id"');
|
||||||
expect(normalized).toContain('INNER JOIN "book_files" AS facet_file ON facet_file.book_id = "books"."id"');
|
expect(normalized).toContain('INNER JOIN "book_files" AS facet_file ON facet_file.book_id = "books"."id"');
|
||||||
|
expect(normalized).toContain('CROSS JOIN LATERAL');
|
||||||
|
expect(normalized).toContain("facet_file.format = 'epub' AND facet_file.media_overlay_available = true");
|
||||||
expect(normalized).toContain('"book_metadata"."title" is not null');
|
expect(normalized).toContain('"book_metadata"."title" is not null');
|
||||||
expect(normalized).toContain('"books"."status" <>');
|
expect(normalized).toContain('"books"."status" <>');
|
||||||
expect(normalized).toContain('facet_file.format IN ($2, $3)');
|
expect(normalized).toContain('facet_key.format IN ($3, $4)');
|
||||||
expect(normalized).toContain('GROUP BY facet_file.format');
|
expect(normalized).toContain('GROUP BY facet_key.format');
|
||||||
expect(query.params).toEqual(['processing', 'epub', 'pdf']);
|
expect(query.params).toEqual(['epub:readalong', 'processing', 'epub', 'pdf']);
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{ format: 'epub', count: 7 },
|
{ format: 'epub', count: 7 },
|
||||||
{ format: 'pdf', count: 3 },
|
{ format: 'pdf', count: 3 },
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
TemporalJumpBucketUnit,
|
TemporalJumpBucketUnit,
|
||||||
} from '@bookorbit/types';
|
} from '@bookorbit/types';
|
||||||
import type { UnscopedBookRecommendation } from '@bookorbit/types';
|
import type { UnscopedBookRecommendation } from '@bookorbit/types';
|
||||||
import { isAudioFormat, isComicFormat, normalizeCoverAspectRatio } from '@bookorbit/types';
|
import { READ_ALONG_FORMAT_PRIORITY, isAudioFormat, isComicFormat, normalizeCoverAspectRatio } from '@bookorbit/types';
|
||||||
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
|
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
|
||||||
import { rankFileRowsByBook } from '../../common/utils/primary-file-selection.utils';
|
import { rankFileRowsByBook } from '../../common/utils/primary-file-selection.utils';
|
||||||
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
|
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
|
||||||
@@ -1944,16 +1944,22 @@ export class BookRepository {
|
|||||||
async countFormatFacets<F extends string>(where: SQL | undefined, formats: readonly F[]): Promise<Array<{ format: F; count: number }>> {
|
async countFormatFacets<F extends string>(where: SQL | undefined, formats: readonly F[]): Promise<Array<{ format: F; count: number }>> {
|
||||||
if (formats.length === 0) return [];
|
if (formats.length === 0) return [];
|
||||||
const result = await this.db.execute<{ format: F; count: string | number }>(sql`
|
const result = await this.db.execute<{ format: F; count: string | number }>(sql`
|
||||||
SELECT facet_file.format AS format, count(DISTINCT ${books.id}) AS count
|
SELECT facet_key.format AS format, count(DISTINCT ${books.id}) AS count
|
||||||
FROM ${books}
|
FROM ${books}
|
||||||
LEFT JOIN ${bookMetadata} ON ${bookMetadata.bookId} = ${books.id}
|
LEFT JOIN ${bookMetadata} ON ${bookMetadata.bookId} = ${books.id}
|
||||||
INNER JOIN ${bookFiles} AS facet_file ON facet_file.book_id = ${books.id}
|
INNER JOIN ${bookFiles} AS facet_file ON facet_file.book_id = ${books.id}
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT facet_file.format AS format
|
||||||
|
UNION ALL
|
||||||
|
SELECT ${READ_ALONG_FORMAT_PRIORITY}
|
||||||
|
WHERE facet_file.format = 'epub' AND facet_file.media_overlay_available = true
|
||||||
|
) AS facet_key
|
||||||
WHERE ${this.visibleWhere(where)}
|
WHERE ${this.visibleWhere(where)}
|
||||||
AND facet_file.format IN (${sql.join(
|
AND facet_key.format IN (${sql.join(
|
||||||
formats.map((format) => sql`${format}`),
|
formats.map((format) => sql`${format}`),
|
||||||
sql`, `,
|
sql`, `,
|
||||||
)})
|
)})
|
||||||
GROUP BY facet_file.format
|
GROUP BY facet_key.format
|
||||||
`);
|
`);
|
||||||
return result.rows.map((row) => ({ format: row.format, count: Number(row.count) }));
|
return result.rows.map((row) => ({ format: row.format, count: Number(row.count) }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { SelfWriteRegistry } from '../../common/services/self-write-registry.ser
|
|||||||
import { FileWatcherService, WATCHER_DEBOUNCE_MS } from '../scanner/file-watcher.service';
|
import { FileWatcherService, WATCHER_DEBOUNCE_MS } from '../scanner/file-watcher.service';
|
||||||
import {
|
import {
|
||||||
AUDIO_BOOK_FILE_WRITE_FIELDS,
|
AUDIO_BOOK_FILE_WRITE_FIELDS,
|
||||||
BOOK_FORMATS,
|
BOOK_FILTER_FORMATS,
|
||||||
MetadataProviderKey,
|
MetadataProviderKey,
|
||||||
Permission,
|
Permission,
|
||||||
type BookQuery,
|
type BookQuery,
|
||||||
@@ -5704,7 +5704,7 @@ describe('BookService', () => {
|
|||||||
expect(queryBuilder.buildWhere).toHaveBeenCalledTimes(2);
|
expect(queryBuilder.buildWhere).toHaveBeenCalledTimes(2);
|
||||||
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter, q: 'dune' });
|
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter, q: 'dune' });
|
||||||
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter: undefined, q: undefined });
|
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter: undefined, q: undefined });
|
||||||
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter, q: 'dune' }, BOOK_FORMATS);
|
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter, q: 'dune' }, BOOK_FILTER_FORMATS);
|
||||||
expect(result).toEqual({ total: 12, libraryTotal: 40, formats: [] });
|
expect(result).toEqual({ total: 12, libraryTotal: 40, formats: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5733,7 +5733,7 @@ describe('BookService', () => {
|
|||||||
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(filter, expect.objectContaining({ q: 'dune' }));
|
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(filter, expect.objectContaining({ q: 'dune' }));
|
||||||
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(stripped, expect.objectContaining({ q: 'dune' }));
|
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(stripped, expect.objectContaining({ q: 'dune' }));
|
||||||
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter, q: 'dune' });
|
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter, q: 'dune' });
|
||||||
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter: stripped, q: 'dune' }, BOOK_FORMATS);
|
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter: stripped, q: 'dune' }, BOOK_FILTER_FORMATS);
|
||||||
expect(filter.rules).toHaveLength(4);
|
expect(filter.rules).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5745,7 +5745,7 @@ describe('BookService', () => {
|
|||||||
|
|
||||||
expect(queryBuilder.buildWhere).toHaveBeenCalledTimes(2);
|
expect(queryBuilder.buildWhere).toHaveBeenCalledTimes(2);
|
||||||
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter, q: undefined });
|
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter, q: undefined });
|
||||||
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter, q: undefined }, BOOK_FORMATS);
|
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter, q: undefined }, BOOK_FILTER_FORMATS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a root format exclusion, which the chips do not own', async () => {
|
it('keeps a root format exclusion, which the chips do not own', async () => {
|
||||||
@@ -5757,7 +5757,7 @@ describe('BookService', () => {
|
|||||||
|
|
||||||
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith(
|
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith(
|
||||||
{ filter: { type: 'group', join: 'AND', rules: [excludePdf] }, q: undefined },
|
{ filter: { type: 'group', join: 'AND', rules: [excludePdf] }, q: undefined },
|
||||||
BOOK_FORMATS,
|
BOOK_FILTER_FORMATS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5771,7 +5771,7 @@ describe('BookService', () => {
|
|||||||
|
|
||||||
await service.facetsForLibrary(makeUser(), 7, facetQuery(filter));
|
await service.facetsForLibrary(makeUser(), 7, facetQuery(filter));
|
||||||
|
|
||||||
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter, q: undefined }, BOOK_FORMATS);
|
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter, q: undefined }, BOOK_FILTER_FORMATS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops the filter entirely when the only root rule is a format rule, still applying q', async () => {
|
it('drops the filter entirely when the only root rule is a format rule, still applying q', async () => {
|
||||||
@@ -5782,12 +5782,13 @@ describe('BookService', () => {
|
|||||||
|
|
||||||
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(undefined, expect.objectContaining({ q: 'dune' }));
|
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(undefined, expect.objectContaining({ q: 'dune' }));
|
||||||
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter, q: 'dune' });
|
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter, q: 'dune' });
|
||||||
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter: undefined, q: 'dune' }, BOOK_FORMATS);
|
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter: undefined, q: 'dune' }, BOOK_FILTER_FORMATS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sorts formats by count then name and omits empty ones', async () => {
|
it('sorts formats by count then name and omits empty ones', async () => {
|
||||||
const { service, bookRepo } = makeFacetService();
|
const { service, bookRepo } = makeFacetService();
|
||||||
bookRepo.countFormatFacets.mockResolvedValue([
|
bookRepo.countFormatFacets.mockResolvedValue([
|
||||||
|
{ format: 'epub:readalong', count: 4 },
|
||||||
{ format: 'pdf', count: 3 },
|
{ format: 'pdf', count: 3 },
|
||||||
{ format: 'epub', count: 7 },
|
{ format: 'epub', count: 7 },
|
||||||
{ format: 'cbz', count: 0 },
|
{ format: 'cbz', count: 0 },
|
||||||
@@ -5799,6 +5800,7 @@ describe('BookService', () => {
|
|||||||
|
|
||||||
expect(result.formats).toEqual([
|
expect(result.formats).toEqual([
|
||||||
{ format: 'epub', count: 7 },
|
{ format: 'epub', count: 7 },
|
||||||
|
{ format: 'epub:readalong', count: 4 },
|
||||||
{ format: 'mobi', count: 3 },
|
{ format: 'mobi', count: 3 },
|
||||||
{ format: 'pdf', count: 3 },
|
{ format: 'pdf', count: 3 },
|
||||||
{ format: 'azw3', count: 1 },
|
{ format: 'azw3', count: 1 },
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { basename, dirname, extname, join, relative } from 'path';
|
|||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BOOK_FORMATS,
|
BOOK_FILTER_FORMATS,
|
||||||
BOOK_METADATA_LOCK_FIELDS,
|
BOOK_METADATA_LOCK_FIELDS,
|
||||||
DEFAULT_DOWNLOAD_PATTERN,
|
DEFAULT_DOWNLOAD_PATTERN,
|
||||||
COVER_MEDIA,
|
COVER_MEDIA,
|
||||||
@@ -1160,7 +1160,7 @@ export class BookService {
|
|||||||
const [total, libraryTotal, formatCounts] = await Promise.all([
|
const [total, libraryTotal, formatCounts] = await Promise.all([
|
||||||
this.bookRepo.countWhere(where),
|
this.bookRepo.countWhere(where),
|
||||||
narrowed ? this.bookRepo.countWhere(this.queryBuilder.buildWhere(undefined, scope)) : null,
|
narrowed ? this.bookRepo.countWhere(this.queryBuilder.buildWhere(undefined, scope)) : null,
|
||||||
this.bookRepo.countFormatFacets(formatWhere, BOOK_FORMATS),
|
this.bookRepo.countFormatFacets(formatWhere, BOOK_FILTER_FORMATS),
|
||||||
]);
|
]);
|
||||||
const formats = formatCounts.filter((facet) => facet.count > 0).sort((a, b) => b.count - a.count || a.format.localeCompare(b.format));
|
const formats = formatCounts.filter((facet) => facet.count > 0).sort((a, b) => b.count - a.count || a.format.localeCompare(b.format));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user