fix(kobo): preserve koreader matching for converted downloads

Register the identity of delivered bytes so Kobo conversions and audio
removal continue matching their source books in KOReader. Keep download
registration recoverable without allowing database lock waits to stall it.

Preserve manual corrections across cached device identities, make review
semantics explicit, and keep cache invalidation bounded for large libraries.
Add unit, database, lifecycle and scale regressions with one generated migration.

Closes #1697
This commit is contained in:
neon
2026-10-07 00:07:50 +00:00
parent 019c432262
commit 58756565fc
51 changed files with 25561 additions and 735 deletions
@@ -454,9 +454,9 @@ async function handleRefreshUnmatched() {
try {
await fetchUnmatchedBooks()
unmatchedPage.value = 1
toast.success(t('settings.reader.koreader.hashLinks.toast.unmatchedRefreshed'))
toast.success(t('settings.reader.koreader.hashLinks.toast.reviewRefreshed'))
} catch {
toast.error(t('settings.reader.koreader.hashLinks.toast.unmatchedRefreshFailed'))
toast.error(t('settings.reader.koreader.hashLinks.toast.reviewRefreshFailed'))
}
}
@@ -499,10 +499,10 @@ async function handleDismissUnmatchedBook() {
dismissingHash.value = dismissConfirmBook.value.hash
try {
await dismissUnmatchedBook(dismissConfirmBook.value.hash)
toast.success(t('settings.reader.koreader.hashLinks.toast.unmatchedDismissed'))
toast.success(t('settings.reader.koreader.hashLinks.toast.reviewDismissed'))
handleCloseDismiss()
} catch (e) {
toast.error(e instanceof Error ? e.message : t('settings.reader.koreader.hashLinks.toast.dismissFailed'))
toast.error(e instanceof Error ? e.message : t('settings.reader.koreader.hashLinks.toast.reviewDismissFailed'))
} finally {
dismissingHash.value = null
}
@@ -521,14 +521,14 @@ async function handleDismissAllUnmatchedBooks() {
try {
const result = await dismissAllUnmatchedBooks()
toast.success(
t('settings.reader.koreader.hashLinks.toast.allDismissed', {
t('settings.reader.koreader.hashLinks.toast.reviewAllDismissed', {
count: result.count,
}),
)
dismissAllConfirmOpen.value = false
unmatchedPage.value = 1
} catch (e) {
toast.error(e instanceof Error ? e.message : t('settings.reader.koreader.hashLinks.toast.dismissAllFailed'))
toast.error(e instanceof Error ? e.message : t('settings.reader.koreader.hashLinks.toast.reviewDismissAllFailed'))
} finally {
dismissingAll.value = false
}
@@ -1152,7 +1152,7 @@ async function handleDownloadPlugin() {
<div class="mb-8">
<div class="mb-2 flex items-center justify-between gap-3">
<p class="settings-group-label mb-0">
{{ t('settings.reader.koreader.hashLinks.unmatchedTitle') }}
{{ t('settings.reader.koreader.hashLinks.reviewTitle') }}
</p>
<div class="flex items-center gap-2">
<Button
@@ -1171,12 +1171,13 @@ async function handleDownloadPlugin() {
</Button>
</div>
</div>
<p class="settings-hint mb-3">{{ t('settings.reader.koreader.hashLinks.reviewHint') }}</p>
<div class="settings-card">
<div v-if="unmatchedLoading" class="px-4 py-5 bg-card text-sm text-muted-foreground md:px-5">
{{ t('settings.reader.koreader.hashLinks.loadingUnmatched') }}
{{ t('settings.reader.koreader.hashLinks.loadingReview') }}
</div>
<div v-else-if="unmatchedBooks.length === 0" class="px-4 py-5 bg-card text-sm text-muted-foreground md:px-5">
{{ t('settings.reader.koreader.hashLinks.noUnmatched') }}
{{ t('settings.reader.koreader.hashLinks.noReview') }}
</div>
<template v-else>
<div v-for="book in pagedUnmatchedBooks" :key="book.hash" class="px-4 py-4 bg-card md:px-5">
@@ -1469,7 +1470,7 @@ async function handleDownloadPlugin() {
</p>
<p class="mt-1 text-sm text-muted-foreground">
{{
t('settings.reader.koreader.hashLinks.unlinkConfirmBody', {
t('settings.reader.koreader.hashLinks.manualUnlinkConfirmBody', {
title: linkedBookTitle(unlinkConfirmLink),
})
}}
@@ -1544,7 +1545,7 @@ async function handleDownloadPlugin() {
}}
</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ t('settings.reader.koreader.hashLinks.dismissConfirmBody') }}
{{ t('settings.reader.koreader.hashLinks.reviewDismissConfirmBody') }}
</p>
<div class="mt-4 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<p class="font-mono text-foreground truncate">
@@ -1575,7 +1576,7 @@ async function handleDownloadPlugin() {
<div class="relative w-full rounded-t-lg border border-border bg-card p-4 shadow-xl md:max-w-md md:rounded-lg md:p-5">
<p class="text-base font-semibold text-foreground">
{{
t('settings.reader.koreader.hashLinks.dismissAllConfirmTitle', {
t('settings.reader.koreader.hashLinks.reviewDismissAllConfirmTitle', {
count: unmatchedBooks.length,
})
}}
@@ -1583,7 +1584,7 @@ async function handleDownloadPlugin() {
<div class="mt-2 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertTriangle :size="14" class="mt-0.5 shrink-0" />
<p>
{{ t('settings.reader.koreader.hashLinks.dismissAllConfirmBody') }}
{{ t('settings.reader.koreader.hashLinks.reviewDismissAllConfirmBody') }}
</p>
</div>
<div class="mt-4 flex items-center justify-end gap-2">
@@ -329,7 +329,7 @@ describe('KoreaderSettings', () => {
'Setup',
'Devices',
'Plugin Activity',
'Unmatched KOReader Books',
'KOReader Books to Review',
'Manual KOReader Links',
'Setup Guide',
'Danger Zone',
@@ -346,7 +346,8 @@ describe('KoreaderSettings', () => {
expect(wrapper.text()).toContain('Update available')
expect(wrapper.text()).toContain('latest plugin v1.6.0')
expect(wrapper.text()).toContain('Matched books')
expect(wrapper.text()).toContain('No unmatched KOReader books.')
expect(wrapper.text()).toContain('No KOReader books to review.')
expect(wrapper.text()).toContain('Unlinked books can still sync through an automatic match.')
expect(wrapper.text()).toContain('No manual KOReader links.')
expect(wrapper.text()).toContain('2 deleted highlights awaiting KOReader plugin acknowledgement.')
expect(wrapper.text()).toContain('3 highlight positions need attention.')
@@ -677,8 +678,8 @@ describe('KoreaderSettings', () => {
await buttonByText(wrapper, 'Dismiss all')!.trigger('click')
expect(wrapper.text()).toContain('Dismiss all 2 unmatched books?')
expect(wrapper.text()).toContain('clears your entire unmatched books list')
expect(wrapper.text()).toContain('Dismiss all 2 books to review?')
expect(wrapper.text()).toContain('clears your entire books to review list')
const dismissAllButtons = wrapper.findAll('button').filter((button) => button.text() === 'Dismiss all')
await dismissAllButtons[dismissAllButtons.length - 1]!.trigger('click')
@@ -700,7 +701,7 @@ describe('KoreaderSettings', () => {
await buttonByText(wrapper, 'Cancel')!.trigger('click')
expect(koreaderMock.dismissAllUnmatchedBooks).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('clears your entire unmatched books list')
expect(wrapper.text()).not.toContain('clears your entire books to review list')
})
it('keeps the confirm dialog open and reports an error when dismissing all unmatched books fails', async () => {
@@ -719,7 +720,7 @@ describe('KoreaderSettings', () => {
await flushPromises()
expect(koreaderMock.dismissAllUnmatchedBooks).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('clears your entire unmatched books list')
expect(wrapper.text()).toContain('clears your entire books to review list')
})
it('loads more BookOrbit search results from the link dialog', async () => {
@@ -778,6 +779,7 @@ describe('KoreaderSettings', () => {
await buttonByText(wrapper, 'Unlink')!.trigger('click')
expect(wrapper.text()).toContain('Unlink KOReader book?')
expect(wrapper.text()).toContain('Future syncs will use an automatic match if one exists.')
expect(wrapper.text()).toContain('Already synced stats will stay on their current BookOrbit book.')
const unlinkButtons = wrapper.findAll('button').filter((button) => button.text() === 'Unlink')
+16 -2
View File
@@ -2886,7 +2886,13 @@
"unmatchedDismissed": "Unmatched book dismissed",
"dismissFailed": "Failed to dismiss unmatched book",
"allDismissed": "{count, plural, =0 {Dismissed no unmatched books} one {Dismissed # unmatched book} other {Dismissed # unmatched books}}",
"dismissAllFailed": "Failed to dismiss all unmatched books"
"dismissAllFailed": "Failed to dismiss all unmatched books",
"reviewRefreshed": "Books to review refreshed",
"reviewRefreshFailed": "Failed to refresh books to review",
"reviewDismissed": "Book dismissed from review",
"reviewDismissFailed": "Failed to dismiss book from review",
"reviewAllDismissed": "{count, plural, =0 {Dismissed no books from review} one {Dismissed # book from review} other {Dismissed # books from review}}",
"reviewDismissAllFailed": "Failed to dismiss all books from review"
},
"modal": {
"linkTitle": "Link KOReader book",
@@ -2939,7 +2945,15 @@
"syncedStatsNote": "Already synced stats will stay on their current BookOrbit book.",
"chooseDifferent": "Choose different",
"saving": "Saving...",
"confirmLink": "Confirm link"
"confirmLink": "Confirm link",
"reviewTitle": "KOReader Books to Review",
"reviewHint": "Books without an automatic match and books you unlinked appear here. Unlinked books can still sync through an automatic match. Link a book to override that match, or dismiss it to remove it from this list.",
"loadingReview": "Loading books to review...",
"noReview": "No KOReader books to review.",
"manualUnlinkConfirmBody": "Remove the manual link to {title} and return this book to the review list. Future syncs will use an automatic match if one exists. Already synced stats will stay on their current BookOrbit book.",
"reviewDismissConfirmBody": "This removes it from your books to review. Automatic matching and existing sync data are kept. If a device reports the hash again without a match, it can reappear.",
"reviewDismissAllConfirmTitle": "{count, plural, =0 {No books to review} one {Dismiss all # book to review?} other {Dismiss all # books to review?}}",
"reviewDismissAllConfirmBody": "This clears your entire books to review list, including entries on other pages. Automatic matching and existing sync data are kept. If a device reports a hash again without a match, it can reappear."
},
"remove": "Remove",
"removing": "Removing...",