mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): remember a default read-aloud narration language
Resolve book languages stated as names ("English") to a code, so the
dialog and the server's fallback stop calling English unsupported. The
dialog groups the ebook and audiobook in a Sources card, shows
Highlighting without the Advanced disclosure, and saves a per-user
default under /user-preferences/read-aloud for books it cannot match.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { Permission, type CreateReadAloudJobRequest, type ReadAloudCapability, type ReadAloudJobInput } from '@bookorbit/types'
|
||||
import { activateI18nLocale, i18n } from '@/i18n'
|
||||
@@ -7,12 +7,19 @@ import { formatLanguageName } from '@/i18n/formatters'
|
||||
import ReadAloudDialog from '../components/ReadAloudDialog.vue'
|
||||
import { makeCapability } from './fixtures'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ permissions: new Set<string>() }))
|
||||
const mocks = vi.hoisted(() => ({
|
||||
permissions: new Set<string>(),
|
||||
savedLanguage: null as string | null,
|
||||
saveOk: true,
|
||||
api: vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/composables/usePermissions', () => ({
|
||||
usePermissions: () => ({ hasPermission: (name: string) => mocks.permissions.has(name) }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({ api: mocks.api }))
|
||||
|
||||
const t = i18n.global.t
|
||||
|
||||
const DialogContentStub = defineComponent({ name: 'DialogContentStub', emits: ['closeAutoFocus'], template: '<div><slot /></div>' })
|
||||
@@ -34,6 +41,13 @@ beforeAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.permissions = new Set([Permission.LibraryDeleteBooks])
|
||||
mocks.savedLanguage = null
|
||||
mocks.saveOk = true
|
||||
mocks.api.mockReset()
|
||||
mocks.api.mockImplementation(async (_url, init) => {
|
||||
if (init?.method === 'PUT') return new Response(null, { status: mocks.saveOk ? 204 : 500 })
|
||||
return new Response(JSON.stringify({ settings: { defaultLanguage: mocks.savedLanguage } }), { status: 200 })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -69,6 +83,10 @@ function buttonByText(target: VueWrapper, text: string) {
|
||||
return button
|
||||
}
|
||||
|
||||
function buttonOrNull(target: VueWrapper, text: string) {
|
||||
return target.findAll('button').find((candidate) => candidate.text().trim() === text) ?? null
|
||||
}
|
||||
|
||||
function trackOrder(target: VueWrapper): number[] {
|
||||
return target.findAll('li[data-track-id]').map((row) => Number(row.attributes('data-track-id')))
|
||||
}
|
||||
@@ -275,13 +293,13 @@ describe('ReadAloudDialog', () => {
|
||||
expect(unknown.text()).toContain(t('book.detail.files.readAloud.dialog.model', { model: 'wav2vec2-base' }))
|
||||
})
|
||||
|
||||
it('submits word highlighting only when chosen under Advanced', async () => {
|
||||
it('offers word highlighting without a disclosure, and submits it only when chosen', async () => {
|
||||
const dialog = mountDialog()
|
||||
const advanced = buttonByText(dialog, t('book.detail.files.readAloud.dialog.advanced'))
|
||||
expect(advanced.attributes('aria-expanded')).toBe('false')
|
||||
const word = dialog.find<HTMLInputElement>('input[type="radio"][value="word"]')
|
||||
expect(word.isVisible()).toBe(true)
|
||||
expect(word.element.checked).toBe(false)
|
||||
|
||||
await advanced.trigger('click')
|
||||
await dialog.find('input[type="radio"][value="word"]').setValue(true)
|
||||
await word.setValue(true)
|
||||
|
||||
expect((await submit(dialog)).granularity).toBe('word')
|
||||
})
|
||||
@@ -295,6 +313,23 @@ describe('ReadAloudDialog', () => {
|
||||
expect(await submit(dialog)).toEqual({ epubFileId: 101, audioFileIds: [104, 102], language: 'de', granularity: 'word' })
|
||||
})
|
||||
|
||||
it.each(['English', 'english', 'eng'])('picks English for a book whose language is stored as "%s"', async (stored) => {
|
||||
const dialog = mountDialog({ defaultLanguage: stored })
|
||||
|
||||
expect(dialog.find('select').element.value).toBe('en')
|
||||
expect(dialog.text()).not.toContain(t('book.detail.files.readAloud.dialog.languageUnsupported', { language: stored }))
|
||||
expect((await submit(dialog)).language).toBe('en')
|
||||
})
|
||||
|
||||
it('prefills "Start over" from a job that stored a full language tag', async () => {
|
||||
const dialog = mountDialog({
|
||||
defaultLanguage: null,
|
||||
initial: { epubFileId: 101, audioFileIds: [102, 103, 104], language: 'en-US', granularity: 'sentence', sourceBytes: 0, replaceFileId: null },
|
||||
})
|
||||
|
||||
expect((await submit(dialog)).language).toBe('en')
|
||||
})
|
||||
|
||||
it('leaves the language unset and says why when the book language has no model', async () => {
|
||||
const dialog = mountDialog({ defaultLanguage: 'nl' })
|
||||
|
||||
@@ -304,6 +339,72 @@ describe('ReadAloudDialog', () => {
|
||||
expect(start.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('fills in the saved default when the book states no language', async () => {
|
||||
mocks.savedLanguage = 'de'
|
||||
const dialog = mountDialog({ defaultLanguage: null })
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog.find('select').element.value).toBe('de')
|
||||
expect(dialog.text()).toContain(t('book.detail.files.readAloud.dialog.languageIsDefault'))
|
||||
expect(buttonOrNull(dialog, t('book.detail.files.readAloud.dialog.setDefaultLanguage'))).toBeNull()
|
||||
})
|
||||
|
||||
it("keeps the book's own language over the saved default", async () => {
|
||||
mocks.savedLanguage = 'de'
|
||||
const dialog = mountDialog({ defaultLanguage: 'English' })
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog.find('select').element.value).toBe('en')
|
||||
expect(dialog.text()).toContain(t('book.detail.files.readAloud.dialog.languageFromBook'))
|
||||
})
|
||||
|
||||
it('uses the saved default for an unsupported book language and still says why', async () => {
|
||||
mocks.savedLanguage = 'en'
|
||||
const dialog = mountDialog({ defaultLanguage: 'nl' })
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog.find('select').element.value).toBe('en')
|
||||
expect(dialog.text()).toContain(t('book.detail.files.readAloud.dialog.languageUnsupported', { language: formatLanguageName('nl') }))
|
||||
})
|
||||
|
||||
it('never overrides a language picked before the saved default arrives', async () => {
|
||||
mocks.savedLanguage = 'de'
|
||||
const dialog = mountDialog({ defaultLanguage: null })
|
||||
await dialog.find('select').setValue('fr')
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog.find('select').element.value).toBe('fr')
|
||||
})
|
||||
|
||||
it('saves the chosen language as the default and confirms it', async () => {
|
||||
const dialog = mountDialog()
|
||||
await flushPromises()
|
||||
await dialog.find('select').setValue('fr')
|
||||
|
||||
await buttonByText(dialog, t('book.detail.files.readAloud.dialog.setDefaultLanguage')).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.api).toHaveBeenCalledWith('/api/v1/user-preferences/read-aloud', expect.objectContaining({ method: 'PUT' }))
|
||||
const [, init] = mocks.api.mock.calls.find(([, options]) => options?.method === 'PUT')!
|
||||
expect(JSON.parse(init!.body as string)).toEqual({ settings: { defaultLanguage: 'fr' } })
|
||||
expect(dialog.text()).toContain(t('book.detail.files.readAloud.dialog.languageSavedAsDefault'))
|
||||
expect(buttonOrNull(dialog, t('book.detail.files.readAloud.dialog.setDefaultLanguage'))).toBeNull()
|
||||
expect(document.activeElement).toBe(dialog.find('select').element)
|
||||
})
|
||||
|
||||
it('says so when the default could not be saved', async () => {
|
||||
mocks.saveOk = false
|
||||
const dialog = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
await buttonByText(dialog, t('book.detail.files.readAloud.dialog.setDefaultLanguage')).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const alert = dialog.findAll('[role="alert"]').find((node) => node.text() === t('book.detail.files.readAloud.dialog.defaultLanguageFailed'))
|
||||
expect(alert).toBeDefined()
|
||||
expect(buttonOrNull(dialog, t('book.detail.files.readAloud.dialog.setDefaultLanguage'))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows its own error once', () => {
|
||||
wrapper = mount(ReadAloudDialog, {
|
||||
props: {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useId, watch } from 'vue'
|
||||
import { computed, nextTick, ref, useId, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AudioLines, ChevronDown, Cpu, Loader2, TriangleAlert, X } from '@lucide/vue'
|
||||
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
||||
import {
|
||||
estimateReadAloudWorkBytes,
|
||||
languageSubtagOf,
|
||||
Permission,
|
||||
type CreateReadAloudJobRequest,
|
||||
type ReadAloudCapability,
|
||||
@@ -15,7 +16,8 @@ import { formatBytes } from '@/lib/formatting'
|
||||
import { formatLanguageName } from '@/i18n/formatters'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { baseLanguage, deviceLabel, formatReadAloudDuration } from '../lib/read-aloud'
|
||||
import { useReadAloudPreferences } from '../composables/useReadAloudPreferences'
|
||||
import { deviceLabel, formatReadAloudDuration } from '../lib/read-aloud'
|
||||
import ReadAloudTrackList from './ReadAloudTrackList.vue'
|
||||
|
||||
/** Display names for the model ids the server reports; an id this build does not know shows as sent. */
|
||||
@@ -36,15 +38,20 @@ const emit = defineEmits<{ close: []; create: [input: CreateReadAloudJobRequest]
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { hasPermission } = usePermissions()
|
||||
const preferences = useReadAloudPreferences()
|
||||
const savingDefault = preferences.saving
|
||||
const ids = useId()
|
||||
const trackListId = `${ids}-tracks`
|
||||
const languageHintId = `${ids}-language-hint`
|
||||
|
||||
const epubFileId = ref<number | null>(null)
|
||||
const trackOrder = ref<number[]>([])
|
||||
const skippedTracks = ref<number[]>([])
|
||||
const orderOpen = ref(false)
|
||||
const advancedOpen = ref(false)
|
||||
const language = ref('')
|
||||
/** Whether the selection was just saved as the default, so the hint can confirm it. */
|
||||
const defaultSaved = ref(false)
|
||||
const defaultSaveFailed = ref(false)
|
||||
const granularity = ref<ReadAloudGranularity>('sentence')
|
||||
/** The read-along to overwrite, or "keep" to add the new one beside it. */
|
||||
const existingChoice = ref<number | 'keep'>('keep')
|
||||
@@ -88,14 +95,29 @@ const languageOptions = computed(() =>
|
||||
.sort((a, b) => a.name.localeCompare(b.name, locale.value)),
|
||||
)
|
||||
|
||||
/** Say why the book's own language was not picked, rather than leaving an unexplained empty select. */
|
||||
const unsupportedLanguageHint = computed(() => {
|
||||
if (language.value) return null
|
||||
const base = baseLanguage(props.defaultLanguage)
|
||||
if (!base || isSupportedLanguage(base)) return null
|
||||
return t('book.detail.files.readAloud.dialog.languageUnsupported', { language: formatLanguageName(base) })
|
||||
/** The book's language as a code; metadata often states it as a name such as "English". */
|
||||
const bookLanguage = computed(() => languageSubtagOf(props.defaultLanguage, props.capability.supportedLanguages))
|
||||
|
||||
/**
|
||||
* Where the selection came from. A book language the model cannot use is said even once a default
|
||||
* fills in, because aligning narration in one language against a model for another fails.
|
||||
*/
|
||||
const languageHint = computed(() => {
|
||||
const isDefault = language.value !== '' && language.value === preferences.defaultLanguage.value
|
||||
if (isDefault && defaultSaved.value) return t('book.detail.files.readAloud.dialog.languageSavedAsDefault')
|
||||
const base = bookLanguage.value
|
||||
if (base && !isSupportedLanguage(base)) {
|
||||
return t('book.detail.files.readAloud.dialog.languageUnsupported', { language: formatLanguageName(base) })
|
||||
}
|
||||
if (isDefault) return t('book.detail.files.readAloud.dialog.languageIsDefault')
|
||||
if (language.value && language.value === base) return t('book.detail.files.readAloud.dialog.languageFromBook')
|
||||
return null
|
||||
})
|
||||
|
||||
const canSetDefaultLanguage = computed(
|
||||
() => preferences.loaded.value && language.value.length > 0 && language.value !== preferences.defaultLanguage.value,
|
||||
)
|
||||
|
||||
const deviceLine = computed(() => deviceLabel(props.capability.workerDevice, t))
|
||||
const estimatedSize = computed(() =>
|
||||
props.capability.estimatedOutputBytes != null
|
||||
@@ -124,6 +146,13 @@ function isSupportedLanguage(code: string): boolean {
|
||||
return (props.capability.supportedLanguages as readonly string[]).includes(code)
|
||||
}
|
||||
|
||||
/** A previous job's language, then the book's, then the user's saved default: the first the model supports. */
|
||||
function preferredLanguage(): string {
|
||||
const candidates = props.initial ? [languageSubtagOf(props.initial.language)] : [bookLanguage.value]
|
||||
candidates.push(preferences.defaultLanguage.value)
|
||||
return candidates.find((code): code is string => !!code && isSupportedLanguage(code)) ?? ''
|
||||
}
|
||||
|
||||
/** BookOrbit's own output is safe to overwrite; an imported one may be the only copy of someone's work. */
|
||||
function defaultExistingChoice(): number | 'keep' {
|
||||
if (!canReplace.value) return 'keep'
|
||||
@@ -151,10 +180,10 @@ function initialise(): void {
|
||||
}
|
||||
orderOpen.value = false
|
||||
|
||||
const preferred = initial?.language ?? baseLanguage(props.defaultLanguage)
|
||||
language.value = preferred && isSupportedLanguage(preferred) ? preferred : ''
|
||||
language.value = preferredLanguage()
|
||||
defaultSaved.value = false
|
||||
defaultSaveFailed.value = false
|
||||
granularity.value = initial?.granularity ?? 'sentence'
|
||||
advancedOpen.value = granularity.value !== 'sentence'
|
||||
|
||||
const existingIds = capability.existingReadAlongs.map((file) => file.fileId)
|
||||
existingChoice.value =
|
||||
@@ -194,10 +223,22 @@ watch(
|
||||
if (!open) return
|
||||
returnFocusTo = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
initialise()
|
||||
void loadPreferences()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** The saved default arrives after the dialog opens; it only fills a selection nobody has made yet. */
|
||||
async function loadPreferences(): Promise<void> {
|
||||
await preferences.load()
|
||||
if (props.open && !language.value) language.value = preferredLanguage()
|
||||
}
|
||||
|
||||
watch(language, () => {
|
||||
defaultSaved.value = false
|
||||
defaultSaveFailed.value = false
|
||||
})
|
||||
|
||||
function handleCloseAutoFocus(event: Event): void {
|
||||
const target = returnFocusTo
|
||||
returnFocusTo = null
|
||||
@@ -230,8 +271,18 @@ function handleRestoreOrder(): void {
|
||||
skippedTracks.value = []
|
||||
}
|
||||
|
||||
function handleToggleAdvanced(): void {
|
||||
advancedOpen.value = !advancedOpen.value
|
||||
async function handleSetDefaultLanguage(): Promise<void> {
|
||||
const code = language.value
|
||||
if (!code || preferences.saving.value) return
|
||||
defaultSaveFailed.value = false
|
||||
const saved = await preferences.saveDefaultLanguage(code)
|
||||
if (language.value !== code) return
|
||||
defaultSaved.value = saved
|
||||
defaultSaveFailed.value = !saved
|
||||
if (!saved) return
|
||||
// The button goes once the selection is the default; the select reads out the new hint instead.
|
||||
await nextTick()
|
||||
document.getElementById(`${ids}-language`)?.focus()
|
||||
}
|
||||
|
||||
function existingOrigin(generatedByJobId: string | null): string {
|
||||
@@ -280,37 +331,88 @@ function handleSubmit(): void {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-5 py-5 sm:px-6">
|
||||
<!-- Ebook -->
|
||||
<fieldset v-if="showEpubChoice">
|
||||
<legend class="text-sm font-semibold text-foreground">{{ t('book.detail.files.readAloud.dialog.ebook') }}</legend>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.ebookHint') }}</p>
|
||||
<div class="mt-2.5 grid gap-1.5">
|
||||
<label
|
||||
v-for="file in capability.epubFiles"
|
||||
:key="file.id"
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors"
|
||||
:class="epubFileId === file.id ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted'"
|
||||
>
|
||||
<input
|
||||
v-model="epubFileId"
|
||||
type="radio"
|
||||
:name="`${ids}-epub`"
|
||||
:value="file.id"
|
||||
class="size-4 shrink-0 accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-foreground" :title="file.filename">{{ file.filename }}</span>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatBytes(file.sizeBytes) }}</span>
|
||||
</label>
|
||||
<div class="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-5 sm:px-6">
|
||||
<!-- Sources -->
|
||||
<section class="rounded-lg border border-border" :aria-labelledby="`${ids}-sources`">
|
||||
<h3 :id="`${ids}-sources`" class="px-3.5 pt-3 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{{ t('book.detail.files.readAloud.dialog.sources') }}
|
||||
</h3>
|
||||
<div class="divide-y divide-border">
|
||||
<div v-if="showEpubChoice || singleEpub" class="grid gap-x-4 gap-y-1 px-3.5 py-2.5 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<span :id="`${ids}-ebook`" class="text-sm text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.ebook') }}</span>
|
||||
<div
|
||||
v-if="showEpubChoice"
|
||||
role="radiogroup"
|
||||
class="min-w-0"
|
||||
:aria-labelledby="`${ids}-ebook`"
|
||||
:aria-describedby="`${ids}-ebook-hint`"
|
||||
>
|
||||
<p :id="`${ids}-ebook-hint`" class="text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.ebookHint') }}</p>
|
||||
<div class="mt-2 grid gap-1.5">
|
||||
<label
|
||||
v-for="file in capability.epubFiles"
|
||||
:key="file.id"
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2 transition-colors"
|
||||
:class="epubFileId === file.id ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted'"
|
||||
>
|
||||
<input
|
||||
v-model="epubFileId"
|
||||
type="radio"
|
||||
:name="`${ids}-epub`"
|
||||
:value="file.id"
|
||||
class="size-4 shrink-0 accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-foreground" :title="file.filename">{{ file.filename }}</span>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatBytes(file.sizeBytes) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="singleEpub" class="flex min-w-0 items-baseline gap-2 text-sm text-foreground">
|
||||
<span class="min-w-0 truncate" :title="singleEpub.filename">{{ singleEpub.filename }}</span>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatBytes(singleEpub.sizeBytes) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div role="group" class="grid gap-x-4 gap-y-1 px-3.5 py-2.5 sm:grid-cols-[7rem_minmax(0,1fr)]" :aria-labelledby="`${ids}-audio`">
|
||||
<span :id="`${ids}-audio`" class="text-sm text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.audio') }}</span>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<p class="text-sm text-foreground">{{ audioSummary }}</p>
|
||||
<p v-if="skippedTracks.length > 0" class="text-xs text-muted-foreground">
|
||||
{{ t('book.detail.files.readAloud.dialog.audioSkipped', { count: skippedTracks.length }) }}
|
||||
</p>
|
||||
<button
|
||||
v-if="capability.audioFiles.length > 1"
|
||||
type="button"
|
||||
class="-mx-1 inline-flex min-h-7 items-center gap-1 rounded px-1 text-[13px] font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-expanded="orderOpen"
|
||||
:aria-controls="trackListId"
|
||||
@click="handleToggleOrder"
|
||||
>
|
||||
{{ t('book.detail.files.readAloud.dialog.changeOrder') }}
|
||||
<ChevronDown
|
||||
class="size-3.5 transition-transform motion-reduce:transition-none"
|
||||
:class="orderOpen ? 'rotate-180' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="orderOpen" :id="trackListId" class="mt-1.5 sm:col-span-2">
|
||||
<ReadAloudTrackList v-model:order="trackOrder" v-model:skipped="skippedTracks" :tracks="capability.audioFiles" />
|
||||
<button
|
||||
v-if="orderChanged"
|
||||
type="button"
|
||||
class="-mx-1 mt-1.5 inline-flex min-h-7 items-center rounded px-1 text-[13px] font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click="handleRestoreOrder"
|
||||
>
|
||||
{{ t('book.detail.files.readAloud.dialog.restoreOrder') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="includedTrackIds.length === 0" role="alert" class="text-xs text-destructive sm:col-start-2">
|
||||
{{ t('book.detail.files.readAloud.dialog.noTracks') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div v-else-if="singleEpub">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ t('book.detail.files.readAloud.dialog.ebook') }}</h3>
|
||||
<p class="mt-1 flex items-center gap-2 text-sm text-foreground">
|
||||
<span class="min-w-0 truncate" :title="singleEpub.filename">{{ singleEpub.filename }}</span>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatBytes(singleEpub.sizeBytes) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Existing read-alongs -->
|
||||
<fieldset v-if="existingReadAlongs.length > 0 && canReplace" class="rounded-lg border border-border bg-muted/40 p-3.5">
|
||||
@@ -386,131 +488,94 @@ function handleSubmit(): void {
|
||||
<p class="mt-2 text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.existing.replaceNotPermitted') }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Audiobook -->
|
||||
<section :aria-labelledby="`${ids}-audio`">
|
||||
<h3 :id="`${ids}-audio`" class="text-sm font-semibold text-foreground">{{ t('book.detail.files.readAloud.dialog.audio') }}</h3>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<p class="text-sm text-foreground">{{ audioSummary }}</p>
|
||||
<p v-if="skippedTracks.length > 0" class="text-xs text-muted-foreground">
|
||||
{{ t('book.detail.files.readAloud.dialog.audioSkipped', { count: skippedTracks.length }) }}
|
||||
</p>
|
||||
<button
|
||||
v-if="capability.audioFiles.length > 1"
|
||||
type="button"
|
||||
class="-mx-1 inline-flex min-h-7 items-center gap-1 rounded px-1 text-[13px] font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-expanded="orderOpen"
|
||||
:aria-controls="trackListId"
|
||||
@click="handleToggleOrder"
|
||||
>
|
||||
{{ t('book.detail.files.readAloud.dialog.changeOrder') }}
|
||||
<ChevronDown
|
||||
class="size-3.5 transition-transform motion-reduce:transition-none"
|
||||
:class="orderOpen ? 'rotate-180' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="orderOpen" :id="trackListId" class="mt-2.5">
|
||||
<ReadAloudTrackList v-model:order="trackOrder" v-model:skipped="skippedTracks" :tracks="capability.audioFiles" />
|
||||
<button
|
||||
v-if="orderChanged"
|
||||
type="button"
|
||||
class="-mx-1 mt-1.5 inline-flex min-h-7 items-center rounded px-1 text-[13px] font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click="handleRestoreOrder"
|
||||
>
|
||||
{{ t('book.detail.files.readAloud.dialog.restoreOrder') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="includedTrackIds.length === 0" role="alert" class="mt-1.5 text-xs text-destructive">
|
||||
{{ t('book.detail.files.readAloud.dialog.noTracks') }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Language -->
|
||||
<div>
|
||||
<label :for="`${ids}-language`" class="text-sm font-semibold text-foreground">{{
|
||||
<label :for="`${ids}-language`" class="block text-sm font-semibold text-foreground">{{
|
||||
t('book.detail.files.readAloud.dialog.language')
|
||||
}}</label>
|
||||
<Select
|
||||
:id="`${ids}-language`"
|
||||
v-model="language"
|
||||
class="mt-2 sm:max-w-xs"
|
||||
:aria-describedby="unsupportedLanguageHint ? `${ids}-language-hint` : undefined"
|
||||
>
|
||||
<option value="" disabled>{{ t('book.detail.files.readAloud.dialog.chooseLanguage') }}</option>
|
||||
<option v-for="option in languageOptions" :key="option.code" :value="option.code">{{ option.name }}</option>
|
||||
</Select>
|
||||
<p v-if="unsupportedLanguageHint" :id="`${ids}-language-hint`" class="mt-1.5 text-xs text-muted-foreground">
|
||||
{{ unsupportedLanguageHint }}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<Select
|
||||
:id="`${ids}-language`"
|
||||
v-model="language"
|
||||
class="sm:max-w-xs sm:flex-1"
|
||||
:aria-describedby="languageHint ? languageHintId : undefined"
|
||||
>
|
||||
<option value="" disabled>{{ t('book.detail.files.readAloud.dialog.chooseLanguage') }}</option>
|
||||
<option v-for="option in languageOptions" :key="option.code" :value="option.code">{{ option.name }}</option>
|
||||
</Select>
|
||||
<button
|
||||
v-if="canSetDefaultLanguage"
|
||||
type="button"
|
||||
class="-mx-1 inline-flex min-h-7 items-center gap-1.5 rounded px-1 text-[13px] font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||
:disabled="savingDefault"
|
||||
@click="handleSetDefaultLanguage"
|
||||
>
|
||||
<Loader2 v-if="savingDefault" class="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
|
||||
{{ t('book.detail.files.readAloud.dialog.setDefaultLanguage') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="languageHint" :id="languageHintId" class="mt-1.5 text-xs text-muted-foreground">{{ languageHint }}</p>
|
||||
<p v-if="defaultSaveFailed" role="alert" class="mt-1.5 text-xs text-destructive">
|
||||
{{ t('book.detail.files.readAloud.dialog.defaultLanguageFailed') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Advanced -->
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="-mx-1 inline-flex min-h-7 items-center gap-1 rounded px-1 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-expanded="advancedOpen"
|
||||
:aria-controls="`${ids}-advanced`"
|
||||
@click="handleToggleAdvanced"
|
||||
>
|
||||
<ChevronDown
|
||||
class="size-4 transition-transform motion-reduce:transition-none"
|
||||
:class="advancedOpen ? '' : '-rotate-90 rtl:rotate-90'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('book.detail.files.readAloud.dialog.advanced') }}
|
||||
</button>
|
||||
<fieldset v-show="advancedOpen" :id="`${ids}-advanced`" class="mt-2.5">
|
||||
<legend class="text-sm font-medium text-foreground">{{ t('book.detail.files.readAloud.dialog.granularity') }}</legend>
|
||||
<div class="mt-2 grid gap-1.5">
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-md px-1 py-1">
|
||||
<input
|
||||
v-model="granularity"
|
||||
type="radio"
|
||||
:name="`${ids}-granularity`"
|
||||
value="sentence"
|
||||
class="mt-0.5 size-4 shrink-0 accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-sm text-foreground">{{ t('book.detail.files.readAloud.dialog.sentence') }}</span>
|
||||
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.sentenceHint') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-md px-1 py-1">
|
||||
<input
|
||||
v-model="granularity"
|
||||
type="radio"
|
||||
:name="`${ids}-granularity`"
|
||||
value="word"
|
||||
class="mt-0.5 size-4 shrink-0 accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex flex-wrap items-center gap-1.5 text-sm text-foreground">
|
||||
{{ t('book.detail.files.readAloud.dialog.word') }}
|
||||
<span
|
||||
class="inline-flex h-[18px] items-center rounded bg-warning/15 px-1.5 text-[10.5px] font-bold uppercase tracking-wider text-foreground"
|
||||
>
|
||||
{{ t('book.detail.files.readAloud.dialog.experimental') }}
|
||||
</span>
|
||||
<!-- Highlighting -->
|
||||
<fieldset>
|
||||
<legend class="text-sm font-semibold text-foreground">{{ t('book.detail.files.readAloud.dialog.granularity') }}</legend>
|
||||
<div class="mt-2 grid gap-1.5">
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-md px-1 py-1">
|
||||
<input
|
||||
v-model="granularity"
|
||||
type="radio"
|
||||
:name="`${ids}-granularity`"
|
||||
value="sentence"
|
||||
class="mt-0.5 size-4 shrink-0 accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-sm text-foreground">{{ t('book.detail.files.readAloud.dialog.sentence') }}</span>
|
||||
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.sentenceHint') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-md px-1 py-1">
|
||||
<input
|
||||
v-model="granularity"
|
||||
type="radio"
|
||||
:name="`${ids}-granularity`"
|
||||
value="word"
|
||||
class="mt-0.5 size-4 shrink-0 accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex flex-wrap items-center gap-1.5 text-sm text-foreground">
|
||||
{{ t('book.detail.files.readAloud.dialog.word') }}
|
||||
<span
|
||||
class="inline-flex h-[18px] items-center rounded bg-warning/15 px-1.5 text-[10.5px] font-bold uppercase tracking-wider text-foreground"
|
||||
>
|
||||
{{ t('book.detail.files.readAloud.dialog.experimental') }}
|
||||
</span>
|
||||
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.wordHint') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.wordHint') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- What runs it -->
|
||||
<div class="flex items-start gap-3 rounded-lg border border-border bg-muted/40 p-3.5">
|
||||
<Cpu class="mt-0.5 size-4 shrink-0 text-primary" aria-hidden="true" />
|
||||
<div class="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
<p class="text-sm font-medium text-foreground">{{ t('book.detail.files.readAloud.dialog.model', { model: modelName }) }}</p>
|
||||
<p v-if="deviceLine || estimatedSize" class="mt-0.5 flex flex-wrap gap-x-3">
|
||||
<span v-if="deviceLine">{{ deviceLine }}</span>
|
||||
<span v-if="estimatedSize" class="tabular-nums">{{ estimatedSize }}</span>
|
||||
<div class="flex items-start gap-2.5 border-t border-border pt-4 text-xs text-muted-foreground">
|
||||
<Cpu class="mt-px size-3.5 shrink-0" aria-hidden="true" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="flex flex-wrap gap-x-1.5">
|
||||
<span class="font-medium text-foreground">{{ t('book.detail.files.readAloud.dialog.model', { model: modelName }) }}</span>
|
||||
<template v-if="deviceLine">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ deviceLine }}</span>
|
||||
</template>
|
||||
<template v-if="estimatedSize">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span class="tabular-nums">{{ estimatedSize }}</span>
|
||||
</template>
|
||||
</p>
|
||||
<p class="mt-1.5">{{ t('book.detail.files.readAloud.dialog.processingNote') }}</p>
|
||||
<p class="mt-1">{{ t('book.detail.files.readAloud.dialog.processingNote') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ref } from 'vue'
|
||||
import type { ReadAloudPreferences } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const ENDPOINT = '/api/v1/user-preferences/read-aloud'
|
||||
|
||||
/**
|
||||
* The signed-in user's read-aloud defaults. Fetched each time a dialog asks rather than cached for
|
||||
* the session, so a different account signing in on the same tab never inherits them.
|
||||
*/
|
||||
export function useReadAloudPreferences() {
|
||||
const defaultLanguage = ref<string | null>(null)
|
||||
const loaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const response = await api(ENDPOINT)
|
||||
if (!response.ok) return
|
||||
const body = (await response.json()) as { settings?: Partial<ReadAloudPreferences> }
|
||||
const stored = body.settings?.defaultLanguage
|
||||
defaultLanguage.value = typeof stored === 'string' && stored ? stored : null
|
||||
} catch {
|
||||
// Without a saved default nothing is preselected, which the dialog already handles.
|
||||
} finally {
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDefaultLanguage(code: string): Promise<boolean> {
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await api(ENDPOINT, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: { defaultLanguage: code } satisfies ReadAloudPreferences }),
|
||||
})
|
||||
if (!response.ok) return false
|
||||
defaultLanguage.value = code
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { defaultLanguage, loaded, saving, load, saveDefaultLanguage }
|
||||
}
|
||||
@@ -344,16 +344,6 @@ export function formatReadAloudDuration(seconds: number, t: (key: string, values
|
||||
return t('book.detail.files.duration.minutes', { minutes: Math.max(minutes, 1) })
|
||||
}
|
||||
|
||||
/** The primary language subtag of a BCP 47 tag, so "en-GB" picks the English model. */
|
||||
export function baseLanguage(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
try {
|
||||
return new Intl.Locale(value).language
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const READ_ALOUD_I18N_KEYS = {
|
||||
reasons: [...Object.values(REASON_KEYS), NO_EPUB_WITH_EXISTING_KEY, UNKNOWN_REASON_KEY],
|
||||
errors: Object.values(ERROR_KEYS),
|
||||
|
||||
@@ -7110,6 +7110,7 @@
|
||||
"dialog": {
|
||||
"title": "Create a read-aloud",
|
||||
"description": "Matches the narration to the ebook text and adds an EPUB that highlights along with the audio.",
|
||||
"sources": "Sources",
|
||||
"ebook": "Ebook",
|
||||
"ebookHint": "Pick the plain EPUB that matches the narration.",
|
||||
"existing": {
|
||||
@@ -7138,7 +7139,11 @@
|
||||
"language": "Narration language",
|
||||
"chooseLanguage": "Choose a language",
|
||||
"languageUnsupported": "{language} isn't supported by the speech model.",
|
||||
"advanced": "Advanced",
|
||||
"languageFromBook": "From this book's details.",
|
||||
"languageIsDefault": "Your default language.",
|
||||
"languageSavedAsDefault": "Saved as your default language.",
|
||||
"setDefaultLanguage": "Set as default",
|
||||
"defaultLanguageFailed": "Couldn't save your default language. Try again.",
|
||||
"granularity": "Highlighting",
|
||||
"sentence": "Sentence",
|
||||
"sentenceHint": "Highlights one sentence at a time. Recommended.",
|
||||
|
||||
@@ -121,3 +121,50 @@ export function toRequestLanguage(value: string | null | undefined): string | nu
|
||||
const code = normalizeLanguage(value.trim());
|
||||
return /^[a-z]{2}$/.test(code) ? code : null;
|
||||
}
|
||||
|
||||
/** Codes that say there is no single language, which no language-specific model can use. */
|
||||
const NO_LANGUAGE_SUBTAGS = new Set(["und", "mul", "mis", "zxx"]);
|
||||
|
||||
let englishLanguageNames: Intl.DisplayNames | null = null;
|
||||
const languageNamesByCode = new Map<string, readonly string[]>();
|
||||
|
||||
/** A code's name in English and in the language itself, lowercased. */
|
||||
function languageNamesOf(code: string): readonly string[] {
|
||||
let names = languageNamesByCode.get(code);
|
||||
if (names) return names;
|
||||
englishLanguageNames ??= new Intl.DisplayNames(["en"], { type: "language", fallback: "none" });
|
||||
const found = new Set<string>();
|
||||
try {
|
||||
const english = englishLanguageNames.of(code);
|
||||
if (english) found.add(english.toLowerCase());
|
||||
const own = new Intl.DisplayNames([code], { type: "language", fallback: "none" }).of(code);
|
||||
if (own) found.add(own.toLowerCase());
|
||||
} catch {
|
||||
// Not a language code; nothing to match by name.
|
||||
}
|
||||
names = [...found];
|
||||
languageNamesByCode.set(code, names);
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* The language subtag a stored language value names ("en" for "en-GB", "eng" or "English"), or null.
|
||||
*
|
||||
* Book metadata states a language as a tag, a three-letter code or a name, and a name is not a tag:
|
||||
* `Intl.Locale` accepts "english" as a well-formed subtag and hands it straight back. Names are
|
||||
* matched in English and in the language's own name, against the request languages plus
|
||||
* `extraCodes` for anything those do not list.
|
||||
*/
|
||||
export function languageSubtagOf(value: string | null | undefined, extraCodes: readonly string[] = []): string | null {
|
||||
const lowered = value?.trim().toLowerCase();
|
||||
if (!lowered) return null;
|
||||
const named = [...REQUEST_LANGUAGE_CODES, ...extraCodes].find((code) => languageNamesOf(code).includes(lowered));
|
||||
if (named) return named;
|
||||
let language: string | undefined;
|
||||
try {
|
||||
language = new Intl.Locale(lowered).language;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return language && /^[a-z]{2,3}$/.test(language) && !NO_LANGUAGE_SUBTAGS.has(language) ? language : null;
|
||||
}
|
||||
|
||||
@@ -264,6 +264,17 @@ export type ReadAloudCapability = {
|
||||
latestJob: ReadAloudJob | null;
|
||||
};
|
||||
|
||||
/** What a user's read-alouds start with, under `/user-preferences/read-aloud`. */
|
||||
export interface ReadAloudPreferences {
|
||||
/**
|
||||
* The narration language preselected when a book states none the speech model supports, as a
|
||||
* language subtag. The book's own language still wins when it is supported.
|
||||
*/
|
||||
defaultLanguage: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_READ_ALOUD_PREFERENCES: ReadAloudPreferences = { defaultLanguage: null };
|
||||
|
||||
export type CreateReadAloudJobRequest = {
|
||||
epubFileId?: number;
|
||||
audioFileIds?: number[];
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { languageSubtagOf, READ_ALOUD_QUARTZNET_LANGUAGES } from '@bookorbit/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('languageSubtagOf', () => {
|
||||
it.each([
|
||||
['English', 'en'],
|
||||
['english', 'en'],
|
||||
['en', 'en'],
|
||||
['en-US', 'en'],
|
||||
['eng', 'en'],
|
||||
['ja-jp', 'ja'],
|
||||
['Spanish', 'es'],
|
||||
['español', 'es'],
|
||||
['Deutsch', 'de'],
|
||||
['ger', 'de'],
|
||||
])('reads %s as %s', (value, code) => {
|
||||
expect(languageSubtagOf(value)).toBe(code);
|
||||
});
|
||||
|
||||
it('names nothing for empty, undetermined and unrecognised values', () => {
|
||||
for (const value of [null, undefined, '', ' ', 'und', 'UND', 'mul', 'zxx', 'Klingon', 'not a language']) {
|
||||
expect(languageSubtagOf(value)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('matches names of languages the request list does not offer when asked to', () => {
|
||||
expect(languageSubtagOf('Faroese')).toBeNull();
|
||||
expect(languageSubtagOf('Faroese', READ_ALOUD_QUARTZNET_LANGUAGES)).toBe('fo');
|
||||
expect(languageSubtagOf('Kazakh', READ_ALOUD_QUARTZNET_LANGUAGES)).toBe('kk');
|
||||
});
|
||||
});
|
||||
@@ -227,6 +227,13 @@ describe('ReadAloudService.create', () => {
|
||||
expect(jobs.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads a book language stated as a name when the request names none', async () => {
|
||||
const { service, jobs } = makeService({ book: bookDetail({ language: 'English' }) });
|
||||
await service.create(12, Object.assign(new CreateReadAloudJobDto(), { epubFileId: 101, audioFileIds: [102] }), OWNER);
|
||||
|
||||
expect(jobs.create).toHaveBeenCalledWith(expect.objectContaining({ input: expect.objectContaining({ language: 'en' }) }));
|
||||
});
|
||||
|
||||
it('refuses unsupported languages, unmounted libraries, and an unavailable worker', async () => {
|
||||
await expect(makeService({ book: bookDetail({ language: 'nl-NL' }) }).service.create(12, new CreateReadAloudJobDto(), OWNER)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
import {
|
||||
isAudioFormat,
|
||||
isContentBookFile,
|
||||
languageSubtagOf,
|
||||
Permission,
|
||||
READ_ALOUD_ACTIVE_JOB_STATUSES,
|
||||
READ_ALOUD_MAX_AUDIO_FILES,
|
||||
@@ -169,7 +170,7 @@ export class ReadAloudService {
|
||||
replaceFileId = dto.replaceFileId;
|
||||
}
|
||||
|
||||
const locale = this.parseLocale(dto.language ?? book.language ?? 'en');
|
||||
const locale = this.parseLocale(dto.language ?? languageSubtagOf(book.language, READ_ALOUD_QUARTZNET_LANGUAGES) ?? 'en');
|
||||
if (!QUARTZNET_LANGUAGES.has(locale.language)) {
|
||||
throw new BadRequestException(`QuartzNet15x5 does not support language "${locale.language}"`);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ describe('UserPreferencesController', () => {
|
||||
upsertLocalePreferences: vi.fn(),
|
||||
upsertCoverSearchPreferences: vi.fn(),
|
||||
upsertBookRequestPreferences: vi.fn(),
|
||||
getReadAloudPreferences: vi.fn(),
|
||||
upsertReadAloudPreferences: vi.fn(),
|
||||
getAnnotationColorNamePreferences: vi.fn(),
|
||||
upsertAnnotationColorNamePreferences: vi.fn(),
|
||||
} as unknown as Mocked<UserPreferencesService>;
|
||||
@@ -187,6 +189,20 @@ describe('UserPreferencesController', () => {
|
||||
expect(service.upsertBookRequestPreferences).toHaveBeenCalledWith(42, dto.settings);
|
||||
});
|
||||
|
||||
it("GET /read-aloud returns the current user's saved default language", async () => {
|
||||
service.getReadAloudPreferences.mockResolvedValueOnce({ defaultLanguage: 'en' });
|
||||
|
||||
await expect(controller.getReadAloudPreferences(makeUser({ id: 11 }))).resolves.toEqual({ settings: { defaultLanguage: 'en' } });
|
||||
expect(service.getReadAloudPreferences).toHaveBeenCalledWith(11);
|
||||
});
|
||||
|
||||
it('PUT /read-aloud forwards the current user id and settings', async () => {
|
||||
const dto = { settings: { defaultLanguage: 'de' } };
|
||||
|
||||
await expect(controller.upsertReadAloudPreferences(dto, makeUser({ id: 42 }))).resolves.toBeUndefined();
|
||||
expect(service.upsertReadAloudPreferences).toHaveBeenCalledWith(42, dto.settings);
|
||||
});
|
||||
|
||||
it('GET /locale returns null settings when no saved preferences exist', async () => {
|
||||
service.getLocalePreferences.mockResolvedValueOnce(null);
|
||||
|
||||
|
||||
@@ -57,6 +57,18 @@ export class UserPreferencesController {
|
||||
await this.userPreferencesService.upsertBookRequestPreferences(user.id, dto.settings);
|
||||
}
|
||||
|
||||
@Get('read-aloud')
|
||||
async getReadAloudPreferences(@CurrentUser() user: RequestUser) {
|
||||
const settings = await this.userPreferencesService.getReadAloudPreferences(user.id);
|
||||
return { settings };
|
||||
}
|
||||
|
||||
@Put('read-aloud')
|
||||
@HttpCode(204)
|
||||
async upsertReadAloudPreferences(@Body() dto: UpsertUserPreferenceDto, @CurrentUser() user: RequestUser) {
|
||||
await this.userPreferencesService.upsertReadAloudPreferences(user.id, dto.settings);
|
||||
}
|
||||
|
||||
@Get('locale')
|
||||
async getLocalePreferences(@CurrentUser() user: RequestUser) {
|
||||
const settings = await this.userPreferencesService.getLocalePreferences(user.id);
|
||||
|
||||
@@ -205,6 +205,34 @@ describe('UserPreferencesService', () => {
|
||||
expect(repo.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getReadAloudPreferences returns no language when nothing was saved', async () => {
|
||||
await expect(service.getReadAloudPreferences(7)).resolves.toEqual({ defaultLanguage: null });
|
||||
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'read-aloud');
|
||||
});
|
||||
|
||||
it('getReadAloudPreferences returns the saved language, and none when the stored row is malformed', async () => {
|
||||
repo.findByCategory.mockResolvedValueOnce({ data: { defaultLanguage: 'fr' } } as never);
|
||||
await expect(service.getReadAloudPreferences(7)).resolves.toEqual({ defaultLanguage: 'fr' });
|
||||
|
||||
repo.findByCategory.mockResolvedValueOnce({ data: { defaultLanguage: 'English' } } as never);
|
||||
await expect(service.getReadAloudPreferences(7)).resolves.toEqual({ defaultLanguage: null });
|
||||
});
|
||||
|
||||
it('upsertReadAloudPreferences saves and clears the default language', async () => {
|
||||
await expect(service.upsertReadAloudPreferences(11, { defaultLanguage: 'de' })).resolves.toBeUndefined();
|
||||
expect(repo.upsert).toHaveBeenCalledWith(11, 'read-aloud', { defaultLanguage: 'de' });
|
||||
|
||||
await expect(service.upsertReadAloudPreferences(11, { defaultLanguage: null })).resolves.toBeUndefined();
|
||||
expect(repo.upsert).toHaveBeenLastCalledWith(11, 'read-aloud', { defaultLanguage: null });
|
||||
});
|
||||
|
||||
it('upsertReadAloudPreferences rejects a language name, a full tag and unknown fields', async () => {
|
||||
for (const settings of [{ defaultLanguage: 'English' }, { defaultLanguage: 'en-US' }, { defaultLanguage: 'en', unexpected: true }]) {
|
||||
await expect(service.upsertReadAloudPreferences(11, settings)).rejects.toBeInstanceOf(BadRequestException);
|
||||
}
|
||||
expect(repo.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getBookRequestPreferences returns no language when nothing was saved', async () => {
|
||||
await expect(service.getBookRequestPreferences(7)).resolves.toEqual({ defaultLanguage: null });
|
||||
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'book-requests');
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
COVER_SEARCH_DEFAULT_PROVIDERS,
|
||||
COVER_SIZE_SCOPES,
|
||||
DEFAULT_BOOK_REQUEST_PREFERENCES,
|
||||
DEFAULT_READ_ALOUD_PREFERENCES,
|
||||
RETIRED_BOOK_REQUEST_PREFERENCE_FIELDS,
|
||||
DEFAULT_COVER_SEARCH_PROVIDER,
|
||||
FONT_FAMILY_NAME_MAX_LENGTH,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
type LocalePreferences,
|
||||
type PodcastPlaybackPreferences,
|
||||
type PodcastPlaylistPreferences,
|
||||
type ReadAloudPreferences,
|
||||
type ServerFontPreferences,
|
||||
type ThemePreferences,
|
||||
type WhatsNewPreferences,
|
||||
@@ -151,6 +153,20 @@ const BOOK_REQUEST_PREFERENCES_SCHEMA = z.preprocess(
|
||||
.strict(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Any well-formed language subtag, not only the current speech model's: the model's language list
|
||||
* can grow, and the dialog ignores a stored language it cannot offer.
|
||||
*/
|
||||
const READ_ALOUD_PREFERENCES_SCHEMA = z
|
||||
.object({
|
||||
defaultLanguage: z
|
||||
.string()
|
||||
.regex(/^[a-z]{2,3}$/, 'must be a lowercase language subtag')
|
||||
.nullable()
|
||||
.default(null),
|
||||
})
|
||||
.strict() satisfies z.ZodType<ReadAloudPreferences>;
|
||||
|
||||
const LOCALE_PREFERENCES_SCHEMA = z
|
||||
.object({
|
||||
locale: z.enum(SUPPORTED_LOCALES),
|
||||
@@ -472,6 +488,24 @@ export class UserPreferencesService {
|
||||
await this.repo.upsert(userId, 'book-requests', result.data);
|
||||
}
|
||||
|
||||
async getReadAloudPreferences(userId: number): Promise<ReadAloudPreferences> {
|
||||
const row = await this.repo.findByCategory(userId, 'read-aloud');
|
||||
const result = READ_ALOUD_PREFERENCES_SCHEMA.safeParse(row?.data);
|
||||
return result.success ? result.data : { ...DEFAULT_READ_ALOUD_PREFERENCES };
|
||||
}
|
||||
|
||||
async upsertReadAloudPreferences(userId: number, data: Record<string, unknown>): Promise<void> {
|
||||
const result = READ_ALOUD_PREFERENCES_SCHEMA.safeParse(data);
|
||||
if (!result.success) {
|
||||
const firstIssue = result.error.issues[0];
|
||||
const issuePath = firstIssue?.path.length ? firstIssue.path.join('.') : 'settings';
|
||||
const issueMessage = firstIssue?.message ?? 'Invalid settings payload';
|
||||
throw new BadRequestException(`Invalid read-aloud preferences at "${issuePath}": ${issueMessage}`);
|
||||
}
|
||||
|
||||
await this.repo.upsert(userId, 'read-aloud', result.data);
|
||||
}
|
||||
|
||||
async upsertThemePreferences(userId: number, data: Record<string, unknown>): Promise<void> {
|
||||
const start = Date.now();
|
||||
this.logger.log(`[user_preferences.upsert_theme] [start] userId=${userId} - upsert theme preferences started`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"totalRoutes": 677,
|
||||
"totalRoutes": 679,
|
||||
"byPermission": {
|
||||
"(none)": 287,
|
||||
"(none)": 289,
|
||||
"Permission.ManageMetadataConfig": 41,
|
||||
"Permission.EmailSend": 34,
|
||||
"Permission.LibraryEditMetadata": 37,
|
||||
|
||||
@@ -814,5 +814,29 @@
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/user-preferences/user-preferences.controller.ts",
|
||||
"className": "UserPreferencesController",
|
||||
"methodName": "getReadAloudPreferences",
|
||||
"httpMethod": "GET",
|
||||
"path": "/user-preferences/read-aloud",
|
||||
"permissions": [],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/user-preferences/user-preferences.controller.ts",
|
||||
"className": "UserPreferencesController",
|
||||
"methodName": "upsertReadAloudPreferences",
|
||||
"httpMethod": "PUT",
|
||||
"path": "/user-preferences/read-aloud",
|
||||
"permissions": [],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user