mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user