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 { 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 { defineComponent, nextTick } from 'vue'
|
||||||
import { Permission, type CreateReadAloudJobRequest, type ReadAloudCapability, type ReadAloudJobInput } from '@bookorbit/types'
|
import { Permission, type CreateReadAloudJobRequest, type ReadAloudCapability, type ReadAloudJobInput } from '@bookorbit/types'
|
||||||
import { activateI18nLocale, i18n } from '@/i18n'
|
import { activateI18nLocale, i18n } from '@/i18n'
|
||||||
@@ -7,12 +7,19 @@ import { formatLanguageName } from '@/i18n/formatters'
|
|||||||
import ReadAloudDialog from '../components/ReadAloudDialog.vue'
|
import ReadAloudDialog from '../components/ReadAloudDialog.vue'
|
||||||
import { makeCapability } from './fixtures'
|
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', () => ({
|
vi.mock('@/features/auth/composables/usePermissions', () => ({
|
||||||
usePermissions: () => ({ hasPermission: (name: string) => mocks.permissions.has(name) }),
|
usePermissions: () => ({ hasPermission: (name: string) => mocks.permissions.has(name) }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({ api: mocks.api }))
|
||||||
|
|
||||||
const t = i18n.global.t
|
const t = i18n.global.t
|
||||||
|
|
||||||
const DialogContentStub = defineComponent({ name: 'DialogContentStub', emits: ['closeAutoFocus'], template: '<div><slot /></div>' })
|
const DialogContentStub = defineComponent({ name: 'DialogContentStub', emits: ['closeAutoFocus'], template: '<div><slot /></div>' })
|
||||||
@@ -34,6 +41,13 @@ beforeAll(() => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.permissions = new Set([Permission.LibraryDeleteBooks])
|
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(() => {
|
afterEach(() => {
|
||||||
@@ -69,6 +83,10 @@ function buttonByText(target: VueWrapper, text: string) {
|
|||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buttonOrNull(target: VueWrapper, text: string) {
|
||||||
|
return target.findAll('button').find((candidate) => candidate.text().trim() === text) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
function trackOrder(target: VueWrapper): number[] {
|
function trackOrder(target: VueWrapper): number[] {
|
||||||
return target.findAll('li[data-track-id]').map((row) => Number(row.attributes('data-track-id')))
|
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' }))
|
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 dialog = mountDialog()
|
||||||
const advanced = buttonByText(dialog, t('book.detail.files.readAloud.dialog.advanced'))
|
const word = dialog.find<HTMLInputElement>('input[type="radio"][value="word"]')
|
||||||
expect(advanced.attributes('aria-expanded')).toBe('false')
|
expect(word.isVisible()).toBe(true)
|
||||||
|
expect(word.element.checked).toBe(false)
|
||||||
|
|
||||||
await advanced.trigger('click')
|
await word.setValue(true)
|
||||||
await dialog.find('input[type="radio"][value="word"]').setValue(true)
|
|
||||||
|
|
||||||
expect((await submit(dialog)).granularity).toBe('word')
|
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' })
|
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 () => {
|
it('leaves the language unset and says why when the book language has no model', async () => {
|
||||||
const dialog = mountDialog({ defaultLanguage: 'nl' })
|
const dialog = mountDialog({ defaultLanguage: 'nl' })
|
||||||
|
|
||||||
@@ -304,6 +339,72 @@ describe('ReadAloudDialog', () => {
|
|||||||
expect(start.attributes('disabled')).toBeDefined()
|
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', () => {
|
it('shows its own error once', () => {
|
||||||
wrapper = mount(ReadAloudDialog, {
|
wrapper = mount(ReadAloudDialog, {
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import { AudioLines, ChevronDown, Cpu, Loader2, TriangleAlert, X } from '@lucide/vue'
|
import { AudioLines, ChevronDown, Cpu, Loader2, TriangleAlert, X } from '@lucide/vue'
|
||||||
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
||||||
import {
|
import {
|
||||||
estimateReadAloudWorkBytes,
|
estimateReadAloudWorkBytes,
|
||||||
|
languageSubtagOf,
|
||||||
Permission,
|
Permission,
|
||||||
type CreateReadAloudJobRequest,
|
type CreateReadAloudJobRequest,
|
||||||
type ReadAloudCapability,
|
type ReadAloudCapability,
|
||||||
@@ -15,7 +16,8 @@ import { formatBytes } from '@/lib/formatting'
|
|||||||
import { formatLanguageName } from '@/i18n/formatters'
|
import { formatLanguageName } from '@/i18n/formatters'
|
||||||
import { Select } from '@/components/ui/select'
|
import { Select } from '@/components/ui/select'
|
||||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
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'
|
import ReadAloudTrackList from './ReadAloudTrackList.vue'
|
||||||
|
|
||||||
/** Display names for the model ids the server reports; an id this build does not know shows as sent. */
|
/** 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 { t, locale } = useI18n()
|
||||||
const { hasPermission } = usePermissions()
|
const { hasPermission } = usePermissions()
|
||||||
|
const preferences = useReadAloudPreferences()
|
||||||
|
const savingDefault = preferences.saving
|
||||||
const ids = useId()
|
const ids = useId()
|
||||||
const trackListId = `${ids}-tracks`
|
const trackListId = `${ids}-tracks`
|
||||||
|
const languageHintId = `${ids}-language-hint`
|
||||||
|
|
||||||
const epubFileId = ref<number | null>(null)
|
const epubFileId = ref<number | null>(null)
|
||||||
const trackOrder = ref<number[]>([])
|
const trackOrder = ref<number[]>([])
|
||||||
const skippedTracks = ref<number[]>([])
|
const skippedTracks = ref<number[]>([])
|
||||||
const orderOpen = ref(false)
|
const orderOpen = ref(false)
|
||||||
const advancedOpen = ref(false)
|
|
||||||
const language = ref('')
|
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')
|
const granularity = ref<ReadAloudGranularity>('sentence')
|
||||||
/** The read-along to overwrite, or "keep" to add the new one beside it. */
|
/** The read-along to overwrite, or "keep" to add the new one beside it. */
|
||||||
const existingChoice = ref<number | 'keep'>('keep')
|
const existingChoice = ref<number | 'keep'>('keep')
|
||||||
@@ -88,14 +95,29 @@ const languageOptions = computed(() =>
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name, locale.value)),
|
.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. */
|
/** The book's language as a code; metadata often states it as a name such as "English". */
|
||||||
const unsupportedLanguageHint = computed(() => {
|
const bookLanguage = computed(() => languageSubtagOf(props.defaultLanguage, props.capability.supportedLanguages))
|
||||||
if (language.value) return null
|
|
||||||
const base = baseLanguage(props.defaultLanguage)
|
/**
|
||||||
if (!base || isSupportedLanguage(base)) return null
|
* Where the selection came from. A book language the model cannot use is said even once a default
|
||||||
return t('book.detail.files.readAloud.dialog.languageUnsupported', { language: formatLanguageName(base) })
|
* 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 deviceLine = computed(() => deviceLabel(props.capability.workerDevice, t))
|
||||||
const estimatedSize = computed(() =>
|
const estimatedSize = computed(() =>
|
||||||
props.capability.estimatedOutputBytes != null
|
props.capability.estimatedOutputBytes != null
|
||||||
@@ -124,6 +146,13 @@ function isSupportedLanguage(code: string): boolean {
|
|||||||
return (props.capability.supportedLanguages as readonly string[]).includes(code)
|
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. */
|
/** BookOrbit's own output is safe to overwrite; an imported one may be the only copy of someone's work. */
|
||||||
function defaultExistingChoice(): number | 'keep' {
|
function defaultExistingChoice(): number | 'keep' {
|
||||||
if (!canReplace.value) return 'keep'
|
if (!canReplace.value) return 'keep'
|
||||||
@@ -151,10 +180,10 @@ function initialise(): void {
|
|||||||
}
|
}
|
||||||
orderOpen.value = false
|
orderOpen.value = false
|
||||||
|
|
||||||
const preferred = initial?.language ?? baseLanguage(props.defaultLanguage)
|
language.value = preferredLanguage()
|
||||||
language.value = preferred && isSupportedLanguage(preferred) ? preferred : ''
|
defaultSaved.value = false
|
||||||
|
defaultSaveFailed.value = false
|
||||||
granularity.value = initial?.granularity ?? 'sentence'
|
granularity.value = initial?.granularity ?? 'sentence'
|
||||||
advancedOpen.value = granularity.value !== 'sentence'
|
|
||||||
|
|
||||||
const existingIds = capability.existingReadAlongs.map((file) => file.fileId)
|
const existingIds = capability.existingReadAlongs.map((file) => file.fileId)
|
||||||
existingChoice.value =
|
existingChoice.value =
|
||||||
@@ -194,10 +223,22 @@ watch(
|
|||||||
if (!open) return
|
if (!open) return
|
||||||
returnFocusTo = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
returnFocusTo = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||||
initialise()
|
initialise()
|
||||||
|
void loadPreferences()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ 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 {
|
function handleCloseAutoFocus(event: Event): void {
|
||||||
const target = returnFocusTo
|
const target = returnFocusTo
|
||||||
returnFocusTo = null
|
returnFocusTo = null
|
||||||
@@ -230,8 +271,18 @@ function handleRestoreOrder(): void {
|
|||||||
skippedTracks.value = []
|
skippedTracks.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToggleAdvanced(): void {
|
async function handleSetDefaultLanguage(): Promise<void> {
|
||||||
advancedOpen.value = !advancedOpen.value
|
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 {
|
function existingOrigin(generatedByJobId: string | null): string {
|
||||||
@@ -280,37 +331,88 @@ function handleSubmit(): void {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-5 py-5 sm:px-6">
|
<div class="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-5 sm:px-6">
|
||||||
<!-- Ebook -->
|
<!-- Sources -->
|
||||||
<fieldset v-if="showEpubChoice">
|
<section class="rounded-lg border border-border" :aria-labelledby="`${ids}-sources`">
|
||||||
<legend class="text-sm font-semibold text-foreground">{{ t('book.detail.files.readAloud.dialog.ebook') }}</legend>
|
<h3 :id="`${ids}-sources`" class="px-3.5 pt-3 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
<p class="mt-1 text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.ebookHint') }}</p>
|
{{ t('book.detail.files.readAloud.dialog.sources') }}
|
||||||
<div class="mt-2.5 grid gap-1.5">
|
</h3>
|
||||||
<label
|
<div class="divide-y divide-border">
|
||||||
v-for="file in capability.epubFiles"
|
<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)]">
|
||||||
:key="file.id"
|
<span :id="`${ids}-ebook`" class="text-sm text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.ebook') }}</span>
|
||||||
class="flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors"
|
<div
|
||||||
:class="epubFileId === file.id ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted'"
|
v-if="showEpubChoice"
|
||||||
>
|
role="radiogroup"
|
||||||
<input
|
class="min-w-0"
|
||||||
v-model="epubFileId"
|
:aria-labelledby="`${ids}-ebook`"
|
||||||
type="radio"
|
:aria-describedby="`${ids}-ebook-hint`"
|
||||||
:name="`${ids}-epub`"
|
>
|
||||||
:value="file.id"
|
<p :id="`${ids}-ebook-hint`" class="text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.ebookHint') }}</p>
|
||||||
class="size-4 shrink-0 accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
<div class="mt-2 grid gap-1.5">
|
||||||
/>
|
<label
|
||||||
<span class="min-w-0 flex-1 truncate text-sm text-foreground" :title="file.filename">{{ file.filename }}</span>
|
v-for="file in capability.epubFiles"
|
||||||
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatBytes(file.sizeBytes) }}</span>
|
:key="file.id"
|
||||||
</label>
|
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>
|
</div>
|
||||||
</fieldset>
|
</section>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Existing read-alongs -->
|
<!-- Existing read-alongs -->
|
||||||
<fieldset v-if="existingReadAlongs.length > 0 && canReplace" class="rounded-lg border border-border bg-muted/40 p-3.5">
|
<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>
|
<p class="mt-2 text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.existing.replaceNotPermitted') }}</p>
|
||||||
</section>
|
</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 -->
|
<!-- Language -->
|
||||||
<div>
|
<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')
|
t('book.detail.files.readAloud.dialog.language')
|
||||||
}}</label>
|
}}</label>
|
||||||
<Select
|
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||||
:id="`${ids}-language`"
|
<Select
|
||||||
v-model="language"
|
:id="`${ids}-language`"
|
||||||
class="mt-2 sm:max-w-xs"
|
v-model="language"
|
||||||
:aria-describedby="unsupportedLanguageHint ? `${ids}-language-hint` : undefined"
|
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>
|
<option value="" disabled>{{ t('book.detail.files.readAloud.dialog.chooseLanguage') }}</option>
|
||||||
</Select>
|
<option v-for="option in languageOptions" :key="option.code" :value="option.code">{{ option.name }}</option>
|
||||||
<p v-if="unsupportedLanguageHint" :id="`${ids}-language-hint`" class="mt-1.5 text-xs text-muted-foreground">
|
</Select>
|
||||||
{{ unsupportedLanguageHint }}
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Advanced -->
|
<!-- Highlighting -->
|
||||||
<div>
|
<fieldset>
|
||||||
<button
|
<legend class="text-sm font-semibold text-foreground">{{ t('book.detail.files.readAloud.dialog.granularity') }}</legend>
|
||||||
type="button"
|
<div class="mt-2 grid gap-1.5">
|
||||||
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"
|
<label class="flex cursor-pointer items-start gap-3 rounded-md px-1 py-1">
|
||||||
:aria-expanded="advancedOpen"
|
<input
|
||||||
:aria-controls="`${ids}-advanced`"
|
v-model="granularity"
|
||||||
@click="handleToggleAdvanced"
|
type="radio"
|
||||||
>
|
:name="`${ids}-granularity`"
|
||||||
<ChevronDown
|
value="sentence"
|
||||||
class="size-4 transition-transform motion-reduce:transition-none"
|
class="mt-0.5 size-4 shrink-0 accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
:class="advancedOpen ? '' : '-rotate-90 rtl:rotate-90'"
|
/>
|
||||||
aria-hidden="true"
|
<span class="min-w-0 flex-1">
|
||||||
/>
|
<span class="block text-sm text-foreground">{{ t('book.detail.files.readAloud.dialog.sentence') }}</span>
|
||||||
{{ t('book.detail.files.readAloud.dialog.advanced') }}
|
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.sentenceHint') }}</span>
|
||||||
</button>
|
</span>
|
||||||
<fieldset v-show="advancedOpen" :id="`${ids}-advanced`" class="mt-2.5">
|
</label>
|
||||||
<legend class="text-sm font-medium text-foreground">{{ t('book.detail.files.readAloud.dialog.granularity') }}</legend>
|
<label class="flex cursor-pointer items-start gap-3 rounded-md px-1 py-1">
|
||||||
<div class="mt-2 grid gap-1.5">
|
<input
|
||||||
<label class="flex cursor-pointer items-start gap-3 rounded-md px-1 py-1">
|
v-model="granularity"
|
||||||
<input
|
type="radio"
|
||||||
v-model="granularity"
|
:name="`${ids}-granularity`"
|
||||||
type="radio"
|
value="word"
|
||||||
:name="`${ids}-granularity`"
|
class="mt-0.5 size-4 shrink-0 accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
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="flex flex-wrap items-center gap-1.5 text-sm text-foreground">
|
||||||
<span class="min-w-0 flex-1">
|
{{ t('book.detail.files.readAloud.dialog.word') }}
|
||||||
<span class="block text-sm text-foreground">{{ t('book.detail.files.readAloud.dialog.sentence') }}</span>
|
<span
|
||||||
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.sentenceHint') }}</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"
|
||||||
</span>
|
>
|
||||||
</label>
|
{{ t('book.detail.files.readAloud.dialog.experimental') }}
|
||||||
<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>
|
</span>
|
||||||
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.wordHint') }}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t('book.detail.files.readAloud.dialog.wordHint') }}</span>
|
||||||
</div>
|
</span>
|
||||||
</fieldset>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<!-- What runs it -->
|
<!-- What runs it -->
|
||||||
<div class="flex items-start gap-3 rounded-lg border border-border bg-muted/40 p-3.5">
|
<div class="flex items-start gap-2.5 border-t border-border pt-4 text-xs text-muted-foreground">
|
||||||
<Cpu class="mt-0.5 size-4 shrink-0 text-primary" aria-hidden="true" />
|
<Cpu class="mt-px size-3.5 shrink-0" aria-hidden="true" />
|
||||||
<div class="min-w-0 flex-1 text-xs text-muted-foreground">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium text-foreground">{{ t('book.detail.files.readAloud.dialog.model', { model: modelName }) }}</p>
|
<p class="flex flex-wrap gap-x-1.5">
|
||||||
<p v-if="deviceLine || estimatedSize" class="mt-0.5 flex flex-wrap gap-x-3">
|
<span class="font-medium text-foreground">{{ t('book.detail.files.readAloud.dialog.model', { model: modelName }) }}</span>
|
||||||
<span v-if="deviceLine">{{ deviceLine }}</span>
|
<template v-if="deviceLine">
|
||||||
<span v-if="estimatedSize" class="tabular-nums">{{ estimatedSize }}</span>
|
<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>
|
||||||
<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>
|
||||||
</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) })
|
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 = {
|
export const READ_ALOUD_I18N_KEYS = {
|
||||||
reasons: [...Object.values(REASON_KEYS), NO_EPUB_WITH_EXISTING_KEY, UNKNOWN_REASON_KEY],
|
reasons: [...Object.values(REASON_KEYS), NO_EPUB_WITH_EXISTING_KEY, UNKNOWN_REASON_KEY],
|
||||||
errors: Object.values(ERROR_KEYS),
|
errors: Object.values(ERROR_KEYS),
|
||||||
|
|||||||
@@ -7110,6 +7110,7 @@
|
|||||||
"dialog": {
|
"dialog": {
|
||||||
"title": "Create a read-aloud",
|
"title": "Create a read-aloud",
|
||||||
"description": "Matches the narration to the ebook text and adds an EPUB that highlights along with the audio.",
|
"description": "Matches the narration to the ebook text and adds an EPUB that highlights along with the audio.",
|
||||||
|
"sources": "Sources",
|
||||||
"ebook": "Ebook",
|
"ebook": "Ebook",
|
||||||
"ebookHint": "Pick the plain EPUB that matches the narration.",
|
"ebookHint": "Pick the plain EPUB that matches the narration.",
|
||||||
"existing": {
|
"existing": {
|
||||||
@@ -7138,7 +7139,11 @@
|
|||||||
"language": "Narration language",
|
"language": "Narration language",
|
||||||
"chooseLanguage": "Choose a language",
|
"chooseLanguage": "Choose a language",
|
||||||
"languageUnsupported": "{language} isn't supported by the speech model.",
|
"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",
|
"granularity": "Highlighting",
|
||||||
"sentence": "Sentence",
|
"sentence": "Sentence",
|
||||||
"sentenceHint": "Highlights one sentence at a time. Recommended.",
|
"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());
|
const code = normalizeLanguage(value.trim());
|
||||||
return /^[a-z]{2}$/.test(code) ? code : null;
|
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;
|
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 = {
|
export type CreateReadAloudJobRequest = {
|
||||||
epubFileId?: number;
|
epubFileId?: number;
|
||||||
audioFileIds?: 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();
|
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 () => {
|
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(
|
await expect(makeService({ book: bookDetail({ language: 'nl-NL' }) }).service.create(12, new CreateReadAloudJobDto(), OWNER)).rejects.toThrow(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
isAudioFormat,
|
isAudioFormat,
|
||||||
isContentBookFile,
|
isContentBookFile,
|
||||||
|
languageSubtagOf,
|
||||||
Permission,
|
Permission,
|
||||||
READ_ALOUD_ACTIVE_JOB_STATUSES,
|
READ_ALOUD_ACTIVE_JOB_STATUSES,
|
||||||
READ_ALOUD_MAX_AUDIO_FILES,
|
READ_ALOUD_MAX_AUDIO_FILES,
|
||||||
@@ -169,7 +170,7 @@ export class ReadAloudService {
|
|||||||
replaceFileId = dto.replaceFileId;
|
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)) {
|
if (!QUARTZNET_LANGUAGES.has(locale.language)) {
|
||||||
throw new BadRequestException(`QuartzNet15x5 does not support language "${locale.language}"`);
|
throw new BadRequestException(`QuartzNet15x5 does not support language "${locale.language}"`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ describe('UserPreferencesController', () => {
|
|||||||
upsertLocalePreferences: vi.fn(),
|
upsertLocalePreferences: vi.fn(),
|
||||||
upsertCoverSearchPreferences: vi.fn(),
|
upsertCoverSearchPreferences: vi.fn(),
|
||||||
upsertBookRequestPreferences: vi.fn(),
|
upsertBookRequestPreferences: vi.fn(),
|
||||||
|
getReadAloudPreferences: vi.fn(),
|
||||||
|
upsertReadAloudPreferences: vi.fn(),
|
||||||
getAnnotationColorNamePreferences: vi.fn(),
|
getAnnotationColorNamePreferences: vi.fn(),
|
||||||
upsertAnnotationColorNamePreferences: vi.fn(),
|
upsertAnnotationColorNamePreferences: vi.fn(),
|
||||||
} as unknown as Mocked<UserPreferencesService>;
|
} as unknown as Mocked<UserPreferencesService>;
|
||||||
@@ -187,6 +189,20 @@ describe('UserPreferencesController', () => {
|
|||||||
expect(service.upsertBookRequestPreferences).toHaveBeenCalledWith(42, dto.settings);
|
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 () => {
|
it('GET /locale returns null settings when no saved preferences exist', async () => {
|
||||||
service.getLocalePreferences.mockResolvedValueOnce(null);
|
service.getLocalePreferences.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ export class UserPreferencesController {
|
|||||||
await this.userPreferencesService.upsertBookRequestPreferences(user.id, dto.settings);
|
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')
|
@Get('locale')
|
||||||
async getLocalePreferences(@CurrentUser() user: RequestUser) {
|
async getLocalePreferences(@CurrentUser() user: RequestUser) {
|
||||||
const settings = await this.userPreferencesService.getLocalePreferences(user.id);
|
const settings = await this.userPreferencesService.getLocalePreferences(user.id);
|
||||||
|
|||||||
@@ -205,6 +205,34 @@ describe('UserPreferencesService', () => {
|
|||||||
expect(repo.upsert).not.toHaveBeenCalled();
|
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 () => {
|
it('getBookRequestPreferences returns no language when nothing was saved', async () => {
|
||||||
await expect(service.getBookRequestPreferences(7)).resolves.toEqual({ defaultLanguage: null });
|
await expect(service.getBookRequestPreferences(7)).resolves.toEqual({ defaultLanguage: null });
|
||||||
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'book-requests');
|
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'book-requests');
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
COVER_SEARCH_DEFAULT_PROVIDERS,
|
COVER_SEARCH_DEFAULT_PROVIDERS,
|
||||||
COVER_SIZE_SCOPES,
|
COVER_SIZE_SCOPES,
|
||||||
DEFAULT_BOOK_REQUEST_PREFERENCES,
|
DEFAULT_BOOK_REQUEST_PREFERENCES,
|
||||||
|
DEFAULT_READ_ALOUD_PREFERENCES,
|
||||||
RETIRED_BOOK_REQUEST_PREFERENCE_FIELDS,
|
RETIRED_BOOK_REQUEST_PREFERENCE_FIELDS,
|
||||||
DEFAULT_COVER_SEARCH_PROVIDER,
|
DEFAULT_COVER_SEARCH_PROVIDER,
|
||||||
FONT_FAMILY_NAME_MAX_LENGTH,
|
FONT_FAMILY_NAME_MAX_LENGTH,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
type LocalePreferences,
|
type LocalePreferences,
|
||||||
type PodcastPlaybackPreferences,
|
type PodcastPlaybackPreferences,
|
||||||
type PodcastPlaylistPreferences,
|
type PodcastPlaylistPreferences,
|
||||||
|
type ReadAloudPreferences,
|
||||||
type ServerFontPreferences,
|
type ServerFontPreferences,
|
||||||
type ThemePreferences,
|
type ThemePreferences,
|
||||||
type WhatsNewPreferences,
|
type WhatsNewPreferences,
|
||||||
@@ -151,6 +153,20 @@ const BOOK_REQUEST_PREFERENCES_SCHEMA = z.preprocess(
|
|||||||
.strict(),
|
.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
|
const LOCALE_PREFERENCES_SCHEMA = z
|
||||||
.object({
|
.object({
|
||||||
locale: z.enum(SUPPORTED_LOCALES),
|
locale: z.enum(SUPPORTED_LOCALES),
|
||||||
@@ -472,6 +488,24 @@ export class UserPreferencesService {
|
|||||||
await this.repo.upsert(userId, 'book-requests', result.data);
|
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> {
|
async upsertThemePreferences(userId: number, data: Record<string, unknown>): Promise<void> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
this.logger.log(`[user_preferences.upsert_theme] [start] userId=${userId} - upsert theme preferences started`);
|
this.logger.log(`[user_preferences.upsert_theme] [start] userId=${userId} - upsert theme preferences started`);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"totalRoutes": 677,
|
"totalRoutes": 679,
|
||||||
"byPermission": {
|
"byPermission": {
|
||||||
"(none)": 287,
|
"(none)": 289,
|
||||||
"Permission.ManageMetadataConfig": 41,
|
"Permission.ManageMetadataConfig": 41,
|
||||||
"Permission.EmailSend": 34,
|
"Permission.EmailSend": 34,
|
||||||
"Permission.LibraryEditMetadata": 37,
|
"Permission.LibraryEditMetadata": 37,
|
||||||
|
|||||||
@@ -814,5 +814,29 @@
|
|||||||
"isPublic": false,
|
"isPublic": false,
|
||||||
"allowDefault": false,
|
"allowDefault": false,
|
||||||
"useGuards": []
|
"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