feat(kobo): deliver Kobo store titles and fix store cover 404s

Kobo sync replaces the store API for the device but only ever answered for
BookOrbit's own books, so anything owned through Kobo fell through the gaps.

Library sync never queried Kobo, so Kobo Plus and purchased titles had no route
to the device at all. Add an opt-in per-user store sync that pulls the device's
upstream entitlements with the device's own Kobo credentials, which still reach
us because initialization leaves device_auth and device_refresh pointing at
Kobo, and appends them to BookOrbit's own. Both cursors ride in one composite
sync token, so no server-side state is needed and tokens minted before this
change keep working. The store is only consulted once BookOrbit has drained its
own pages, and paging stops if Kobo's cursor fails to advance.

Everything on the store path stays inside one guarantee: reading the setting, or
a store that is slow, down, unauthorized or answering with a body we cannot use,
costs the store page and never the BookOrbit half of the sync.

Initialization also rewrites the image templates to point at BookOrbit, so the
device asked us for store covers too. Those were forwarded to storeapi.kobo.com,
which does not serve images, and 404d. Redirect unresolved cover ids to Kobo's
image CDN instead. This is not gated on store sync, since browsing the store
shows those covers whether or not the setting is on.

Deliberately left alone: annotation pulls still 404 for content that is not a
BookOrbit book. The device reconciles annotations by omission, so answering with
an empty set would wipe its local highlights.

Closes #789
This commit is contained in:
neon
2026-08-10 23:45:56 +00:00
parent 80223b5e67
commit 2d09c37806
23 changed files with 16845 additions and 82 deletions
@@ -0,0 +1,76 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { KoboSyncSettings } from '@bookorbit/types'
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>())
vi.mock('@/lib/api', () => ({
api: apiMock,
}))
// The composable keeps settings in a module-level ref, so each test takes a fresh module
// instance rather than inheriting whatever the previous test left behind.
async function freshComposable() {
vi.resetModules()
const { useKoboSettings } = await import('../useKoboSettings')
return useKoboSettings()
}
function makeSettings(overrides: Partial<KoboSyncSettings> = {}): KoboSyncSettings {
return {
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
syncBookOrbitAnnotationsToKobo: false,
storeSync: false,
...overrides,
}
}
function makeResponse(data: unknown, ok = true): Response {
return { ok, status: ok ? 200 : 500, json: async () => data } as Response
}
describe('useKoboSettings', () => {
beforeEach(() => {
apiMock.mockReset()
})
it('defaults store sync to off before any fetch resolves', async () => {
const { settings } = await freshComposable()
expect(settings.value.storeSync).toBe(false)
})
it('adopts the store sync flag the server reports', async () => {
apiMock.mockResolvedValue(makeResponse(makeSettings({ storeSync: true })))
const { settings, fetchSettings } = await freshComposable()
await fetchSettings()
expect(settings.value.storeSync).toBe(true)
})
it('sends the store sync flag in the patch body and adopts the response', async () => {
apiMock.mockResolvedValue(makeResponse(makeSettings({ storeSync: true })))
const { settings, updateSettings } = await freshComposable()
await updateSettings({ storeSync: true })
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/kobo/settings',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ storeSync: true }) }),
)
expect(settings.value.storeSync).toBe(true)
})
it('surfaces the server message when an update is rejected', async () => {
apiMock.mockResolvedValue(makeResponse({ message: 'Reading threshold must be less than finished threshold' }, false))
const { updateSettings } = await freshComposable()
await expect(updateSettings({ storeSync: true })).rejects.toThrow('Reading threshold must be less than finished threshold')
})
})
@@ -10,6 +10,7 @@ const settings = ref<KoboSyncSettings>({
kepubConversionLimitMb: 100,
twoWayProgressSync: false,
syncBookOrbitAnnotationsToKobo: false,
storeSync: false,
})
let fetchPromise: Promise<void> | null = null
@@ -46,6 +46,7 @@ const forceEnableHyphenation = ref(false)
const kepubConversionLimitMb = ref(100)
const twoWayProgressSync = ref(false)
const syncBookOrbitAnnotationsToKobo = ref(false)
const storeSync = ref(false)
const savingSettings = ref(false)
const settingsError = ref<string | null>(null)
const refreshingHistory = ref(false)
@@ -118,6 +119,7 @@ function applySettingsToLocal() {
kepubConversionLimitMb.value = settings.value.kepubConversionLimitMb
twoWayProgressSync.value = settings.value.twoWayProgressSync
syncBookOrbitAnnotationsToKobo.value = settings.value.syncBookOrbitAnnotationsToKobo
storeSync.value = settings.value.storeSync
}
function formatLastSeen(date: string | null): string {
@@ -615,6 +617,7 @@ async function saveSettings() {
kepubConversionLimitMb: kepubConversionLimitMb.value,
twoWayProgressSync: twoWayProgressSync.value,
syncBookOrbitAnnotationsToKobo: syncBookOrbitAnnotationsToKobo.value,
storeSync: storeSync.value,
})
applySettingsToLocal()
toast.success(t('settings.reader.kobo.settingsSaved'))
@@ -999,6 +1002,16 @@ async function saveSettings() {
<ToggleSwitch v-model="syncBookOrbitAnnotationsToKobo" />
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div class="pr-8">
<p class="settings-label">{{ t('settings.reader.kobo.storeSync') }}</p>
<p class="settings-hint">
{{ t('settings.reader.kobo.storeSyncHint') }}
</p>
</div>
<ToggleSwitch v-model="storeSync" />
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div class="pr-8">
<p class="settings-label">{{ t('settings.reader.kobo.convertKepub') }}</p>
+2
View File
@@ -1876,6 +1876,8 @@
"twoWaySyncHint": "Sync reading position between BookOrbit and Kobo. Requires KEPUB delivery for precise locations and reliable page restore.",
"syncHighlights": "Sync BookOrbit highlights to Kobo",
"syncHighlightsHint": "Send highlights made in BookOrbit or KOReader to your Kobo. Kobo highlights are imported into BookOrbit even when this is off. Linked annotations sync edits and deletes both ways. Requires KEPUB delivery; the book on Kobo must be the KEPUB downloaded from BookOrbit.",
"storeSync": "Include Kobo store titles",
"storeSyncHint": "Also deliver books from your Kobo account, including Kobo Plus and purchases, alongside your BookOrbit library. Without this, only BookOrbit books reach the device. Store titles are downloaded from Kobo directly and are not added to your BookOrbit library.",
"convertKepub": "Convert to KEPUB",
"convertKepubHint": "Send eligible EPUBs as KEPUB. This stays on when progress sync or BookOrbit-to-Kobo highlights are enabled. On the next Kobo sync, affected books are offered again as KEPUB downloads; remove any old EPUB copy if Kobo keeps opening it.",
"forceHyphenation": "Force hyphenation",