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