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:
neon
2026-10-21 05:18:37 +00:00
parent d1cc18dc51
commit edc5e41105
14 changed files with 159 additions and 29 deletions
@@ -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">
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<{
modelValue: string[]
@@ -7,6 +9,8 @@ const props = defineProps<{
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
const formatKeys = withReadAlongFormatPriority(BOOK_FORMATS)
function toggle(format: string) {
if (props.modelValue.includes(format)) {
emit(
@@ -22,9 +26,11 @@ function toggle(format: string) {
<template>
<div class="flex flex-wrap items-center gap-1">
<button
v-for="format in BOOK_FORMATS"
v-for="format in formatKeys"
:key="format"
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="
modelValue.includes(format)
@@ -33,7 +39,8 @@ function toggle(format: string) {
"
@click="toggle(format)"
>
{{ format }}
{{ formatKeyCode(format) }}
<Headphones v-if="isReadAlongFormatKey(format)" class="ms-1 inline size-3" aria-hidden="true" />
</button>
</div>
</template>
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import type { GroupRule, Rule } from '@bookorbit/types'
import { READ_ALONG_FORMAT_PRIORITY } from '@bookorbit/types'
import { useFilterTokens } from '../useFilterTokens'
import { ruleToTokenParts } from '../../lib/filter-labels'
@@ -95,6 +96,11 @@ describe('ruleToTokenParts', () => {
value: 'Want to Read',
})
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', () => {
@@ -1,9 +1,10 @@
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 { activeCustomFieldLabel } from '@/features/book/composables/useActiveCustomFields'
import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { formatBytes } from '@/lib/formatting'
import { formatKeyName } from './book-formats'
// 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,
@@ -74,10 +75,14 @@ function tokenValueLabel(field: Rule['field'], value: string): string {
const label = t(key)
return label === key ? value : label
}
if (field === 'format') return value.toUpperCase()
if (field === 'format') return formatFilterValueLabel(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
* "includes any of" reads as "is", read statuses use their translated names and formats
+2 -2
View File
@@ -51,7 +51,7 @@ import { useLibraryScan } from '@/features/scanner/composables/useLibraryScan'
import { useFilterTokens } from '@/features/book/composables/useFilterTokens'
import { useLibraryFacets } from '@/features/library/composables/useLibraryFacets'
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 { formatRelativeTimeFromNow } from '@/i18n/formatters'
import { useEffectiveViewMode } from '@/composables/useEffectiveViewMode'
@@ -343,7 +343,7 @@ const { tokens: filterTokens, selectedFormats, ruleCount: filterRuleCount, remov
const { facets } = useLibraryFacets(libraryId, filter, debouncedQuery)
const formatFacets = computed(() => facets.value?.formats ?? [])
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 canManageLibraries = computed(() => hasPermission('manage_libraries'))