fix(kobo): refuse a replacement with the bytes the device already has

A replacement byte-identical to the device's copy was never downloaded, so
it stayed offered and every bookmark the device pushed was discarded.
Keep the current delivery instead, and tell the user in the replacement
panel that the device already has this copy.

Ref #1786
This commit is contained in:
neon
2026-10-17 00:10:32 +00:00
parent 5051ec0913
commit abb44aa47a
6 changed files with 61 additions and 6 deletions
@@ -20,10 +20,10 @@ watch(query, () => {
})
const { results, loading, settled, hasMore, loadMore, clear } = useGlobalSearch(query)
onScopeDispose(clear)
const { replacement, busy, failed, prepare, transition, refresh } = useKoboReplacement(deviceId, bookId)
const { replacement, busy, failed, alreadyOnDevice, 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) return alreadyOnDevice.value ? t('delivery.readAloud.alreadyOnDevice') : ''
if (replacement.value.state === 'prepared')
return t('delivery.readAloud.prepared', {
format: replacement.value.metadata.format.toUpperCase(),
@@ -36,6 +36,30 @@ describe('guided Kobo replacement lifecycle', () => {
expect(api).toHaveBeenCalledTimes(calls)
})
it('says the device already has the copy when preparing answers with the current delivery', async () => {
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(null))
device.value = 1
book.value = 2
await nextTick()
await flushPromises()
vi.mocked(api).mockResolvedValueOnce(response({ ...record('transfer_observed'), options: { replacement: false } }))
await state.prepare('audioless_epub')
expect(state.replacement.value).toBeNull()
expect(state.alreadyOnDevice.value).toBe(true)
vi.mocked(api).mockResolvedValueOnce(response(null))
book.value = 3
await nextTick()
expect(state.alreadyOnDevice.value).toBe(false)
vi.mocked(api).mockResolvedValueOnce(response(record('prepared')))
await state.prepare('audioless_epub')
expect(state.alreadyOnDevice.value).toBe(false)
scope.stop()
})
it('discards stale status responses when the selected book changes', async () => {
const scope = effectScope()
const device = ref<number | null>(null)
@@ -12,6 +12,7 @@ export function useKoboReplacement(deviceId: Ref<number | null>, bookId: Ref<num
const replacement = ref<Replacement | null>(null)
const busy = ref(false)
const failed = ref(false)
const alreadyOnDevice = ref(false)
let generation = 0
let timer: ReturnType<typeof setTimeout> | undefined
let controller: AbortController | undefined
@@ -25,7 +26,7 @@ export function useKoboReplacement(deviceId: Ref<number | null>, bookId: Ref<num
clearTimeout(timer)
if (replacement.value && ['offered', 'transfer_observed'].includes(replacement.value.state)) timer = setTimeout(refresh, 3000)
}
async function request(path: string, body?: unknown) {
async function request(path: string, body?: unknown, onRecord?: (record: Replacement | null) => void) {
const current = ++generation
clearTimeout(timer)
controller?.abort()
@@ -40,7 +41,10 @@ export function useKoboReplacement(deviceId: Ref<number | null>, bookId: Ref<num
})
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
if (current === generation) {
replacement.value = record?.options.replacement ? record : null
onRecord?.(record)
}
} catch {
if (current === generation && !requestController.signal.aborted) failed.value = true
} finally {
@@ -59,15 +63,23 @@ export function useKoboReplacement(deviceId: Ref<number | null>, bookId: Ref<num
replacement.value = null
busy.value = false
failed.value = false
alreadyOnDevice.value = false
void refresh()
})
onScopeDispose(stop)
// The server answers with the device's current copy, not a replacement, when that copy already has these bytes.
function noteCopyOnDevice(record: Replacement | null) {
alreadyOnDevice.value = !!record && !record.options.replacement
}
async function prepare(variant: ReadAloudDeliveryVariant) {
if (deviceId.value && bookId.value) await request(`/api/v1/kobo/devices/${deviceId.value}/books/${bookId.value}/replacement`, { variant })
alreadyOnDevice.value = false
if (deviceId.value && bookId.value)
await request(`/api/v1/kobo/devices/${deviceId.value}/books/${bookId.value}/replacement`, { variant }, noteCopyOnDevice)
}
async function transition(action: 'offer' | 'confirm' | 'cancel') {
if (!deviceId.value || !replacement.value) return
alreadyOnDevice.value = false
await request(`/api/v1/kobo/devices/${deviceId.value}/replacements/${replacement.value.id}/${action}`, {})
}
return { replacement, busy, failed, prepare, transition, refresh }
return { replacement, busy, failed, alreadyOnDevice, prepare, transition, refresh }
}
+1
View File
@@ -13986,6 +13986,7 @@
"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.",
"alreadyOnDevice": "The device already has this exact copy, so there is nothing to replace.",
"failed": "Could not prepare or update this replacement.",
"searchBook": "Search for a book",
"chooseBook": "Select a book",