From 8810d476583bef1af9c200ef51c2f455c2b79255 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:42:06 +0000 Subject: [PATCH] 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. --- .../__tests__/ReadAloudDialog.spec.ts | 115 +++++- .../read-aloud/components/ReadAloudDialog.vue | 379 ++++++++++-------- .../composables/useReadAloudPreferences.ts | 49 +++ .../src/features/read-aloud/lib/read-aloud.ts | 10 - client/src/locales/en.json | 7 +- packages/types/src/language.ts | 47 +++ packages/types/src/read-aloud.ts | 11 + .../read-aloud/read-aloud-language.test.ts | 31 ++ .../read-aloud/read-aloud.service.test.ts | 7 + .../modules/read-aloud/read-aloud.service.ts | 3 +- .../user-preferences.controller.test.ts | 16 + .../user-preferences.controller.ts | 12 + .../user-preferences.service.test.ts | 28 ++ .../user-preferences.service.ts | 34 ++ .../route-inventory/manifest.json | 4 +- .../route-inventory/routes-09.json | 24 ++ 16 files changed, 599 insertions(+), 178 deletions(-) create mode 100644 client/src/features/read-aloud/composables/useReadAloudPreferences.ts create mode 100644 server/src/modules/read-aloud/read-aloud-language.test.ts diff --git a/client/src/features/read-aloud/__tests__/ReadAloudDialog.spec.ts b/client/src/features/read-aloud/__tests__/ReadAloudDialog.spec.ts index f2f1d4f54..557b3892c 100644 --- a/client/src/features/read-aloud/__tests__/ReadAloudDialog.spec.ts +++ b/client/src/features/read-aloud/__tests__/ReadAloudDialog.spec.ts @@ -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() })) +const mocks = vi.hoisted(() => ({ + permissions: new Set(), + savedLanguage: null as string | null, + saveOk: true, + api: vi.fn<(url: string, init?: RequestInit) => Promise>(), +})) 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: '
' }) @@ -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('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: { diff --git a/client/src/features/read-aloud/components/ReadAloudDialog.vue b/client/src/features/read-aloud/components/ReadAloudDialog.vue index 40db7d991..724ef333d 100644 --- a/client/src/features/read-aloud/components/ReadAloudDialog.vue +++ b/client/src/features/read-aloud/components/ReadAloudDialog.vue @@ -1,10 +1,11 @@