feat(books): add read-aloud delivery policies and preserve sync

Add original and text-only EPUB delivery choices across Kobo, KOReader,
OPDS, web downloads and email while keeping source files unchanged.
Persist server defaults, device overrides and captured artifact identities
so retries and guided replacement retain the selected rendition.

Preserve saved progress across first opens, replacement resets and stale
updates, and project annotations against the copy delivered to each device.
Handle XML resource references, large inodes, retry backoff and background
worker state correctly. Consolidate the schema changes into one migration
and retain released-client request and response compatibility.

Verify with the full server and client checks, delivery and sync HTTP suites,
independent code reviews, and live KOReader download/settings/reader proof.

Fixes #1496
Closes #1603
Closes #1714
Fixes #1723
Ref #1633
Ref #1508
Ref #1621
Ref #1136
This commit is contained in:
neon
2026-10-11 03:11:27 +00:00
parent 7bb51a7a0b
commit cc50b55eeb
149 changed files with 30116 additions and 675 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ const viewKey = computed(() => {
document-level scrollbar.
-->
<div
class="app-shell-scroll px-(--shell-content-gutter) pt-(--shell-gap) pb-[var(--podcast-mini-player-clearance,0px)] flex-1 overflow-y-auto overflow-x-hidden relative scroll-smooth bg-transparent"
class="app-shell-scroll ps-(--shell-content-gutter) pe-(--shell-gap) pt-(--shell-gap) pb-[var(--podcast-mini-player-clearance,0px)] flex-1 overflow-y-auto overflow-x-hidden relative scroll-smooth bg-transparent"
>
<router-view v-slot="{ Component }">
<Transition name="page" mode="out-in">
@@ -113,17 +113,19 @@ function handleExportPrimary() {
@click="handleFileDownload(file)"
>
<BookFormatChip :format-key="fileFormatKey(file) ?? '?'" class="shrink-0 rounded-xs px-1.5 py-0.5 text-[10px] tracking-wider" />
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{ formatFileSize(file.sizeBytes) }}</span>
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{
isAudiolessEpubCandidate(file) ? t('book.download.originalNarratedEpub') : formatFileSize(file.sizeBytes)
}}</span>
</button>
<button
v-for="file in audiolessEpubFiles"
:key="`audioless-${file.id}`"
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded-xs text-sm hover:bg-muted transition-colors"
:title="t('book.download.audiolessEpubHint')"
:title="t('book.download.textOnlyEpubHint')"
@click="handleAudiolessEpubDownload(file)"
>
<BookFormatChip format-key="epub" class="shrink-0 rounded-xs px-1.5 py-0.5 text-[10px] tracking-wider" />
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{ t('book.download.audiolessEpub') }}</span>
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{ t('book.download.textOnlyEpub') }}</span>
<span class="text-[10px] font-medium text-muted-foreground shrink-0">{{ t('book.download.noAudio') }}</span>
</button>
</template>
@@ -137,18 +139,20 @@ function handleExportPrimary() {
@click="handleFileDownload(file)"
>
<BookFormatChip :format-key="fileFormatKey(file) ?? '?'" class="shrink-0 rounded-xs px-1.5 py-0.5 text-[10px] tracking-wider" />
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{ formatFileSize(file.sizeBytes) }}</span>
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{
isAudiolessEpubCandidate(file) ? t('book.download.originalNarratedEpub') : formatFileSize(file.sizeBytes)
}}</span>
<span v-if="file.role === 'primary'" class="text-[10px] text-primary font-medium shrink-0">{{ t('book.file.primary') }}</span>
</button>
<button
v-for="file in audiolessEpubFiles"
:key="`audioless-${file.id}`"
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded-xs text-sm hover:bg-muted transition-colors"
:title="t('book.download.audiolessEpubHint')"
:title="t('book.download.textOnlyEpubHint')"
@click="handleAudiolessEpubDownload(file)"
>
<BookFormatChip format-key="epub" class="shrink-0 rounded-xs px-1.5 py-0.5 text-[10px] tracking-wider" />
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{ t('book.download.audiolessEpub') }}</span>
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{ t('book.download.textOnlyEpub') }}</span>
<span class="text-[10px] font-medium text-muted-foreground shrink-0">{{ t('book.download.noAudio') }}</span>
<span v-if="isAudiolessEpubCandidate(file) && file.role === 'primary'" class="text-[10px] text-primary font-medium shrink-0">{{
t('book.file.primary')
@@ -0,0 +1,58 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import KoboReplacement from './KoboReplacement.vue'
vi.mock('@/lib/api', () => ({ api: vi.fn<typeof api>() }))
const response = (value: unknown) => new Response(JSON.stringify(value), { headers: { 'Content-Type': 'application/json' } })
describe('Kobo replacement book selection', () => {
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('requires a new selection after changing the search query', async () => {
vi.useFakeTimers()
vi.mocked(api).mockImplementation(async (path, options) => {
if (path === '/api/v1/books/query') {
const query = JSON.parse(String(options?.body)) as { q: string }
const book = query.q === 'first' ? { id: 1, title: 'First book' } : { id: 2, title: 'Second book' }
return response({ items: [book], total: 1, page: 0 })
}
return response(null)
})
const wrapper = mount(KoboReplacement, {
props: { devices: [{ id: 1, name: 'Reader', lastSeenAt: null, createdAt: '' }] },
})
const device = wrapper.find('select[id$="-device"]')
const book = wrapper.find('select[id$="-result"]')
const search = wrapper.find('input[type="search"]')
const prepare = () => wrapper.find('button')
await device.setValue('1')
await search.setValue('first')
await vi.advanceTimersByTimeAsync(300)
await flushPromises()
await book.setValue('1')
await flushPromises()
expect(prepare().attributes('disabled')).toBeUndefined()
await search.setValue('second')
await flushPromises()
expect(prepare().attributes('disabled')).toBeDefined()
await vi.advanceTimersByTimeAsync(300)
await flushPromises()
expect(prepare().attributes('disabled')).toBeDefined()
await book.setValue('2')
await flushPromises()
await prepare().trigger('click')
await flushPromises()
expect(api).toHaveBeenCalledWith('/api/v1/kobo/devices/1/books/2/replacement', expect.objectContaining({ method: 'POST' }))
expect(vi.mocked(api).mock.calls.some(([path, options]) => String(path).includes('/books/1/replacement') && options?.method === 'POST')).toBe(
false,
)
wrapper.unmount()
})
})
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, onScopeDispose, ref, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import type { KoboDevice, ReadAloudDeliveryPreference } from '@bookorbit/types'
import { formatBytes } from '@/lib/formatting'
import { useGlobalSearch } from '@/features/book/composables/useGlobalSearch'
import { useKoboReplacement } from '../composables/useKoboReplacement'
import ReadAloudDeliverySelect from './ReadAloudDeliverySelect.vue'
const props = defineProps<{ devices: KoboDevice[] }>()
const { t } = useI18n()
const id = useId()
const deviceId = ref<number | null>(null)
const bookId = ref<number | null>(null)
const variant = ref<ReadAloudDeliveryPreference>('audioless_epub')
const query = ref('')
watch(query, () => {
bookId.value = null
})
const { results, loading, settled, hasMore, loadMore, clear } = useGlobalSearch(query)
onScopeDispose(clear)
const { replacement, busy, failed, prepare, transition, refresh } = useKoboReplacement(deviceId, bookId)
const locked = computed(() => busy.value || (!!replacement.value && !['cancelled', 'user_confirmed'].includes(replacement.value.state)))
const status = computed(() => {
if (!replacement.value) return ''
if (replacement.value.state === 'prepared')
return t('delivery.readAloud.prepared', {
format: replacement.value.metadata.format.toUpperCase(),
size: formatBytes(replacement.value.metadata.sizeBytes),
})
if (replacement.value.state === 'cancelled') return t('delivery.readAloud.cancelled')
if (replacement.value.state === 'user_confirmed') return t('delivery.readAloud.confirmed')
if (replacement.value.state === 'transfer_observed') return t('delivery.readAloud.transferred')
return t('delivery.readAloud.offered')
})
async function handlePrepare() {
if (deviceId.value && bookId.value && variant.value) await prepare(variant.value)
}
async function handleOffer() {
if (deviceId.value) await transition('offer')
}
async function handleConfirm() {
if (deviceId.value) await transition('confirm')
}
async function handleCancel() {
if (deviceId.value) await transition('cancel')
}
</script>
<template>
<section v-if="props.devices.length" class="rounded-xs border border-border bg-card p-5 space-y-4">
<h3 class="font-medium text-foreground">{{ t('delivery.readAloud.replacementTitle') }}</h3>
<p class="text-sm text-muted-foreground">{{ t('delivery.readAloud.replacementHint') }}</p>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label :for="`${id}-device`" class="block text-sm text-foreground">{{ t('delivery.readAloud.device') }}</label>
<select :id="`${id}-device`" v-model="deviceId" :disabled="locked" class="select-field w-full text-foreground">
<option v-for="device in devices" :key="device.id" :value="device.id">{{ device.name }}</option>
</select>
</div>
<div>
<label :for="`${id}-book`" class="block text-sm text-foreground">{{ t('delivery.readAloud.searchBook') }}</label>
<input
:id="`${id}-book`"
v-model="query"
type="search"
:disabled="locked"
class="input-field w-full text-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
<label :for="`${id}-result`" class="block text-sm text-foreground">{{ t('delivery.readAloud.chooseBook') }}</label>
<select
:id="`${id}-result`"
v-model="bookId"
:disabled="locked"
class="select-field w-full text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<option :value="null">{{ t('delivery.readAloud.chooseBook') }}</option>
<option v-for="book in results" :key="book.id" :value="book.id">{{ book.title }}</option>
</select>
<p v-if="loading" role="status" class="text-sm text-muted-foreground">{{ t('common.loading') }}</p>
<p v-else-if="settled && !results.length" role="status" class="text-sm text-muted-foreground">{{ t('delivery.readAloud.noResults') }}</p>
<Button v-if="hasMore" variant="outline" :disabled="loading || locked" @click="loadMore">{{ t('delivery.readAloud.moreResults') }}</Button>
</div>
</div>
<ReadAloudDeliverySelect v-model="variant" explicit-only :disabled="locked" />
<p role="status" class="text-sm text-muted-foreground">{{ status }}</p>
<p v-if="failed" role="alert" class="text-sm text-destructive">{{ t('delivery.readAloud.failed') }}</p>
<div class="flex flex-wrap gap-2">
<Button v-if="!locked" :disabled="busy || !deviceId || !bookId || !variant" @click="handlePrepare">{{
t('delivery.readAloud.prepare')
}}</Button>
<Button v-if="replacement?.state === 'prepared'" :disabled="busy" @click="handleOffer">{{ t('delivery.readAloud.offer') }}</Button>
<Button v-if="replacement && replacement.state === 'transfer_observed'" :disabled="busy" @click="handleConfirm">{{
t('delivery.readAloud.confirm')
}}</Button>
<Button v-if="replacement" variant="outline" :disabled="busy" @click="refresh">{{ t('delivery.readAloud.refresh') }}</Button>
<Button v-if="locked" variant="outline" :disabled="busy" @click="handleCancel">{{ t('delivery.readAloud.cancel') }}</Button>
</div>
</section>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { useId } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ReadAloudDeliveryPreference } from '@bookorbit/types'
const props = defineProps<{
modelValue?: ReadAloudDeliveryPreference
kindle?: boolean
disabled?: boolean
inheritLabel?: string
explicitOnly?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: ReadAloudDeliveryPreference] }>()
const { t } = useI18n()
const id = useId()
function handleChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
emit('update:modelValue', value === 'original' ? 'original' : value === 'audioless_epub' ? 'audioless_epub' : null)
}
</script>
<template>
<div class="min-w-0 space-y-1.5">
<label :for="id" class="block text-sm font-medium text-foreground">{{ t('delivery.readAloud.label') }}</label>
<select
:id="id"
:value="props.modelValue ?? ''"
:disabled="disabled"
:aria-describedby="`${id}-hint`"
class="select-field w-full text-foreground focus-visible:ring-2 focus-visible:ring-ring"
@change="handleChange"
>
<option v-if="!explicitOnly" value="">{{ inheritLabel ?? t('delivery.readAloud.automatic') }}</option>
<option value="audioless_epub">{{ t('delivery.readAloud.textOnly') }}</option>
<option v-if="!kindle" value="original">{{ t('delivery.readAloud.original') }}</option>
</select>
<p :id="`${id}-hint`" class="text-xs text-muted-foreground">{{ t('delivery.readAloud.hint') }}</p>
</div>
</template>
@@ -0,0 +1,99 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { effectScope, nextTick, ref } from 'vue'
import { flushPromises } from '@vue/test-utils'
import { api } from '@/lib/api'
import { useKoboReplacement } from './useKoboReplacement'
vi.mock('@/lib/api', () => ({ api: vi.fn<typeof api>() }))
const record = (state: string, id = 'delivery') => ({ id, state, options: { replacement: true }, metadata: { format: 'epub', sizeBytes: 100 } })
const response = (value: unknown) => new Response(JSON.stringify(value), { headers: { 'Content-Type': 'application/json' } })
describe('guided Kobo replacement lifecycle', () => {
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('recovers an existing pending replacement and polls after offering it', async () => {
vi.useFakeTimers()
const scope = effectScope()
const device = ref<number | null>(null)
const book = ref<number | null>(null)
const state = scope.run(() => useKoboReplacement(device, book))!
vi.mocked(api).mockResolvedValueOnce(response(record('prepared')))
device.value = 1
book.value = 2
await nextTick()
await flushPromises()
expect(state.replacement.value?.state).toBe('prepared')
vi.mocked(api).mockResolvedValueOnce(response(record('offered')))
await state.transition('offer')
vi.mocked(api).mockResolvedValueOnce(response(record('transfer_observed')))
await vi.advanceTimersByTimeAsync(3000)
expect(state.replacement.value?.state).toBe('transfer_observed')
scope.stop()
const calls = vi.mocked(api).mock.calls.length
await vi.advanceTimersByTimeAsync(6000)
expect(api).toHaveBeenCalledTimes(calls)
})
it('discards stale status responses when the selected book changes', async () => {
const scope = effectScope()
const device = ref<number | null>(null)
const book = ref<number | null>(null)
const state = scope.run(() => useKoboReplacement(device, book))!
let resolveOld!: (value: Response) => void
vi.mocked(api).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOld = resolve
}),
)
device.value = 1
book.value = 2
await nextTick()
const signal = vi.mocked(api).mock.calls[0]?.[1]?.signal
vi.mocked(api).mockResolvedValueOnce(response(record('user_confirmed', 'new')))
book.value = 3
await nextTick()
await flushPromises()
resolveOld(response(record('prepared', 'old')))
await flushPromises()
expect(signal?.aborted).toBe(true)
expect(state.replacement.value?.id).toBe('new')
scope.stop()
})
it('suspends a scheduled status poll while a replacement action is pending', async () => {
vi.useFakeTimers()
const scope = effectScope()
const device = ref<number | null>(null)
const book = ref<number | null>(null)
const state = scope.run(() => useKoboReplacement(device, book))!
vi.mocked(api).mockResolvedValueOnce(response(record('offered')))
device.value = 1
book.value = 2
await nextTick()
await flushPromises()
await vi.advanceTimersByTimeAsync(2999)
let resolveCancel!: (value: Response) => void
vi.mocked(api).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveCancel = resolve
}),
)
const cancelling = state.transition('cancel')
const signal = vi.mocked(api).mock.calls[1]?.[1]?.signal
await vi.advanceTimersByTimeAsync(3000)
expect(api).toHaveBeenCalledTimes(2)
expect(signal?.aborted).toBe(false)
expect(state.busy.value).toBe(true)
resolveCancel(response(record('cancelled')))
await cancelling
expect(state.replacement.value?.state).toBe('cancelled')
await vi.advanceTimersByTimeAsync(6000)
expect(api).toHaveBeenCalledTimes(2)
scope.stop()
})
})
@@ -0,0 +1,73 @@
import { onScopeDispose, ref, watch, type Ref } from 'vue'
import type { DeliveryArtifactMetadata, ReadAloudDeliveryVariant } from '@bookorbit/types'
import { api } from '@/lib/api'
interface Replacement {
id: string
state: string
options: { replacement: boolean }
metadata: DeliveryArtifactMetadata
}
export function useKoboReplacement(deviceId: Ref<number | null>, bookId: Ref<number | null>) {
const replacement = ref<Replacement | null>(null)
const busy = ref(false)
const failed = ref(false)
let generation = 0
let timer: ReturnType<typeof setTimeout> | undefined
let controller: AbortController | undefined
function stop() {
generation++
clearTimeout(timer)
controller?.abort()
}
function poll() {
clearTimeout(timer)
if (replacement.value && ['offered', 'transfer_observed'].includes(replacement.value.state)) timer = setTimeout(refresh, 3000)
}
async function request(path: string, body?: unknown) {
const current = ++generation
clearTimeout(timer)
controller?.abort()
const requestController = new AbortController()
controller = requestController
busy.value = true
failed.value = false
try {
const response = await api(path, {
...(body !== undefined ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}),
signal: requestController.signal,
})
if (!response.ok) throw new Error('Replacement request failed')
const record: Replacement | null = await response.json()
if (current === generation) replacement.value = record?.options.replacement ? record : null
} catch {
if (current === generation && !requestController.signal.aborted) failed.value = true
} finally {
if (current === generation) {
busy.value = false
poll()
}
}
}
async function refresh() {
if (!deviceId.value || !bookId.value) return
await request(`/api/v1/kobo/devices/${deviceId.value}/books/${bookId.value}/delivery`)
}
watch([deviceId, bookId], () => {
stop()
replacement.value = null
busy.value = false
failed.value = false
void refresh()
})
onScopeDispose(stop)
async function prepare(variant: ReadAloudDeliveryVariant) {
if (deviceId.value && bookId.value) await request(`/api/v1/kobo/devices/${deviceId.value}/books/${bookId.value}/replacement`, { variant })
}
async function transition(action: 'offer' | 'confirm' | 'cancel') {
if (!deviceId.value || !replacement.value) return
await request(`/api/v1/kobo/devices/${deviceId.value}/replacements/${replacement.value.id}/${action}`, {})
}
return { replacement, busy, failed, prepare, transition, refresh }
}
@@ -0,0 +1,22 @@
import { onMounted, ref } from 'vue'
import { api } from '@/lib/api'
import type { AppInfoResponse, ServerFeature } from '@bookorbit/types'
export function useReadAloudDelivery(feature: ServerFeature = 'read-aloud-delivery') {
const supported = ref(false)
let pending: Promise<void> | undefined
async function fetchSupport() {
pending ??= (async () => {
supported.value = false
const response = await api('/api/v1/app-info')
if (response.ok) supported.value = ((await response.json()) as AppInfoResponse).features?.includes(feature) === true
})()
.catch(() => {})
.finally(() => {
pending = undefined
})
await pending
}
onMounted(fetchSupport)
return { supported, fetchSupport }
}
@@ -1,6 +1,8 @@
<script setup lang="ts">
import ReadAloudDeliverySelect from '@/features/delivery/components/ReadAloudDeliverySelect.vue'
import { useReadAloudDelivery } from '@/features/delivery/composables/useReadAloudDelivery'
import { Button } from '@/components/ui/button'
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -9,6 +11,7 @@ import { useEmailRecipients, type EmailRecipient, type EmailRecipientForm } from
import { useEmailTemplates } from '../composables/useEmailTemplates'
const { t } = useI18n()
const { supported: deliverySupported } = useReadAloudDelivery()
const { recipients, createRecipient, updateRecipient, deleteRecipient, setDefaultRecipient } = useEmailRecipients()
const { templates, fetchTemplates } = useEmailTemplates()
@@ -31,10 +34,17 @@ const emptyForm = (): EmailRecipientForm => ({
deviceType: null,
preferredFormat: null,
defaultTemplateId: null,
readAloudDelivery: 'audioless_epub',
})
const form = reactive<EmailRecipientForm>(emptyForm())
const formError = ref<string | null>(null)
watch(
() => form.deviceType,
(deviceType) => {
if (deviceType === 'kindle' && form.readAloudDelivery === 'original') form.readAloudDelivery = 'audioless_epub'
},
)
function openCreate() {
Object.assign(form, emptyForm())
@@ -51,6 +61,7 @@ function openEdit(r: EmailRecipient) {
deviceType: r.deviceType,
preferredFormat: r.preferredFormat,
defaultTemplateId: r.defaultTemplateId,
readAloudDelivery: r.readAloudDelivery ?? null,
})
editingId.value = r.id
formError.value = null
@@ -72,11 +83,14 @@ async function submitForm() {
saving.value = true
formError.value = null
try {
const { readAloudDelivery: ignoredDelivery, ...legacy } = form
void ignoredDelivery
const payload = deliverySupported.value ? form : legacy
if (editingId.value) {
await updateRecipient(editingId.value, form)
await updateRecipient(editingId.value, payload)
toast.success(t('email.recipients.updated'))
} else {
await createRecipient(form)
await createRecipient(payload)
toast.success(t('email.recipients.created'))
}
cancelForm()
@@ -197,6 +211,7 @@ function cancelDelete() {
</div>
</div>
<ReadAloudDeliverySelect v-if="deliverySupported" v-model="form.readAloudDelivery" :kindle="form.deviceType === 'kindle'" :disabled="saving" />
<div v-if="form.deviceType === 'kindle'" class="text-xs text-muted-foreground bg-muted/50 rounded-xs px-3 py-2">
{{ t('email.recipients.kindleNote') }}
</div>
@@ -1,26 +1,8 @@
import type { EmailRecipient, EmailRecipientForm } from '@bookorbit/types'
export type { EmailRecipient, EmailRecipientForm } from '@bookorbit/types'
import { ref } from 'vue'
import { api } from '@/lib/api'
export interface EmailRecipient {
id: number
userId: number
name: string
email: string
isDefault: boolean
deviceType: string | null
preferredFormat: string | null
defaultTemplateId: number | null
createdAt: string
}
export interface EmailRecipientForm {
name: string
email: string
deviceType: string | null
preferredFormat: string | null
defaultTemplateId: number | null
}
const recipients = ref<EmailRecipient[]>([])
let fetchPromise: Promise<void> | null = null
@@ -1,23 +1,8 @@
import type { EmailSendLogEntry } from '@bookorbit/types'
export type { EmailSendLogEntry } from '@bookorbit/types'
import { ref } from 'vue'
import { api } from '@/lib/api'
export interface EmailSendLogEntry {
id: number
userId: number
bookId: number | null
bookFileId: number | null
providerId: number | null
templateId: number | null
toEmail: string
toName: string | null
subject: string | null
status: 'pending' | 'sent' | 'failed'
attemptCount: number
errorMessage: string | null
sentAt: string | null
createdAt: string
}
const logEntries = ref<EmailSendLogEntry[]>([])
const total = ref(0)
@@ -0,0 +1,65 @@
import { computed, reactive, ref, watch, type Ref } from 'vue'
import type { KoreaderCredentials, KoreaderDeviceInfo, KoreaderDeviceSweepInfo, ReadAloudDeliveryPreference } from '@bookorbit/types'
import { api } from '@/lib/api'
export function useKoreaderDeliverySettings(
credentials: Ref<KoreaderCredentials | null>,
devices: Ref<(KoreaderDeviceInfo | KoreaderDeviceSweepInfo)[]>,
refreshed: () => void,
) {
const defaultDraft = ref<ReadAloudDeliveryPreference>(credentials.value?.readAloudDelivery ?? null)
const deviceDrafts = reactive<Record<string, ReadAloudDeliveryPreference>>({})
const saving = ref<string | null>(null)
const error = ref(false)
const saved = ref(false)
const defaultChanged = computed(() => defaultDraft.value !== (credentials.value?.readAloudDelivery ?? null))
watch(
() => credentials.value?.readAloudDelivery,
(value) => {
defaultDraft.value = value ?? null
},
)
watch(
devices,
(rows, previous) => {
const oldValues = new Map((previous ?? []).map((device) => [device.deviceId, device.readAloudDelivery ?? null]))
for (const device of rows) {
if (!(device.deviceId in deviceDrafts) || deviceDrafts[device.deviceId] === oldValues.get(device.deviceId))
deviceDrafts[device.deviceId] = device.readAloudDelivery ?? null
}
},
{ immediate: true },
)
function deviceChanged(device: KoreaderDeviceInfo | KoreaderDeviceSweepInfo) {
return deviceDrafts[device.deviceId] !== (device.readAloudDelivery ?? null)
}
async function save(deviceId?: string) {
if (saving.value) return
const value = deviceId ? (deviceDrafts[deviceId] ?? null) : defaultDraft.value
saving.value = deviceId ?? 'default'
error.value = false
saved.value = false
try {
const response = await api(
deviceId ? `/api/v1/koreader/devices/${encodeURIComponent(deviceId)}/delivery-settings` : '/api/v1/koreader/credentials',
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ readAloudDelivery: value }),
},
)
if (!response.ok) throw new Error('save')
saved.value = true
refreshed()
} catch {
error.value = true
} finally {
saving.value = null
}
}
return { defaultDraft, deviceDrafts, saving, saved, error, defaultChanged, deviceChanged, save }
}
+9 -1
View File
@@ -244,6 +244,7 @@ function handleTranslate() {
}
const bookMeta = ref<BookDetail | null>(null)
const initialNavigationPending = ref(true)
const {
foliateReady,
@@ -267,7 +268,10 @@ let mediaOverlayStartId = 0
const isMediaOverlayAvailable = computed(() => isTtsAvailable && hasMediaOverlay.value)
const isTtsActive = computed(() => isActive.value && currentBook.value?.bookFileId === fileId)
const isNavigationLocked = computed(
() => (isTtsActive.value && playbackState.value === 'playing') || (mediaOverlay.isActive.value && mediaOverlay.isPlaying.value),
() =>
initialNavigationPending.value ||
(isTtsActive.value && playbackState.value === 'playing') ||
(mediaOverlay.isActive.value && mediaOverlay.isPlaying.value),
)
useTtsKeyboard(() => mediaOverlay.isActive.value, {
@@ -750,6 +754,8 @@ function onMiddleTapHandler() {
}
function canRunManualNavigation(): boolean {
if (initialNavigationPending.value) return false
if (!isNavigationLocked.value) {
relocationSource = 'text'
return true
@@ -890,6 +896,8 @@ onMounted(async () => {
}
}
initialNavigationPending.value = error.value !== null
const resumedPercentage = resumeTarget.checkpointPercentage ?? (hadProgress ? progress.percentage.value : null)
if (resumedPercentage !== null) {
const pct = Math.round(resumedPercentage)
@@ -1,4 +1,8 @@
<script setup lang="ts">
import KoboReplacement from '@/features/delivery/components/KoboReplacement.vue'
import ReadAloudDeliverySelect from '@/features/delivery/components/ReadAloudDeliverySelect.vue'
import { useReadAloudDelivery } from '@/features/delivery/composables/useReadAloudDelivery'
import type { ReadAloudDeliveryPreference } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -15,6 +19,8 @@ import { useKoboSyncHistory } from '@/features/kobo/composables/useKoboSyncHisto
import type { KoboDevice, KoboSyncHistoryEntry } from '@bookorbit/types'
const { t } = useI18n()
const { supported: deliverySupported } = useReadAloudDelivery()
const readAloudDelivery = ref<ReadAloudDeliveryPreference>(null)
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
embedded: false,
})
@@ -29,6 +35,7 @@ const error = ref<string | null>(null)
// Create device
const showCreateForm = ref(false)
const newDeviceName = ref('')
const firstDeviceDelivery = ref<ReadAloudDeliveryPreference>('audioless_epub')
const creating = ref(false)
const createError = ref<string | null>(null)
@@ -147,6 +154,7 @@ function applySettingsToLocal() {
twoWayProgressSync.value = settings.value.twoWayProgressSync
syncBookOrbitAnnotationsToKobo.value = settings.value.syncBookOrbitAnnotationsToKobo
storeSync.value = settings.value.storeSync
readAloudDelivery.value = settings.value.readAloudDelivery ?? null
}
function formatLastSeen(date: string | null): string {
@@ -581,6 +589,10 @@ async function submitCreate() {
creating.value = true
createError.value = null
try {
if (deliverySupported.value && devices.value.length === 0) {
await updateSettings({ readAloudDelivery: firstDeviceDelivery.value })
readAloudDelivery.value = firstDeviceDelivery.value
}
const device = await createDevice(newDeviceName.value.trim())
newDeviceToken.value = device.token
newDeviceSyncUrl.value = `${window.location.origin}/api/v1/kobo/${device.token}`
@@ -680,6 +692,7 @@ async function saveSettings() {
twoWayProgressSync: twoWayProgressSync.value,
syncBookOrbitAnnotationsToKobo: syncBookOrbitAnnotationsToKobo.value,
storeSync: storeSync.value,
...(deliverySupported.value ? { readAloudDelivery: readAloudDelivery.value } : {}),
})
applySettingsToLocal()
toast.success(t('settings.reader.kobo.settingsSaved'))
@@ -691,6 +704,7 @@ async function saveSettings() {
}
}
function beginCreate() {
firstDeviceDelivery.value = settings.value?.readAloudDelivery ?? 'audioless_epub'
showCreateForm.value = true
}
</script>
@@ -788,6 +802,10 @@ function beginCreate() {
class="input-field w-full"
/>
</div>
<div v-if="deliverySupported && devices.length === 0" class="space-y-2">
<ReadAloudDeliverySelect v-model="firstDeviceDelivery" :disabled="creating" />
<p class="text-xs text-muted-foreground">{{ t('delivery.readAloud.koboHint') }}</p>
</div>
<div v-if="createError" class="text-xs text-destructive">
{{ createError }}
</div>
@@ -1039,6 +1057,13 @@ function beginCreate() {
</div>
</div>
<div v-if="deliverySupported && activeTab === 'settings'" class="rounded-xs border border-border bg-card p-5 space-y-3">
<ReadAloudDeliverySelect v-model="readAloudDelivery" :disabled="savingSettings" />
<p class="text-xs text-muted-foreground">{{ t('delivery.readAloud.koboHint') }}</p>
</div>
<KoboReplacement v-if="deliverySupported && activeTab === 'settings'" :devices="devices" />
<!-- Sync settings -->
<div v-show="activeTab === 'settings' || props.embedded" class="mb-8">
<p class="settings-group-label">
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type { KoreaderCredentials, KoreaderDeviceInfo, KoreaderDeviceSweepInfo } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import ReadAloudDeliverySelect from '@/features/delivery/components/ReadAloudDeliverySelect.vue'
import { useKoreaderDeliverySettings } from '@/features/koreader/composables/useKoreaderDeliverySettings'
const props = defineProps<{ credentials: KoreaderCredentials | null; devices: KoreaderDeviceInfo[]; sweeps: KoreaderDeviceSweepInfo[] }>()
const emit = defineEmits<{ updated: [] }>()
const { t } = useI18n()
const devices = computed(() => [
...new Map([...props.devices, ...props.sweeps].filter((device) => device.retiredAt === null).map((device) => [device.deviceId, device])).values(),
])
const { defaultDraft, deviceDrafts, saving, saved, error, defaultChanged, deviceChanged, save } = useKoreaderDeliverySettings(
toRef(props, 'credentials'),
devices,
handleUpdated,
)
function handleUpdated() {
emit('updated')
}
function saveDefault() {
void save()
}
function saveDevice(deviceId: string) {
void save(deviceId)
}
function deviceName(device: KoreaderDeviceInfo | KoreaderDeviceSweepInfo) {
return 'device' in device ? device.device : device.deviceModel
}
</script>
<template>
<div class="mb-8 space-y-3">
<p class="settings-group-label">{{ t('delivery.readAloud.koreaderDefaultTitle') }}</p>
<div class="settings-card">
<div class="space-y-3 px-4 py-4 bg-card md:px-5">
<ReadAloudDeliverySelect v-model="defaultDraft" :disabled="saving !== null" />
<p class="settings-hint">{{ t('delivery.readAloud.koreaderDefaultHint') }}</p>
<Button :disabled="saving !== null || !defaultChanged" @click="saveDefault">{{ t('common.save') }}</Button>
</div>
<div v-for="device in devices" :key="device.deviceId" role="group" :aria-label="deviceName(device)" class="space-y-3 px-4 py-4 bg-card md:px-5">
<p class="settings-label">{{ deviceName(device) }}</p>
<p class="settings-hint break-all">{{ device.deviceId }}</p>
<ReadAloudDeliverySelect
v-model="deviceDrafts[device.deviceId]"
:disabled="saving !== null"
:inherit-label="t('delivery.readAloud.koreaderUseDefault')"
/>
<Button :disabled="saving !== null || !deviceChanged(device)" @click="saveDevice(device.deviceId)">{{ t('common.save') }}</Button>
</div>
</div>
<p v-if="error" role="alert" class="text-sm text-destructive">{{ t('delivery.readAloud.koreaderSaveFailed') }}</p>
<p v-if="saved" role="status" class="text-sm text-muted-foreground">{{ t('delivery.readAloud.koreaderSaved') }}</p>
</div>
</template>
@@ -40,6 +40,10 @@ import type {
} from '@bookorbit/types'
import SettingsTabs from './components/SettingsTabs.vue'
import KoreaderCredentialsDialog from './KoreaderCredentialsDialog.vue'
import KoreaderDeliverySettings from './KoreaderDeliverySettings.vue'
import ReadAloudDeliverySelect from '@/features/delivery/components/ReadAloudDeliverySelect.vue'
import { useReadAloudDelivery } from '@/features/delivery/composables/useReadAloudDelivery'
import type { ReadAloudDeliveryPreference } from '@bookorbit/types'
import KoreaderFileNamingSettings from './KoreaderFileNamingSettings.vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { copyToClipboard } from '@/lib/clipboard'
@@ -170,6 +174,9 @@ onUnmounted(() => {
})
const syncUrl = computed(() => new URL('/api/v1/koreader', getSyncUrl()).toString())
const { supported: deliverySupported } = useReadAloudDelivery('koreader-delivery-settings')
const newReadAloudDelivery = ref<ReadAloudDeliveryPreference>('audioless_epub')
const hasCredentials = computed(() => !!credentials.value)
const activeDevices = computed(() => (syncStatus.value?.devices ?? []).filter((device) => device.retiredAt === null))
const retiredDevices = computed(() => (syncStatus.value?.devices ?? []).filter((device) => device.retiredAt !== null))
@@ -343,6 +350,7 @@ async function handleCreate() {
await createCredentials({
username: newUsername.value,
password: newPassword.value,
...(deliverySupported.value ? { readAloudDelivery: newReadAloudDelivery.value } : {}),
})
showSetupForm.value = false
helpOpen.value = false
@@ -722,6 +730,7 @@ async function handleDownloadPlugin() {
<p class="text-xs text-muted-foreground">
{{ t('settings.reader.koreader.createFormHint') }}
</p>
<ReadAloudDeliverySelect v-if="deliverySupported" v-model="newReadAloudDelivery" :disabled="creating" explicit-only />
<div>
<label class="block text-xs font-medium text-muted-foreground mb-1.5">{{ t('settings.reader.koreader.username') }}</label>
<input
@@ -1023,6 +1032,14 @@ async function handleDownloadPlugin() {
</div>
</div>
<KoreaderDeliverySettings
v-if="deliverySupported"
:credentials="credentials"
:devices="syncStatus?.devices ?? []"
:sweeps="syncStatus?.sweeps ?? []"
@updated="handleRefresh"
/>
<div class="mb-8">
<p class="settings-group-label">
{{ t('settings.reader.koreader.pluginActivity') }}
@@ -1,4 +1,7 @@
<script setup lang="ts">
import ReadAloudDeliverySelect from '@/features/delivery/components/ReadAloudDeliverySelect.vue'
import { useReadAloudDelivery } from '@/features/delivery/composables/useReadAloudDelivery'
import type { ReadAloudDeliveryPreference } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { onMounted, ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -13,6 +16,8 @@ import { useMediaQuery } from '@vueuse/core'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
const { t } = useI18n()
const { supported: deliverySupported } = useReadAloudDelivery()
const createDelivery = ref<ReadAloudDeliveryPreference>('audioless_epub')
const { hasPermission } = usePermissions()
const canManageSettings = computed(() => hasPermission('manage_app_settings'))
@@ -105,6 +110,7 @@ async function createUser() {
username: createUsername.value,
password: createPassword.value,
sortOrder: createSortOrder.value,
...(deliverySupported.value ? { readAloudDelivery: createDelivery.value } : {}),
}),
})
if (!res.ok) {
@@ -127,6 +133,20 @@ async function createUser() {
}
}
async function updateDelivery(user: OpdsUser, readAloudDelivery: ReadAloudDeliveryPreference) {
const res = await api(`/api/v1/opds-users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ readAloudDelivery }),
})
if (!res.ok) {
toast.error(t('settings.reader.opds.updateSettingsFailed'))
return
}
const updated: OpdsUser = await res.json()
opdsUsers.value = opdsUsers.value.map((profile) => (profile.id === user.id ? updated : profile))
}
async function updateSortOrder(user: OpdsUser, sortOrder: OpdsSortOrder) {
try {
const res = await api(`/api/v1/opds-users/${user.id}`, {
@@ -306,6 +326,7 @@ function cancelDelete() {
</option>
</select>
</div>
<ReadAloudDeliverySelect v-if="deliverySupported" v-model="createDelivery" />
<div v-if="createError" class="text-xs text-destructive">
{{ createError }}
</div>
@@ -353,6 +374,11 @@ function cancelDelete() {
{{ opt.label }}
</option>
</select>
<ReadAloudDeliverySelect
v-if="deliverySupported"
:model-value="user.readAloudDelivery"
@update:model-value="updateDelivery(user, $event)"
/>
<Button variant="destructive-ghost" size="icon-sm" class="hidden md:flex" @click="requestDeleteUser(user)">
<Trash2 :size="14" />
</Button>
@@ -0,0 +1,93 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import KoreaderDeliverySettings from '../KoreaderDeliverySettings.vue'
import { api } from '@/lib/api'
import type { KoreaderDeviceInfo } from '@bookorbit/types'
vi.mock('@/lib/api', () => ({ api: vi.fn<typeof api>() }))
const apiMock = vi.mocked(api)
const device: KoreaderDeviceInfo = {
device: 'Reader A',
deviceId: 'device-a',
retiredAt: null,
lastSyncAt: '',
lastBookTitle: null,
readAloudDelivery: null,
}
function createWrapper() {
return mount(KoreaderDeliverySettings, {
props: {
credentials: { username: 'reader', syncEnabled: true, createdAt: '', readAloudDelivery: 'audioless_epub' },
devices: [device],
sweeps: [],
},
})
}
describe('KOReader delivery settings', () => {
beforeEach(() => {
vi.clearAllMocks()
apiMock.mockResolvedValue({ ok: true } as Response)
})
it('saves a credential default through the existing route', async () => {
const wrapper = createWrapper()
await wrapper.findAll('select')[0]!.setValue('original')
await wrapper.findAll('button')[0]!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/koreader/credentials',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ readAloudDelivery: 'original' }) }),
)
expect(wrapper.emitted('updated')).toHaveLength(1)
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('saves a device override and restores inheritance explicitly', async () => {
const wrapper = createWrapper()
await wrapper.findAll('select')[1]!.setValue('original')
await wrapper.findAll('button')[1]!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenLastCalledWith(
'/api/v1/koreader/devices/device-a/delivery-settings',
expect.objectContaining({ body: JSON.stringify({ readAloudDelivery: 'original' }) }),
)
await wrapper.setProps({ devices: [{ ...device, readAloudDelivery: 'original' }] })
await wrapper.findAll('select')[1]!.setValue('')
await wrapper.findAll('button')[1]!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenLastCalledWith(
'/api/v1/koreader/devices/device-a/delivery-settings',
expect.objectContaining({ body: JSON.stringify({ readAloudDelivery: null }) }),
)
})
it('exposes save errors accessibly and keeps the draft', async () => {
apiMock.mockResolvedValue({ ok: false } as Response)
const wrapper = createWrapper()
await wrapper.findAll('select')[0]!.setValue('original')
await wrapper.findAll('button')[0]!.trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.emitted('updated')).toBeUndefined()
expect((wrapper.findAll('select')[0]!.element as HTMLSelectElement).value).toBe('original')
})
it('updates a clean device selector after a plugin change while preserving an unsaved draft', async () => {
const wrapper = createWrapper()
await wrapper.setProps({ devices: [{ ...device, readAloudDelivery: 'original' }] })
expect((wrapper.findAll('select')[1]!.element as HTMLSelectElement).value).toBe('original')
await wrapper.findAll('select')[1]!.setValue('audioless_epub')
await wrapper.setProps({ devices: [{ ...device, readAloudDelivery: null }] })
expect((wrapper.findAll('select')[1]!.element as HTMLSelectElement).value).toBe('audioless_epub')
})
it('deduplicates devices from progress and sweeps and hides retired devices', async () => {
const wrapper = createWrapper()
await wrapper.setProps({
sweeps: [
{ deviceId: 'device-a', deviceModel: 'Reader A', retiredAt: null } as never,
{ deviceId: 'retired', deviceModel: 'Old', retiredAt: '2026-01-01' } as never,
],
})
expect(wrapper.findAll('select')).toHaveLength(2)
for (const select of wrapper.findAll('select')) {
expect(wrapper.find(`label[for="${select.attributes('id')}"]`).exists()).toBe(true)
}
})
})
+40 -1
View File
@@ -5174,7 +5174,11 @@
"exportFailed": "Export failed",
"audiolessEpub": "KOReader EPUB",
"audiolessEpubHint": "Download EPUB for KOReader with audio files removed",
"noAudio": "no audio"
"noAudio": "no audio",
"textOnlyEpub": "Text-only EPUB",
"textOnlyEpubHint": "Download a narration-free EPUB while preserving reading anchors.",
"originalNarrated": "Original EPUB with narration",
"originalNarratedEpub": "Original EPUB with narration"
},
"sort": {
"moveUp": "Move up",
@@ -12677,5 +12681,40 @@
"errors": {
"generic": "TTS error"
}
},
"delivery": {
"readAloud": {
"label": "Read-aloud EPUB delivery",
"automatic": "Automatic: prefer text-only, allow original fallback.",
"textOnly": "Text-only EPUB",
"original": "Original EPUB with narration",
"hint": "Applies to future deliveries. Originals preserve existing narration and can be much larger. Text-only preparation fails if a safe copy cannot be made.",
"koboHint": "Original EPUBs bypass KEPUB conversion. Native Kobo firmware may not play narration, and precise position and highlight sync are unavailable for that copy.",
"profileHint": "Use separate OPDS profiles for readers that need different EPUB variants.",
"replacementTitle": "Replace a Kobo copy",
"bookId": "Book ID",
"device": "Device",
"prepare": "Prepare replacement",
"offer": "I removed only the device copy; offer the replacement",
"confirm": "I installed the replacement",
"cancel": "Cancel replacement",
"replacementHint": "Prepare and review first. Removing a device copy does not delete the library book. Back up device-local notes yourself. BookOrbit preserves canonical progress and server annotations. Delayed events can limit precise sync.",
"prepared": "Replacement prepared: {format}, {size}. Remove this book only from the selected device, then offer the prepared replacement and sync.",
"offered": "Replacement offered. Sync and download on the device, then confirm installation. A completed HTTP transfer does not prove installation.",
"cancelled": "Replacement cancelled. Cancellation does not restore a removed or partially replaced device copy. Select the same book to prepare a recovery download.",
"failed": "Could not prepare or update this replacement.",
"searchBook": "Search for a book",
"chooseBook": "Select a book",
"moreResults": "Show more books",
"noResults": "No matching books found.",
"refresh": "Refresh delivery status",
"confirmed": "You confirmed installation. This records your confirmation, not device verification.",
"transferred": "The transfer completed. Open the replacement on the device before confirming installation.",
"koreaderDefaultTitle": "KOReader EPUB delivery",
"koreaderDefaultHint": "The default applies to future downloads on devices using these credentials. Device settings can override it. Existing copies and interrupted downloads stay unchanged. Automatic preserves existing device choices.",
"koreaderUseDefault": "Use credential default",
"koreaderSaveFailed": "Could not save the KOReader EPUB delivery setting. Try again.",
"koreaderSaved": "KOReader EPUB delivery setting saved."
}
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ watch(
</script>
<template>
<div class="mt-2 flex h-[calc(100%-0.5rem)] flex-col overflow-hidden rounded-lg border border-border/70 bg-card/40 shadow-sm">
<div class="flex h-full flex-col overflow-hidden rounded-lg border border-border/70 bg-card/40 shadow-sm">
<div class="shrink-0 border-b border-border/70 bg-card/60 px-4 py-2.5 md:px-6" data-testid="settings-page-header">
<div class="flex min-h-9 items-center gap-4">
<div class="flex min-w-0 flex-1 items-baseline gap-2">