fix(koreader): keep manual links to a different copy matched and suggest links

Report a linked copy whose bytes differ from the library file as positionIncompatible instead of
unmatched, so the plugin keeps the match, skips highlights without a failure and still syncs progress.
Treat a position-less place within a page as the same place and jump to the nearest page.
Suggest library books with the same title and authors for books to review, with bulk linking.
See docs/KOREADER_HASH_MATCHING.md.

Closes #1872
Closes #1919
This commit is contained in:
neon
2026-10-26 15:06:04 +00:00
parent 834da8c954
commit 1c27e5dd05
36 changed files with 1634 additions and 63 deletions
@@ -76,6 +76,7 @@ function makeManualHashLink(overrides: Partial<KoreaderManualHashLink> = {}): Ko
koreaderTitle: 'KOReader Title',
koreaderAuthors: 'Device Author',
koreaderLastOpen: 1700000001,
positionCompatible: true,
createdAt: '2026-06-03T00:00:00.000Z',
updatedAt: '2026-06-04T00:00:00.000Z',
...overrides,
@@ -441,6 +442,72 @@ describe('useKoreaderSync', () => {
expect(manualHashLinks.value).toEqual(manualLinks)
})
it('fetchLinkSuggestions stores suggestions and treats any failure as none', async () => {
const suggestions = [{ hash: 'a'.repeat(32), candidates: [], moreCandidates: false }]
apiMock
.mockResolvedValueOnce(makeResponse(suggestions))
.mockResolvedValueOnce(makeResponse({ message: 'Not Found' }, { ok: false, status: 404 }))
.mockRejectedValueOnce(new Error('offline'))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { linkSuggestions, fetchLinkSuggestions } = useKoreaderSync()
await fetchLinkSuggestions()
expect(apiMock).toHaveBeenCalledWith('/api/v1/koreader/unmatched-books/suggestions')
expect(linkSuggestions.value).toEqual(suggestions)
await fetchLinkSuggestions()
expect(linkSuggestions.value).toEqual([])
linkSuggestions.value = suggestions
await expect(fetchLinkSuggestions()).resolves.toBeUndefined()
expect(linkSuggestions.value).toEqual([])
})
it('linkSuggestedBooks links each pair, keeps failures listed, and refreshes once', async () => {
const [hashA, hashB, hashC] = ['a'.repeat(32), 'b'.repeat(32), 'c'.repeat(32)]
apiMock.mockImplementation(async (input) => {
const url = String(input)
if (url.endsWith(`${hashB}/link`)) return makeResponse({ message: 'conflict' }, { ok: false, status: 409 })
if (url.endsWith(`${hashC}/link`)) throw new Error('offline')
if (url.endsWith('/link')) return makeResponse({ hash: hashA, bookId: 1, bookFileId: 2 })
if (url.endsWith('/sync-status')) return makeResponse(makeSyncStatus())
return makeResponse([])
})
const { useKoreaderSync } = await import('../useKoreaderSync')
const { unmatchedBooks, linkSuggestions, linkSuggestedBooks } = useKoreaderSync()
unmatchedBooks.value = [makeUnmatchedBook({ hash: hashA }), makeUnmatchedBook({ hash: hashB }), makeUnmatchedBook({ hash: hashC })]
linkSuggestions.value = [hashA, hashB, hashC].map((hash) => ({ hash, candidates: [], moreCandidates: false }))
const outcome = await linkSuggestedBooks([
{ hash: hashA, bookId: 1 },
{ hash: hashB, bookId: 2 },
{ hash: hashC, bookId: 3 },
])
expect(outcome.linked).toBe(1)
expect([...outcome.failed].sort()).toEqual([hashB, hashC])
expect(unmatchedBooks.value.map((book) => book.hash)).toEqual([hashB, hashC])
expect(linkSuggestions.value.map((suggestion) => suggestion.hash)).toEqual([hashB, hashC])
const urls = apiMock.mock.calls.map(([input]) => String(input))
expect(urls.filter((url) => url.endsWith('/link'))).toHaveLength(3)
expect(urls.filter((url) => url.endsWith('/sync-status'))).toHaveLength(1)
expect(urls.filter((url) => url.endsWith('/hash-links'))).toHaveLength(1)
const [, request] = apiMock.mock.calls.find(([input]) => String(input).endsWith(`${hashA}/link`)) as [string, RequestInit]
expect(JSON.parse(String(request.body))).toEqual({ bookId: 1 })
})
it('linkSuggestedBooks skips the refresh when nothing linked', async () => {
apiMock.mockResolvedValue(makeResponse({ message: 'gone' }, { ok: false, status: 404 }))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { linkSuggestedBooks } = useKoreaderSync()
await expect(linkSuggestedBooks([{ hash: 'a'.repeat(32), bookId: 1 }])).resolves.toEqual({ linked: 0, failed: ['a'.repeat(32)] })
expect(apiMock).toHaveBeenCalledTimes(1)
})
it('linkUnmatchedBook throws the server message on failure', async () => {
apiMock.mockResolvedValueOnce(makeResponse({ message: 'KOReader hash already matches a different book' }, { ok: false, status: 409 }))
@@ -8,6 +8,7 @@ import type {
KoreaderManualHashLink,
KoreaderSyncStatus,
KoreaderUnmatchedBook,
KoreaderUnmatchedBookSuggestion,
LinkKoreaderUnmatchedBookPayload,
LinkKoreaderUnmatchedBookResult,
UnlinkKoreaderManualHashLinkResult,
@@ -19,6 +20,7 @@ const credentials = ref<KoreaderCredentials | null>(null)
const syncStatus = ref<KoreaderSyncStatus | null>(null)
const unmatchedBooks = ref<KoreaderUnmatchedBook[]>([])
const manualHashLinks = ref<KoreaderManualHashLink[]>([])
const linkSuggestions = ref<KoreaderUnmatchedBookSuggestion[]>([])
const loading = ref(false)
const unmatchedLoading = ref(false)
const manualLinksLoading = ref(false)
@@ -33,6 +35,13 @@ export class KoreaderFileNamingRequestError extends Error {
}
}
const SUGGESTED_LINK_CONCURRENCY = 3
export interface SuggestedLinkOutcome {
linked: number
failed: string[]
}
export function useKoreaderSync() {
async function fetchSyncStatus(silent = false): Promise<void> {
if (!silent) loading.value = true
@@ -79,6 +88,7 @@ export function useKoreaderSync() {
syncStatus.value = null
unmatchedBooks.value = []
manualHashLinks.value = []
linkSuggestions.value = []
}
async function testConnection(username: string, password: string): Promise<boolean> {
@@ -121,6 +131,21 @@ export function useKoreaderSync() {
}
}
// Suggestions are optional help: a server without the route, or a failed request, just shows none.
async function fetchLinkSuggestions(): Promise<void> {
try {
const res = await api('/api/v1/koreader/unmatched-books/suggestions')
linkSuggestions.value = res.ok ? await res.json() : []
} catch {
linkSuggestions.value = []
}
}
function forgetUnmatched(hashes: ReadonlySet<string>): void {
unmatchedBooks.value = unmatchedBooks.value.filter((book) => !hashes.has(book.hash))
linkSuggestions.value = linkSuggestions.value.filter((suggestion) => !hashes.has(suggestion.hash))
}
async function fetchManualHashLinks(): Promise<void> {
manualLinksLoading.value = true
try {
@@ -146,11 +171,41 @@ export function useKoreaderSync() {
throw new Error(body.message || 'Failed to link KOReader book')
}
const result: LinkKoreaderUnmatchedBookResult = await res.json()
unmatchedBooks.value = unmatchedBooks.value.filter((book) => book.hash !== result.hash)
forgetUnmatched(new Set([result.hash]))
await Promise.all([fetchSyncStatus(true), fetchManualHashLinks()])
return result
}
/**
* Links each confirmed suggestion through the same route as a manual link, a few at a time, and
* refreshes the dependent lists once at the end. Returns how many linked and which hashes failed.
*/
async function linkSuggestedBooks(links: readonly { hash: string; bookId: number }[]): Promise<SuggestedLinkOutcome> {
const linked = new Set<string>()
const failed: string[] = []
let next = 0
async function worker(): Promise<void> {
while (next < links.length) {
const link = links[next++]!
try {
const res = await api(`/api/v1/koreader/unmatched-books/${link.hash}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookId: link.bookId }),
})
if (res.ok) linked.add(link.hash)
else failed.push(link.hash)
} catch {
failed.push(link.hash)
}
}
}
await Promise.all(Array.from({ length: Math.min(SUGGESTED_LINK_CONCURRENCY, links.length) }, worker))
forgetUnmatched(linked)
if (linked.size > 0) await Promise.all([fetchSyncStatus(true), fetchManualHashLinks()])
return { linked: linked.size, failed }
}
async function dismissUnmatchedBook(hash: string): Promise<DismissKoreaderUnmatchedBookResult> {
const res = await api(`/api/v1/koreader/unmatched-books/${hash}`, { method: 'DELETE' })
if (!res.ok) {
@@ -158,7 +213,7 @@ export function useKoreaderSync() {
throw new Error(body.message || 'Failed to dismiss KOReader unmatched book')
}
const result: DismissKoreaderUnmatchedBookResult = await res.json()
unmatchedBooks.value = unmatchedBooks.value.filter((book) => book.hash !== result.hash)
forgetUnmatched(new Set([result.hash]))
await fetchSyncStatus(true)
return result
}
@@ -171,6 +226,7 @@ export function useKoreaderSync() {
}
const result: DismissAllKoreaderUnmatchedBooksResult = await res.json()
unmatchedBooks.value = []
linkSuggestions.value = []
await fetchSyncStatus(true)
return result
}
@@ -198,7 +254,7 @@ export function useKoreaderSync() {
}
const result: UnlinkKoreaderManualHashLinkResult = await res.json()
manualHashLinks.value = manualHashLinks.value.filter((link) => link.hash !== result.hash)
await Promise.all([fetchSyncStatus(true), fetchUnmatchedBooks()])
await Promise.all([fetchSyncStatus(true), fetchUnmatchedBooks(), fetchLinkSuggestions()])
return result
}
@@ -265,6 +321,7 @@ export function useKoreaderSync() {
syncStatus,
unmatchedBooks,
manualHashLinks,
linkSuggestions,
loading,
unmatchedLoading,
manualLinksLoading,
@@ -272,6 +329,7 @@ export function useKoreaderSync() {
fetchSyncStatus,
fetchUnmatchedBooks,
fetchManualHashLinks,
fetchLinkSuggestions,
createCredentials,
updateCredentials,
deleteCredentials,
@@ -279,6 +337,7 @@ export function useKoreaderSync() {
getSyncUrl,
downloadPluginPackage,
linkUnmatchedBook,
linkSuggestedBooks,
dismissUnmatchedBook,
dismissAllUnmatchedBooks,
relinkManualHashLink,
+236 -20
View File
@@ -7,6 +7,7 @@ import { formatDate as formatLocaleDate } from '@/i18n/formatters'
import {
AlertTriangle,
Archive,
ArrowRight,
ArchiveRestore,
BookOpen,
Calendar,
@@ -17,8 +18,10 @@ import {
ChevronUp,
Copy,
Download,
ExternalLink,
Eye,
EyeOff,
Info,
KeyRound,
Library,
Link2,
@@ -34,10 +37,13 @@ import type {
BookCard,
KoreaderDeviceInfo,
KoreaderDeviceSweepInfo,
KoreaderLinkSuggestionCandidate,
KoreaderManualHashLink,
KoreaderUnmatchedBook,
KoreaderUnmatchedBookSuggestion,
UpdateKoreaderCredentialsPayload,
} from '@bookorbit/types'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
import SettingsTabs from './components/SettingsTabs.vue'
import KoreaderCredentialsDialog from './KoreaderCredentialsDialog.vue'
import KoreaderDeliverySettings from './KoreaderDeliverySettings.vue'
@@ -108,18 +114,21 @@ const {
syncStatus,
unmatchedBooks,
manualHashLinks,
linkSuggestions,
loading,
unmatchedLoading,
manualLinksLoading,
fetchSyncStatus,
fetchUnmatchedBooks,
fetchManualHashLinks,
fetchLinkSuggestions,
createCredentials,
updateCredentials,
deleteCredentials,
getSyncUrl,
downloadPluginPackage,
linkUnmatchedBook,
linkSuggestedBooks,
dismissUnmatchedBook,
dismissAllUnmatchedBooks,
relinkManualHashLink,
@@ -144,7 +153,10 @@ const selectedUnmatched = ref<KoreaderUnmatchedBook | null>(null)
const selectedManualLink = ref<KoreaderManualHashLink | null>(null)
const linkSearchQuery = ref('')
const linkingBookId = ref<number | null>(null)
const pendingLinkTarget = ref<BookCard | null>(null)
type LinkTarget = { id: number; title: string | null; detail: string }
const pendingLinkTarget = ref<LinkTarget | null>(null)
const bulkLinkOpen = ref(false)
const bulkLinking = ref(false)
const unlinkConfirmLink = ref<KoreaderManualHashLink | null>(null)
const unlinkingHash = ref<string | null>(null)
const removeDeviceConfirmTarget = ref<KoreaderDeviceInfo | null>(null)
@@ -217,6 +229,13 @@ const pagedUnmatchedBooks = computed(() => {
const unmatchedPageStart = computed(() => (unmatchedBooks.value.length === 0 ? 0 : (clampedUnmatchedPage.value - 1) * unmatchedPageSize + 1))
const unmatchedPageEnd = computed(() => Math.min(clampedUnmatchedPage.value * unmatchedPageSize, unmatchedBooks.value.length))
const showUnmatchedPager = computed(() => unmatchedBooks.value.length > unmatchedPageSize)
const pagedReviewRows = computed(() =>
pagedUnmatchedBooks.value.map((book) => {
const suggestion = suggestionFor(book)
const single = suggestion && isSingleSuggestion(suggestion) ? suggestion.candidates[0]! : null
return { book, suggestion, single, matchesLabel: suggestion && !single ? possibleMatchesLabel(suggestion) : null }
}),
)
const hasPluginActivity = computed(
() =>
sweeps.value.length > 0 ||
@@ -245,6 +264,19 @@ const selectedLinkLastOpen = computed(() => selectedUnmatched.value?.lastOpen ??
const linkDialogTitle = computed(() =>
selectedManualLink.value ? t('settings.reader.koreader.hashLinks.changeLinkTitle') : t('settings.reader.koreader.hashLinks.linkBookTitle'),
)
const suggestionsByHash = computed(() => new Map(linkSuggestions.value.map((suggestion) => [suggestion.hash, suggestion])))
const selectedSuggestion = computed(() => (selectedUnmatched.value ? (suggestionsByHash.value.get(selectedUnmatched.value.hash) ?? null) : null))
const selectedSuggestionLabel = computed(() => {
const suggestion = selectedSuggestion.value
if (!suggestion) return ''
return isSingleSuggestion(suggestion) ? t('settings.reader.koreader.hashLinks.suggestedMatch') : possibleMatchesLabel(suggestion)
})
const singleSuggestionLinks = computed(() =>
unmatchedBooks.value.flatMap((book) => {
const suggestion = suggestionsByHash.value.get(book.hash)
return suggestion && isSingleSuggestion(suggestion) ? [{ book, candidate: suggestion.candidates[0]! }] : []
}),
)
watch(unmatchedTotalPages, (totalPages) => {
if (unmatchedPage.value > totalPages) unmatchedPage.value = totalPages
@@ -295,8 +327,18 @@ function unmatchedBookTitle(book: KoreaderUnmatchedBook): string {
)
}
// KOReader reports several authors on separate lines.
function koreaderAuthorsLabel(authors: string | null | undefined): string {
return (authors ?? '')
.split('\n')
.map((name) => name.trim())
.filter(Boolean)
.join(', ')
}
function unmatchedBookSubtitle(book: KoreaderUnmatchedBook): string {
if (book.authors?.trim()) return book.authors
const authors = koreaderAuthorsLabel(book.authors)
if (authors) return authors
return book.title?.trim() ? t('settings.reader.koreader.hashLinks.unknownAuthor') : t('settings.reader.koreader.hashLinks.noTitleOrAuthor')
}
@@ -310,7 +352,8 @@ function manualLinkTitle(link: KoreaderManualHashLink): string {
}
function manualLinkSubtitle(link: KoreaderManualHashLink): string {
if (link.koreaderAuthors?.trim()) return link.koreaderAuthors
const authors = koreaderAuthorsLabel(link.koreaderAuthors)
if (authors) return authors
return link.koreaderTitle?.trim() ? t('settings.reader.koreader.hashLinks.unknownAuthor') : t('settings.reader.koreader.hashLinks.noTitleOrAuthor')
}
@@ -339,7 +382,7 @@ function pluginUpdateClass(sweep: KoreaderDeviceSweepInfo): string {
onMounted(async () => {
try {
await fetchSyncStatus()
if (credentials.value) await Promise.all([fetchUnmatchedBooks(), fetchManualHashLinks()])
if (credentials.value) await Promise.all([fetchUnmatchedBooks(), fetchManualHashLinks(), fetchLinkSuggestions()])
} catch (e) {
error.value = e instanceof Error ? e.message : t('settings.reader.koreader.loadFailed')
}
@@ -464,7 +507,7 @@ async function handleCopySyncUrl() {
async function handleRefresh() {
try {
await fetchSyncStatus(true)
if (credentials.value) await Promise.all([fetchUnmatchedBooks(), fetchManualHashLinks()])
if (credentials.value) await Promise.all([fetchUnmatchedBooks(), fetchManualHashLinks(), fetchLinkSuggestions()])
toast.success(t('settings.reader.koreader.statusRefreshed'))
} catch {
toast.error(t('settings.reader.koreader.refreshFailed'))
@@ -473,7 +516,7 @@ async function handleRefresh() {
async function handleRefreshUnmatched() {
try {
await fetchUnmatchedBooks()
await Promise.all([fetchUnmatchedBooks(), fetchLinkSuggestions()])
unmatchedPage.value = 1
toast.success(t('settings.reader.koreader.hashLinks.toast.reviewRefreshed'))
} catch {
@@ -573,7 +616,80 @@ function handleCloseLink() {
}
function handleChooseLinkTarget(book: BookCard) {
pendingLinkTarget.value = book
pendingLinkTarget.value = { id: book.id, title: book.title ?? null, detail: bookSearchSubtitle(book) }
}
function handleChooseSuggestedTarget(candidate: KoreaderLinkSuggestionCandidate) {
pendingLinkTarget.value = { id: candidate.bookId, title: candidateTitle(candidate), detail: candidateDetail(candidate) }
}
function suggestionFor(book: KoreaderUnmatchedBook): KoreaderUnmatchedBookSuggestion | null {
return suggestionsByHash.value.get(book.hash) ?? null
}
function isSingleSuggestion(suggestion: KoreaderUnmatchedBookSuggestion): boolean {
return suggestion.candidates.length === 1 && !suggestion.moreCandidates
}
function possibleMatchesLabel(suggestion: KoreaderUnmatchedBookSuggestion): string {
const count = suggestion.candidates.length
return suggestion.moreCandidates
? t('settings.reader.koreader.hashLinks.morePossibleMatches', { count })
: t('settings.reader.koreader.hashLinks.possibleMatches', { count })
}
function candidateTitle(candidate: KoreaderLinkSuggestionCandidate): string {
const title = candidate.title?.trim()
if (!title) return t('settings.reader.koreader.hashLinks.untitledBook')
const subtitle = candidate.subtitle?.trim()
return subtitle ? `${title}: ${subtitle}` : title
}
function candidateDetail(candidate: KoreaderLinkSuggestionCandidate): string {
const authors = candidate.authors.length > 0 ? candidate.authors.join(', ') : t('settings.reader.koreader.hashLinks.unknownAuthor')
return [authors, candidate.format, candidate.libraryName, candidate.publishedYear, candidate.language]
.filter((part) => part !== null && part !== '')
.join(' - ')
}
function candidateHref(candidate: KoreaderLinkSuggestionCandidate): string {
return `${import.meta.env.BASE_URL}book/${candidate.bookId}`
}
function handleOpenSuggestion(book: KoreaderUnmatchedBook) {
handleOpenLink(book)
const suggestion = suggestionFor(book)
if (suggestion && isSingleSuggestion(suggestion)) handleChooseSuggestedTarget(suggestion.candidates[0]!)
}
function handleOpenBulkLink() {
bulkLinkOpen.value = true
}
function handleCancelBulkLink() {
if (!bulkLinking.value) bulkLinkOpen.value = false
}
async function handleConfirmBulkLink() {
const links = singleSuggestionLinks.value.map(({ book, candidate }) => ({ hash: book.hash, bookId: candidate.bookId }))
if (links.length === 0) return
bulkLinking.value = true
try {
const outcome = await linkSuggestedBooks(links)
if (outcome.failed.length === 0) {
toast.success(t('settings.reader.koreader.hashLinks.toast.suggestedLinked', { count: outcome.linked }))
} else if (outcome.linked > 0) {
toast.error(t('settings.reader.koreader.hashLinks.toast.suggestedPartlyLinked', { linked: outcome.linked, failed: outcome.failed.length }))
} else {
toast.error(t('settings.reader.koreader.hashLinks.toast.suggestedLinkFailed'))
}
bulkLinkOpen.value = false
unmatchedPage.value = 1
} catch {
toast.error(t('settings.reader.koreader.hashLinks.toast.suggestedLinkFailed'))
} finally {
bulkLinking.value = false
}
}
function handleClearLinkTarget() {
@@ -1210,7 +1326,17 @@ async function handleDownloadPlugin() {
<p class="settings-group-label mb-0">
{{ t('settings.reader.koreader.hashLinks.reviewTitle') }}
</p>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center justify-end gap-2">
<Button
v-if="singleSuggestionLinks.length > 0"
size="sm"
:disabled="unmatchedLoading || bulkLinking"
@click="handleOpenBulkLink"
type="button"
>
<Link2 :size="12" />
{{ t('settings.reader.koreader.hashLinks.linkSuggested', { count: singleSuggestionLinks.length }) }}
</Button>
<Button
v-if="unmatchedBooks.length > 0"
variant="destructive-outline"
@@ -1236,40 +1362,56 @@ async function handleDownloadPlugin() {
{{ 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">
<div v-for="row in pagedReviewRows" :key="row.book.hash" class="px-4 py-4 bg-card md:px-5">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<div class="min-w-0 flex-1">
<p class="settings-label truncate">
{{ unmatchedBookTitle(book) }}
{{ unmatchedBookTitle(row.book) }}
</p>
<p class="settings-hint truncate">
{{ unmatchedBookSubtitle(book) }}
{{ unmatchedBookSubtitle(row.book) }}
</p>
<div class="mt-2 grid gap-1 text-xs text-muted-foreground sm:grid-cols-2">
<p class="min-w-0 truncate">
{{ t('settings.reader.koreader.hashLinks.hash') }}
<span class="font-mono text-foreground">{{ book.hash }}</span>
<span class="font-mono text-foreground">{{ row.book.hash }}</span>
</p>
<p>
{{ t('settings.reader.koreader.hashLinks.lastOpened') }}
<span class="text-foreground">{{ formatEpochSeconds(book.lastOpen) }}</span>
<span class="text-foreground">{{ formatEpochSeconds(row.book.lastOpen) }}</span>
</p>
<p>
{{ t('settings.reader.koreader.hashLinks.firstSeen') }}
<span class="text-foreground">{{ formatDateTime(book.firstSeenAt) }}</span>
<span class="text-foreground">{{ formatDateTime(row.book.firstSeenAt) }}</span>
</p>
<p>
{{ t('settings.reader.koreader.hashLinks.lastSeen') }}
<span class="text-foreground">{{ formatDateTime(book.lastSeenAt) }}</span>
<span class="text-foreground">{{ formatDateTime(row.book.lastSeenAt) }}</span>
</p>
</div>
<div v-if="row.suggestion" class="mt-3 min-w-0 rounded-md border border-border bg-muted/30 px-3 py-2">
<p class="text-xs font-medium text-muted-foreground">
{{ row.single ? t('settings.reader.koreader.hashLinks.suggestedMatch') : row.matchesLabel }}
</p>
<template v-if="row.single">
<p class="mt-0.5 truncate text-sm font-medium text-foreground">{{ candidateTitle(row.single) }}</p>
<p class="truncate text-xs text-muted-foreground">{{ candidateDetail(row.single) }}</p>
</template>
<p v-else class="mt-0.5 text-xs text-muted-foreground">
{{ t('settings.reader.koreader.hashLinks.possibleMatchesHint') }}
</p>
</div>
</div>
<div class="flex gap-2 self-start md:self-auto">
<Button size="sm" @click="handleOpenLink(book)" type="button">
<div class="flex flex-wrap gap-2 self-start md:shrink-0 md:flex-nowrap md:self-auto">
<Button v-if="row.suggestion" size="sm" @click="handleOpenSuggestion(row.book)" type="button">
<Link2 :size="12" />
{{ row.single ? t('settings.reader.koreader.hashLinks.reviewMatch') : t('settings.reader.koreader.hashLinks.chooseMatch') }}
</Button>
<Button :variant="row.suggestion ? 'outline' : 'default'" size="sm" @click="handleOpenLink(row.book)" type="button">
<Link2 :size="12" />
{{ t('settings.reader.koreader.hashLinks.link') }}
</Button>
<Button variant="destructive-outline" size="sm" @click="handleOpenDismiss(book)">
<Button variant="destructive-outline" size="sm" @click="handleOpenDismiss(row.book)">
<Trash2 :size="12" />
{{ t('settings.reader.koreader.hashLinks.dismiss') }}
</Button>
@@ -1355,6 +1497,10 @@ async function handleDownloadPlugin() {
{{ t('settings.reader.koreader.hashLinks.lastOpened') }}
<span class="text-foreground">{{ formatEpochSeconds(link.koreaderLastOpen) }}</span>
</p>
<p v-if="link.positionCompatible === false" class="flex gap-1.5">
<Info :size="12" class="mt-0.5 shrink-0" aria-hidden="true" />
<span>{{ t('settings.reader.koreader.hashLinks.differentCopyHint') }}</span>
</p>
</div>
</div>
<div class="min-w-0">
@@ -1675,6 +1821,42 @@ async function handleDownloadPlugin() {
<X :size="16" />
</button>
</div>
<div v-if="selectedSuggestion" class="mt-4">
<p class="text-xs font-medium text-muted-foreground">{{ selectedSuggestionLabel }}</p>
<p class="mt-0.5 mb-1.5 text-xs text-muted-foreground">{{ t('settings.reader.koreader.hashLinks.possibleMatchesHint') }}</p>
<div class="max-h-48 overflow-y-auto rounded-md border border-border divide-y divide-border">
<div v-for="candidate in selectedSuggestion.candidates" :key="candidate.bookId" class="flex items-center gap-1 pe-2">
<button
type="button"
class="flex min-w-0 flex-1 items-start justify-between gap-3 px-3 py-3 text-left hover:bg-muted/70 disabled:opacity-60"
:disabled="linkingBookId !== null"
@click="handleChooseSuggestedTarget(candidate)"
>
<span class="min-w-0">
<span class="block truncate text-sm font-medium text-foreground">{{ candidateTitle(candidate) }}</span>
<span class="block truncate text-xs text-muted-foreground">{{ candidateDetail(candidate) }}</span>
</span>
<span class="shrink-0 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground">
{{
pendingLinkTarget?.id === candidate.bookId
? t('settings.reader.koreader.hashLinks.selected')
: t('settings.reader.koreader.hashLinks.select')
}}
</span>
</button>
<a
:href="candidateHref(candidate)"
target="_blank"
rel="noopener"
class="shrink-0 rounded-md p-2 text-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:aria-label="t('settings.reader.koreader.hashLinks.openCandidate', { title: candidateTitle(candidate) })"
:title="t('settings.reader.koreader.hashLinks.openCandidate', { title: candidateTitle(candidate) })"
>
<ExternalLink :size="14" aria-hidden="true" />
</a>
</div>
</div>
</div>
<div class="mt-4">
<label class="block text-xs font-medium text-muted-foreground mb-1.5">{{ t('settings.reader.koreader.hashLinks.bookOrbitBook') }}</label>
<div class="relative">
@@ -1760,7 +1942,7 @@ async function handleDownloadPlugin() {
{{ pendingLinkTarget.title ?? t('settings.reader.koreader.hashLinks.untitledBook') }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ bookSearchSubtitle(pendingLinkTarget) }}
{{ pendingLinkTarget.detail }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{
@@ -1777,6 +1959,12 @@ async function handleDownloadPlugin() {
{{ t('settings.reader.koreader.hashLinks.syncedStatsNote') }}
</p>
</div>
<div class="mt-2 flex gap-2 rounded-md border border-border bg-background px-3 py-2 text-xs text-muted-foreground">
<Info :size="14" class="mt-0.5 shrink-0" aria-hidden="true" />
<p>
{{ t('settings.reader.koreader.hashLinks.copyNote') }}
</p>
</div>
<div class="mt-3 flex items-center justify-end gap-2">
<Button variant="outline" size="sm" :disabled="linkingBookId !== null" @click="handleClearLinkTarget">
{{ t('settings.reader.koreader.hashLinks.chooseDifferent') }}
@@ -1792,6 +1980,34 @@ async function handleDownloadPlugin() {
</div>
</div>
</div>
<ConfirmDialog
:open="bulkLinkOpen"
:title="t('settings.reader.koreader.hashLinks.bulkLinkTitle', { count: singleSuggestionLinks.length })"
:description="t('settings.reader.koreader.hashLinks.bulkLinkDescription')"
:confirm-label="t('settings.reader.koreader.hashLinks.bulkLinkConfirm', { count: singleSuggestionLinks.length })"
:busy="bulkLinking"
:destructive="false"
:confirm-disabled="singleSuggestionLinks.length === 0"
@confirm="handleConfirmBulkLink"
@cancel="handleCancelBulkLink"
>
<ul
class="mt-3 max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border"
:aria-label="t('settings.reader.koreader.hashLinks.bulkLinkListLabel')"
>
<li v-for="entry in singleSuggestionLinks" :key="entry.book.hash" class="min-w-0 px-3 py-2">
<p class="truncate text-sm font-medium text-foreground">{{ unmatchedBookTitle(entry.book) }}</p>
<p class="truncate text-xs text-muted-foreground">{{ unmatchedBookSubtitle(entry.book) }}</p>
<p class="mt-1 flex min-w-0 items-center gap-1.5 text-sm text-foreground">
<ArrowRight :size="12" class="shrink-0 rtl:rotate-180" aria-hidden="true" />
<span class="truncate">{{ candidateTitle(entry.candidate) }}</span>
</p>
<p class="truncate ps-[18px] text-xs text-muted-foreground">{{ candidateDetail(entry.candidate) }}</p>
</li>
</ul>
<p class="mt-3 text-xs text-muted-foreground">{{ t('settings.reader.koreader.hashLinks.copyNote') }}</p>
</ConfirmDialog>
</template>
</div>
@@ -1,7 +1,15 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import KoreaderSettings from '../KoreaderSettings.vue'
import type { BookCard, KoreaderCredentials, KoreaderManualHashLink, KoreaderSyncStatus, KoreaderUnmatchedBook } from '@bookorbit/types'
import type {
BookCard,
KoreaderCredentials,
KoreaderLinkSuggestionCandidate,
KoreaderManualHashLink,
KoreaderSyncStatus,
KoreaderUnmatchedBook,
KoreaderUnmatchedBookSuggestion,
} from '@bookorbit/types'
import { copyToClipboard } from '@/lib/clipboard'
import { toast } from 'vue-sonner'
@@ -24,18 +32,21 @@ const koreaderMock = vi.hoisted(() => ({
syncStatus: { __v_isRef: true, value: null as KoreaderSyncStatus | null },
unmatchedBooks: { __v_isRef: true, value: [] as KoreaderUnmatchedBook[] },
manualHashLinks: { __v_isRef: true, value: [] as KoreaderManualHashLink[] },
linkSuggestions: { __v_isRef: true, value: [] as KoreaderUnmatchedBookSuggestion[] },
loading: { __v_isRef: true, value: false },
unmatchedLoading: { __v_isRef: true, value: false },
manualLinksLoading: { __v_isRef: true, value: false },
fetchSyncStatus: vi.fn<() => Promise<void>>(),
fetchUnmatchedBooks: vi.fn<() => Promise<void>>(),
fetchManualHashLinks: vi.fn<() => Promise<void>>(),
fetchLinkSuggestions: vi.fn<() => Promise<void>>(),
createCredentials: vi.fn<() => Promise<void>>(),
updateCredentials: vi.fn<() => Promise<void>>(),
deleteCredentials: vi.fn<() => Promise<void>>(),
getSyncUrl: vi.fn<() => string>(),
downloadPluginPackage: vi.fn<() => Promise<void>>(),
linkUnmatchedBook: vi.fn<() => Promise<void>>(),
linkSuggestedBooks: vi.fn<(links: { hash: string; bookId: number }[]) => Promise<{ linked: number; failed: string[] }>>(),
dismissUnmatchedBook: vi.fn<() => Promise<void>>(),
dismissAllUnmatchedBooks: vi.fn<() => Promise<{ count: number }>>(),
relinkManualHashLink: vi.fn<() => Promise<void>>(),
@@ -164,12 +175,31 @@ function makeManualHashLink(overrides: Partial<KoreaderManualHashLink> = {}): Ko
koreaderTitle: 'Linked KOReader Title',
koreaderAuthors: 'Device Author',
koreaderLastOpen: 1700000001,
positionCompatible: true,
createdAt: '2026-06-03T00:00:00.000Z',
updatedAt: '2026-06-04T00:00:00.000Z',
...overrides,
}
}
function makeCandidate(overrides: Partial<KoreaderLinkSuggestionCandidate> = {}): KoreaderLinkSuggestionCandidate {
return {
bookId: 91,
title: 'Suggested Title',
subtitle: null,
authors: ['Device Author'],
format: 'epub',
language: 'en',
publishedYear: 2020,
libraryName: 'Novels',
...overrides,
}
}
function makeSuggestion(overrides: Partial<KoreaderUnmatchedBookSuggestion> = {}): KoreaderUnmatchedBookSuggestion {
return { hash: 'a'.repeat(32), candidates: [makeCandidate()], moreCandidates: false, ...overrides }
}
function makeBookCard(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 55,
@@ -224,12 +254,15 @@ describe('KoreaderSettings', () => {
koreaderMock.syncStatus.value = null
koreaderMock.unmatchedBooks.value = []
koreaderMock.manualHashLinks.value = []
koreaderMock.linkSuggestions.value = []
koreaderMock.loading.value = false
koreaderMock.unmatchedLoading.value = false
koreaderMock.manualLinksLoading.value = false
koreaderMock.fetchSyncStatus.mockResolvedValue(undefined)
koreaderMock.fetchUnmatchedBooks.mockResolvedValue(undefined)
koreaderMock.fetchManualHashLinks.mockResolvedValue(undefined)
koreaderMock.fetchLinkSuggestions.mockResolvedValue(undefined)
koreaderMock.linkSuggestedBooks.mockResolvedValue({ linked: 0, failed: [] })
koreaderMock.createCredentials.mockResolvedValue(undefined)
koreaderMock.updateCredentials.mockResolvedValue(undefined)
koreaderMock.deleteCredentials.mockResolvedValue(undefined)
@@ -634,6 +667,230 @@ describe('KoreaderSettings', () => {
expect(koreaderMock.linkUnmatchedBook).toHaveBeenCalledWith('a'.repeat(32), { bookId: 55 })
})
it('says what a link syncs before it is confirmed', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
searchMock.results.value = [makeBookCard()]
const wrapper = mountComponent()
await flushPromises()
await buttonByText(wrapper, 'Link')!.trigger('click')
await wrapper
.findAll('button')
.find((button) => button.text().includes('BookOrbit Title'))!
.trigger('click')
expect(wrapper.text()).toContain('This KOReader copy is a different file from the one in BookOrbit.')
expect(wrapper.text()).toContain('Highlights and bookmarks will not.')
})
it('loads link suggestions with the review list', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
mountComponent()
await flushPromises()
expect(koreaderMock.fetchLinkSuggestions).toHaveBeenCalledTimes(1)
})
it('shows a single suggested match and opens it ready to confirm', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
koreaderMock.linkSuggestions.value = [makeSuggestion({ candidates: [makeCandidate({ subtitle: 'A Novel' })] })]
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('Suggested match')
expect(wrapper.text()).toContain('Suggested Title: A Novel')
expect(wrapper.text()).toContain('Device Author - epub - Novels - 2020 - en')
await buttonByText(wrapper, 'Review match')!.trigger('click')
expect(wrapper.text()).toContain('Confirm KOReader link')
expect(wrapper.text()).toContain('Book ID: 91')
await buttonByText(wrapper, 'Confirm link')!.trigger('click')
await flushPromises()
expect(koreaderMock.linkUnmatchedBook).toHaveBeenCalledWith('a'.repeat(32), { bookId: 91 })
})
it('lists every possible match without choosing one', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
koreaderMock.linkSuggestions.value = [
makeSuggestion({
candidates: [makeCandidate({ bookId: 91 }), makeCandidate({ bookId: 92, title: 'Suggested Title', subtitle: 'Second edition' })],
}),
]
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('2 possible matches')
expect(wrapper.text()).not.toContain('Suggested match')
expect(buttonByText(wrapper, 'Link suggested')).toBeUndefined()
await buttonByText(wrapper, 'Choose match')!.trigger('click')
expect(wrapper.text()).toContain('Suggested Title: Second edition')
expect(wrapper.text()).not.toContain('Confirm KOReader link')
await wrapper
.findAll('button')
.find((button) => button.text().includes('Second edition'))!
.trigger('click')
await buttonByText(wrapper, 'Confirm link')!.trigger('click')
await flushPromises()
expect(koreaderMock.linkUnmatchedBook).toHaveBeenCalledWith('a'.repeat(32), { bookId: 92 })
})
it('opens each possible match in a new tab so editions can be checked', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
koreaderMock.linkSuggestions.value = [makeSuggestion({ candidates: [makeCandidate({ bookId: 91 }), makeCandidate({ bookId: 92 })] })]
const wrapper = mountComponent()
await flushPromises()
await buttonByText(wrapper, 'Choose match')!.trigger('click')
const links = wrapper.findAll('a[target="_blank"]')
expect(links.map((link) => link.attributes('href'))).toEqual(['/book/91', '/book/92'])
expect(links[0]!.attributes('rel')).toBe('noopener')
expect(links[0]!.attributes('aria-label')).toBe('Open Suggested Title in a new tab')
})
it('shows KOReader authors reported on separate lines as a list', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook({ authors: 'Ada Tester\nBo Sample' })]
koreaderMock.manualHashLinks.value = [makeManualHashLink({ koreaderAuthors: 'Cy Third\n Di Fourth ' })]
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('Ada Tester, Bo Sample')
expect(wrapper.text()).toContain('Cy Third, Di Fourth')
})
it('never offers a truncated match list as a single suggestion', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
koreaderMock.linkSuggestions.value = [makeSuggestion({ moreCandidates: true })]
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('More than 1 possible match')
expect(buttonByText(wrapper, 'Link suggested')).toBeUndefined()
await buttonByText(wrapper, 'Choose match')!.trigger('click')
expect(wrapper.text()).not.toContain('Confirm KOReader link')
})
it('ignores suggestions for books no longer under review', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook({ hash: 'c'.repeat(32) })]
koreaderMock.linkSuggestions.value = [makeSuggestion()]
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).not.toContain('Suggested match')
expect(buttonByText(wrapper, 'Link suggested')).toBeUndefined()
})
it('links every single suggestion after listing them for confirmation', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 3 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [
makeUnmatchedBook({ hash: 'a'.repeat(32), title: 'First Device Book' }),
makeUnmatchedBook({ hash: 'c'.repeat(32), title: 'Second Device Book' }),
makeUnmatchedBook({ hash: 'd'.repeat(32), title: 'Ambiguous Device Book' }),
]
koreaderMock.linkSuggestions.value = [
makeSuggestion({ hash: 'a'.repeat(32), candidates: [makeCandidate({ bookId: 91, title: 'First Library Book' })] }),
makeSuggestion({ hash: 'c'.repeat(32), candidates: [makeCandidate({ bookId: 93, title: 'Second Library Book' })] }),
makeSuggestion({ hash: 'd'.repeat(32), candidates: [makeCandidate({ bookId: 94 }), makeCandidate({ bookId: 95 })] }),
]
koreaderMock.linkSuggestedBooks.mockResolvedValue({ linked: 2, failed: [] })
const wrapper = mount(KoreaderSettings, { props: { embedded: true }, attachTo: document.body })
await flushPromises()
await buttonByText(wrapper, 'Link suggested (2)')!.trigger('click')
await flushPromises()
const dialog = document.body.querySelector('[role="dialog"]')!
expect(dialog.textContent).toContain('Link 2 suggested books?')
expect(dialog.textContent).toContain('First Device Book')
expect(dialog.textContent).toContain('First Library Book')
expect(dialog.textContent).toContain('Second Library Book')
expect(dialog.textContent).not.toContain('Ambiguous Device Book')
expect(dialog.textContent).toContain('This KOReader copy is a different file from the one in BookOrbit.')
const confirm = [...dialog.querySelectorAll('button')].find((button) => button.textContent?.includes('Link 2 books'))!
confirm.click()
await flushPromises()
expect(koreaderMock.linkSuggestedBooks).toHaveBeenCalledWith([
{ hash: 'a'.repeat(32), bookId: 91 },
{ hash: 'c'.repeat(32), bookId: 93 },
])
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('Linked 2 suggested books')
wrapper.unmount()
})
it('reports suggested links that could not be made', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 2 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook({ hash: 'a'.repeat(32) }), makeUnmatchedBook({ hash: 'c'.repeat(32) })]
koreaderMock.linkSuggestions.value = [makeSuggestion({ hash: 'a'.repeat(32) }), makeSuggestion({ hash: 'c'.repeat(32) })]
koreaderMock.linkSuggestedBooks.mockResolvedValue({ linked: 1, failed: ['c'.repeat(32)] })
const wrapper = mount(KoreaderSettings, { props: { embedded: true }, attachTo: document.body })
await flushPromises()
await buttonByText(wrapper, 'Link suggested (2)')!.trigger('click')
await flushPromises()
const dialog = document.body.querySelector('[role="dialog"]')!
;[...dialog.querySelectorAll('button')].find((button) => button.textContent?.includes('Link 2 books'))!.click()
await flushPromises()
expect(vi.mocked(toast.error)).toHaveBeenCalledWith('Linked 1 suggested book. 1 could not be linked and stays in the list.')
wrapper.unmount()
})
it('marks manual links to a different copy with what they sync', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.manualHashLinks.value = [
makeManualHashLink({ hash: 'b'.repeat(32), positionCompatible: false }),
makeManualHashLink({ hash: 'c'.repeat(32), koreaderTitle: 'Exact Copy', positionCompatible: true }),
]
const wrapper = mountComponent()
await flushPromises()
const hint = "Different file from the one in BookOrbit: progress syncs as a percentage, and highlights and bookmarks don't sync."
expect(wrapper.text().split(hint)).toHaveLength(2)
})
it('confirms before dismissing an unmatched KOReader book', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
+19 -2
View File
@@ -3577,7 +3577,10 @@
"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"
"reviewDismissAllFailed": "Failed to dismiss all books from review",
"suggestedLinked": "{count, plural, one {Linked # suggested book} other {Linked # suggested books}}",
"suggestedPartlyLinked": "{linked, plural, one {Linked # suggested book.} other {Linked # suggested books.}} {failed, plural, one {# could not be linked and stays in the list.} other {# could not be linked and stay in the list.}}",
"suggestedLinkFailed": "Could not link the suggested books"
},
"modal": {
"linkTitle": "Link KOReader book",
@@ -3638,7 +3641,21 @@
"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."
"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.",
"copyNote": "This KOReader copy is a different file from the one in BookOrbit. Reading status, rating, reading time and progress (as a percentage) will sync. Highlights and bookmarks will not. To sync everything, download the book to KOReader from BookOrbit.",
"differentCopyHint": "Different file from the one in BookOrbit: progress syncs as a percentage, and highlights and bookmarks don't sync.",
"suggestedMatch": "Suggested match",
"possibleMatches": "{count, plural, one {# possible match} other {# possible matches}}",
"morePossibleMatches": "{count, plural, one {More than # possible match} other {More than # possible matches}}",
"possibleMatchesHint": "Books in your library with the same title and authors. Check the edition before you link.",
"reviewMatch": "Review match",
"chooseMatch": "Choose match",
"linkSuggested": "Link suggested ({count})",
"bulkLinkTitle": "{count, plural, one {Link # suggested book?} other {Link # suggested books?}}",
"bulkLinkDescription": "Each KOReader book below has the same title and authors as exactly one book in your library. Check the list before linking.",
"bulkLinkConfirm": "{count, plural, one {Link # book} other {Link # books}}",
"bulkLinkListLabel": "Suggested links",
"openCandidate": "Open {title} in a new tab"
},
"remove": "Remove",
"removing": "Removing...",
+24
View File
@@ -16,6 +16,30 @@ An explicit manual link takes precedence over automatic hash matches. Removing i
The existing API routes and response fields remain compatible. The file-timestamp component of the match-cache token uses a global indexed lookup, so another library's file changes can trigger extra match refreshes. History revisions and manual links remain scoped to the user's access. Manifest pagination continues across token changes.
## Links to a different copy
A manual link usually points a device copy at a library file with different bytes, such as a book
calibre sent to the device with its own metadata written in. Positions in that copy cannot be placed
in the library file, so the link syncs reading status, rating, reading time and progress as a
percentage, but not highlights, bookmarks or exact positions. Match-check marks such a match with
`positionCompatible: false`, and the highlight and bookmark exchanges list it under
`positionIncompatible`, never under `unmatched`: plugins read `unmatched` as "not in your library" and
drop the match. Plugin 2.0.0 ignores both fields, keeps the match and counts its skipped highlights as
uploaded; later plugins skip the exchange and say why.
A percentage-only place lands on a page boundary, so the plugin treats a remote place without a
position as the same place when it is within one page, and in page mode jumps to the nearest page
rather than the page below.
## Link suggestions
`GET /koreader/unmatched-books/suggestions` offers library books whose title and set of authors equal
a linkable book to review's, compared after folding only case, Unicode compatibility forms and
whitespace. It is read only and scoped like the rest of the user's library: accessible libraries,
content filters, present books, and a primary file that is not audio, because a link targets the
primary file. A title shared by two or more books, or by more books than are checked, is never offered
as a single suggestion. Nothing links until the user confirms through the ordinary link route.
## Historical recovery limit
The one-time backfill recovers delivered identities from retained KEPUB cache artifacts whose source identity is still verifiable. It cannot recover pre-fix temporary audioless outputs, over-limit conversions, or other delivered variants whose bytes have already been deleted. Those hashes need a manual link or another download. New deliveries use registration and durable retry instead of depending on retained artifacts.
@@ -717,6 +717,12 @@ local function hasPending(to_apply)
return to_apply and (#(to_apply.add or {}) > 0 or #(to_apply.edit or {}) > 0 or #(to_apply.delete or {}) > 0)
end
-- The server matches a copy whose bytes differ from the library file but cannot
-- place its highlights, so nothing is exchanged and nothing failed.
local function incompatibleResult()
return { uploaded = 0, applied = 0, deleted = 0, failed = 0, unverified = 0, had_errors = false, incompatible = 1 }
end
--[[--
Full annotation exchange for one matched book. Blocking; callers run it from a
step machine or a scheduled task.
@@ -735,11 +741,13 @@ opts:
- file: book file path (required for apply_mode "sidecar")
Returns a result table { uploaded, applied, deleted, failed, had_errors } or
nil, err for auth/network level failures.
nil, err for auth/network level failures. A position-incompatible copy returns
a result with `incompatible = 1` and exchanges nothing.
]]
function BookOrbitAnnotations.exchangeBook(opts)
local book = opts.state:getBook(opts.digest)
if not book then return nil, "unmatched" end
if book.positionIncompatible then return incompatibleResult() end
local keys = BookOrbitAnnotations.collectKeys(opts.key_annotations or opts.annotations)
local keys_complete = opts.keys_complete ~= false and #keys <= MAX_KEYS_PER_BOOK
@@ -806,6 +814,12 @@ function BookOrbitAnnotations.exchangeBook(opts)
return nil, "unmatched"
end
end
for _, hash in ipairs(body.positionIncompatible or {}) do
if hash == opts.digest then
opts.state:markPositionIncompatible(opts.digest)
return incompatibleResult()
end
end
response = body.results and body.results[1] or nil
result.uploaded = result.uploaded + #chunk
first_request = false
@@ -68,6 +68,7 @@ function BookSheet.data(plugin)
author = props.authors or "",
digest = digest,
matched = book ~= nil,
incompatible = book ~= nil and book.positionIncompatible == true,
book_id = book and book.bookId or nil,
synced = mark and MenuUI.ago(mark) or nil,
local_pct = local_pct,
@@ -171,7 +172,8 @@ local function promptBody(page, w)
table.insert(items, space(nil, px(10)))
table.insert(items, hstack({ w = w, gap = px(8), valign = "top" }, {
icon("info", px(16), { sw = 2.25 }),
text(_("Highlights, bookmarks and reading time sync either way."), { s = 13, col = INK.g3, w = w - px(24), lines = 2 }),
text(d.incompatible and _("Reading time syncs either way. Highlights and bookmarks don't sync for this copy.")
or _("Highlights, bookmarks and reading time sync either way."), { s = 13, col = INK.g3, w = w - px(24), lines = 2 }),
}))
end
table.insert(items, space(nil, px(8)))
@@ -224,6 +226,17 @@ local function body(page, w)
})
end
-- A matched copy whose bytes differ from the library file syncs progress as a
-- percentage and nothing that needs an exact position.
local copy_note
if d.incompatible then
copy_note = hstack({ w = w, gap = px(6), valign = "top" }, {
icon("info", px(16), { sw = 2 }),
text(_("This copy is a different file from the one in BookOrbit. Progress syncs as a percentage. Highlights and bookmarks don't sync. Download the book from BookOrbit to sync everything."),
{ s = 13, col = INK.g3, w = w - px(16) - px(6), lines = 4 }),
})
end
local rows = {
MenuUI.row(w, { icon = "refresh-cw", label = _("Auto-sync"), sub = _("For every book"), toggle = d.auto, action = { kind = "auto" } }),
}
@@ -249,6 +262,8 @@ local function body(page, w)
buttons,
outcome and space(nil, px(10)) or false,
outcome or false,
copy_note and space(nil, px(10)) or false,
copy_note or false,
space(nil, px(14)),
vstack({ w = w }, list),
}
@@ -340,7 +340,7 @@ stepMatch = function(ctx)
for _, match in ipairs(body.matches or {}) do
if match.hash == ctx.snap.digest then
ctx.state:setMatched(match.hash, match.bookFileId, match.bookId, ctx.snap.file)
ctx.state:setMatched(match.hash, match.bookFileId, match.bookId, ctx.snap.file, nil, match.positionCompatible)
bindStatsRow(ctx, ctx.state:getBook(ctx.snap.digest))
if not acknowledge(ctx, "match") then return end
return step(ctx, stepStats)
@@ -561,7 +561,8 @@ stepAnnotations = function(ctx)
closed_book = apply_mode == "sidecar",
})
if not result.had_errors then
ctx.sidecar_ann_done = true
-- An incompatible copy exchanged nothing, so its sidecar is not marked as known.
if not result.incompatible then ctx.sidecar_ann_done = true end
book.annCount = ctx.snap.ann_count
-- Only park remote changes when the user actually wants them applied;
-- with two-way highlights off there is nothing to come back for.
@@ -716,7 +717,11 @@ stepBookmarks = function(ctx)
return step(ctx, stepState)
end
if result.had_errors then ctx.had_errors = true else ctx.sidecar_bm_done = true end
if result.had_errors then
ctx.had_errors = true
elseif not result.incompatible then
ctx.sidecar_bm_done = true
end
ctx.counts.bookmarks = (ctx.counts.bookmarks or 0) + result.uploaded
ctx.highlight_summary = BookOrbitHighlightSummary.addBookmarks(ctx.highlight_summary, result)
if not result.had_errors and result.remote_pending and ctx.on_remote_pending then
@@ -283,6 +283,12 @@ local function hasPending(to_apply)
return to_apply and (#(to_apply.add or {}) > 0 or #(to_apply.delete or {}) > 0)
end
-- The server matches a copy whose bytes differ from the library file but cannot
-- place its bookmarks, so nothing is exchanged and nothing failed.
local function incompatibleResult()
return { uploaded = 0, applied = 0, deleted = 0, failed = 0, had_errors = false, incompatible = 1 }
end
--[[--
Full bookmark exchange for one matched book. Blocking; callers run it from a
step machine or a scheduled task.
@@ -303,6 +309,7 @@ nil, err for auth/network level failures.
function BookOrbitBookmarks.exchangeBook(opts)
local book = opts.state:getBook(opts.digest)
if not book then return nil, "unmatched" end
if book.positionIncompatible then return incompatibleResult() end
local bookmarks = opts.bookmarks or {}
local keys = BookOrbitBookmarks.collectKeys(opts.key_bookmarks or bookmarks)
@@ -350,6 +357,12 @@ function BookOrbitBookmarks.exchangeBook(opts)
return nil, "unmatched"
end
end
for _, hash in ipairs(body.positionIncompatible or {}) do
if hash == opts.digest then
opts.state:markPositionIncompatible(opts.digest)
return incompatibleResult()
end
end
response = body.results and body.results[1] or nil
result.uploaded = result.uploaded + #chunk
first_request = false
@@ -18,6 +18,9 @@ function HighlightDiagnostics.lastSyncText(summary, format_time)
local text = T(_("%1 uploaded, %2 applied, %3 deleted, %4 failed, %5 skipped"),
count(summary.uploaded), count(summary.applied), count(summary.deleted),
count(summary.failed), count(summary.skipped))
if count(summary.incompatible) > 0 then
text = T(_("%1, %2 not synced (different copy)"), text, count(summary.incompatible))
end
local at = tonumber(summary.at)
if at and at > 0 then
local when = format_time and format_time(at) or os.date("%H:%M", at)
@@ -60,6 +63,9 @@ function HighlightDiagnostics.openHighlightsText(ctx)
local pending = ctx.scheduler_status
local matched = ctx.matched == true
if matched and ctx.position_incompatible == true then
return _("not synced: this copy differs from BookOrbit's file")
end
if type(pending) == "table" then
if matched then
return _("matched, sync scheduled")
@@ -17,6 +17,9 @@ local COUNT_FIELDS = {
"failed",
"skipped",
"touched_books",
-- Books whose device copy differs from the library file: matched, but
-- highlights and bookmarks cannot be placed in it, so none were exchanged.
"incompatible",
}
-- Bookmarks are counted separately from highlights: they are a different user
@@ -142,6 +145,9 @@ function HighlightSummary.message(summary)
if summary.touched_books > 0 then
text = text .. "\n" .. T(_("%1 closed book(s) updated."), summary.touched_books)
end
if summary.incompatible > 0 then
text = text .. "\n" .. T(_("%1 book(s) skipped: the copy on this device is a different file from the one in BookOrbit."), summary.incompatible)
end
return text
end
@@ -153,6 +153,7 @@ function MainMenu:diagnosticsSections()
annotation_sync = self.settings.annotation_sync,
has_open_book = has_open_book,
matched = self.isOpenBookMatched and self:isOpenBookMatched(digest) or false,
position_incompatible = self.isOpenBookPositionIncompatible and self:isOpenBookPositionIncompatible(digest) or false,
scheduler_status = scheduler,
last_highlight_sync = self.settings.last_highlight_sync,
} },
@@ -113,9 +113,19 @@ function ProgressSync:syncToProgress(progress, percentage_fallback)
logger.dbg("BookOrbit: syncing to progress", progress, "percent", percentage_fallback)
local has_position = progress ~= nil and progress ~= ""
if not has_position and percentage_fallback then
-- No exact position (push came from web/audio sync); jump by percentage.
-- No exact position (web or audio sync, or a different copy of the
-- book); jump by percentage. In page mode KOReader reports a place as
-- page / pages but GotoPercent floors percent * pages, which lands one
-- page short of the page another device sent, so go to the nearest page.
local rolling = self.ui.rolling
local pages = self.ui.document.info and self.ui.document.info.number_of_pages
if rolling and rolling.view and rolling.view.view_mode == "page" and type(pages) == "number" and pages > 0 then
local page = math.max(1, math.min(pages, math.floor(percentage_fallback * pages + 0.5)))
self.ui:handleEvent(Event:new("GotoPage", page))
else
-- kosync percentage is 0-1; GotoPercent expects 0-100.
self.ui:handleEvent(Event:new("GotoPercent", percentage_fallback * 100))
end
if self.book_sheet then self.book_sheet:refreshProgress() end
return
end
@@ -127,6 +137,23 @@ function ProgressSync:syncToProgress(progress, percentage_fallback)
if self.book_sheet then self.book_sheet:refreshProgress() end
end
-- A place BookOrbit holds without a position (a different copy of the book, or
-- a web or audio place it could not convert) is reachable only by percentage,
-- which lands on a page boundary. Within one page of it is the same place; an
-- exact comparison would offer a jump to where the reader already is on every
-- sync.
function ProgressSync:isSamePlace(body, local_percentage, local_progress)
if local_percentage == body.percentage or body.progress == local_progress then return true end
if body.progress ~= nil and body.progress ~= "" then return false end
local doc = self.ui and self.ui.document
local pages = doc and doc.getPageCount and doc:getPageCount()
if type(pages) ~= "number" or pages <= 0 or type(local_percentage) ~= "number" or type(body.percentage) ~= "number" then
return false
end
-- Plus the 4-decimal rounding both percentages go through on the way.
return math.abs(body.percentage - local_percentage) <= 1 / pages + 0.0001
end
function ProgressSync:remoteProgressIsNewer(body, local_percentage)
local local_timestamp = self.last_page_turn_timestamp or 0
if body.timestamp ~= nil then
@@ -206,7 +233,7 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
local local_progress = self:getLastProgress()
local local_percentage = self:getLastPercent()
if local_percentage == body.percentage or body.progress == local_progress then
if self:isSamePlace(body, local_percentage, local_progress) then
self:noteProgressCompared(digest)
on_done(false)
return true
@@ -416,7 +443,7 @@ function ProgressSync:getProgress(ensure_networking, interactive)
local progress = self:getLastProgress()
local percentage = self:getLastPercent()
if percentage == body.percentage or body.progress == progress then
if self:isSamePlace(body, percentage, progress) then
self:noteProgressCompared(digest)
if interactive then
Toast.show(_("Already in sync"))
@@ -140,8 +140,10 @@ end
-- folder merge re-parents a file to another book without changing its id. The
-- token the match was verified against is stored alongside the time so one
-- library change does not force a request on every later book sync until the
-- user runs a full sweep.
function BookOrbitState:setMatched(md5, book_file_id, book_id, file, verified_version)
-- user runs a full sweep. `position_compatible` is the match's positionCompatible
-- field: false marks a copy whose bytes differ from the library file, which the
-- server matches but cannot place highlights or exact positions in.
function BookOrbitState:setMatched(md5, book_file_id, book_id, file, verified_version, position_compatible)
local previous = self.books[md5]
local identity_changed = previous ~= nil
and (previous.fileId ~= book_file_id or previous.bookId ~= book_id)
@@ -154,6 +156,7 @@ function BookOrbitState:setMatched(md5, book_file_id, book_id, file, verified_ve
book.annCount = book.annCount or 0
book.matchVerifiedAt = os.time()
book.matchVerifiedVersion = verified_version or (self.global and self.global.libraryVersion) or nil
book.positionIncompatible = position_compatible == false or nil
self.books[md5] = book
self.unmatched[md5] = nil
end
@@ -201,6 +204,14 @@ function BookOrbitState.applyLibraryVersion(state, version, now)
return changed
end
-- A highlight or bookmark exchange that learns the copy is position
-- incompatible records it here, so later exchanges skip the book until the next
-- match check says otherwise.
function BookOrbitState:markPositionIncompatible(md5)
local book = self.books[md5]
if book then book.positionIncompatible = true end
end
function BookOrbitState:setUnmatched(md5)
self.books[md5] = nil
self.unmatched[md5] = os.time()
@@ -690,7 +690,7 @@ local function stepMatchNext(ctx)
-- stored, so these books stay on the fast path after the sweep records
-- a changed library version.
ctx.state:setMatched(match.hash, match.bookFileId, match.bookId,
cand and cand.file or nil, body.libraryVersion)
cand and cand.file or nil, body.libraryVersion, match.positionCompatible)
end
for _, md5 in ipairs(batch) do
if not matched[md5] then
@@ -1063,7 +1063,8 @@ local function stepAnnotationsNext(ctx)
local bookmarks_done = exchangeBookmarksForEntry(ctx, entry, apply_mode, pending)
if aborted(ctx) then return end
if bookmarks_done and not result.had_errors and apply_mode == "sidecar" and entry.sidecar_marked == false
if bookmarks_done and not result.had_errors and not result.incompatible
and apply_mode == "sidecar" and entry.sidecar_marked == false
and BookOrbitSidecar.markFile(entry.file) and pending then
-- The marker is this sweep's own write; without this the next sweep would
-- see a changed sidecar and exchange the book again for nothing.
@@ -1283,7 +1284,11 @@ local function stepDone(ctx)
if ann_done and state_done and progress_done then
book.sidecarMtime = pending.mtime
book.annCount = pending.ann_count
-- A copy the server cannot place highlights in sent none, so its
-- watermark stays behind them for a later compatible match.
if not book.positionIncompatible then
BookOrbitAnnotations.advanceWatermark(book, pending.ann_max_datetime, device_now)
end
if state_done and pending.need_state then
if not pending.state_used_server then
book.statusSyncedModified = pending.status_modified or book.statusSyncedModified
+8 -3
View File
@@ -585,6 +585,11 @@ function BookOrbit:isOpenBookMatched(digest)
return BookOrbitStateManager.getBook(digest) ~= nil
end
function BookOrbit:isOpenBookPositionIncompatible(digest)
local book = digest and BookOrbitStateManager.getBook(digest) or nil
return book ~= nil and book.positionIncompatible == true
end
function BookOrbit:recordOpenAnnotationUnmatched(reason)
self:recordHighlightSync({
event = "open_book",
@@ -1072,7 +1077,7 @@ function BookOrbit:matchOpenBookForAutoSync(on_done)
local matched = false
for _, match in ipairs(body.matches or {}) do
if match.hash == digest then
state:setMatched(match.hash, match.bookFileId, match.bookId, self.ui.document.file)
state:setMatched(match.hash, match.bookFileId, match.bookId, self.ui.document.file, nil, match.positionCompatible)
matched = true
break
end
@@ -1143,10 +1148,10 @@ function BookOrbit:exchangeAnnotationsForOpenBook(reason, retry_count)
if bm_ok and bm_err == "unsupported_server" then
BookOrbitBookmarks.markUnsupported(client)
else
bm_done = bm_ok and bm_result ~= nil and not bm_result.had_errors
bm_done = bm_ok and bm_result ~= nil and not bm_result.had_errors and not bm_result.incompatible
end
end
if ok and result and not result.had_errors and bm_done then
if ok and result and not result.had_errors and not result.incompatible and bm_done then
BookOrbitSidecar.markLive(self.ui.doc_settings)
end
state:flush()
@@ -0,0 +1,200 @@
-- A matched copy whose bytes differ from the library file (a manual link to a
-- calibre-sent copy, say) keeps its match: highlight and bookmark exchanges skip
-- it without counting a failure, and the server's answer for it never reads as
-- "not in your library".
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
package.loaded["docsettings"] = {
open = function()
return {
readSetting = function() return nil end,
saveSetting = function() end,
makeTrue = function() end,
flush = function() end,
}
end,
hasSidecarFile = function() return false end,
findSidecarFile = function() return nil end,
}
package.loaded["datastorage"] = { getSettingsDir = function() return "/tmp" end }
package.loaded["luasettings"] = { open = function() return { readSetting = function(_, _, default) return default end } end }
package.loaded["dump"] = function() return "" end
package.loaded["ui/event"] = { new = function(_, name, payload) return { name = name, payload = payload } end }
package.loaded["ui/uimanager"] = { setDirty = function() end }
package.loaded["ui/widget/booklist"] = { setBookInfoCacheProperty = function() end }
package.loaded["logger"] = { dbg = function() end }
package.loaded["ffi/sha2"] = { md5 = function(value) return value end }
package.loaded["ffi/util"] = {
template = function(text, ...)
local values = { ... }
return (text:gsub("%%(%d+)", function(index) return tostring(values[tonumber(index)]) end))
end,
}
package.loaded["gettext"] = function(value) return value end
package.loaded["util"] = { trim = function(value) return tostring(value or ""):match("^%s*(.-)%s*$") end }
package.loaded["libs/libkoreader-lfs"] = { attributes = function() return nil end }
package.loaded["bookorbit_capabilities"] = {
supports = function() return true end,
markUnsupported = function() end,
}
local BookOrbitState = require("bookorbit_state")
local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitBookmarks = require("bookorbit_bookmarks")
local HighlightSummary = require("bookorbit_highlight_summary")
local Diagnostics = require("bookorbit_highlight_diagnostics")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)), 2)
end
end
local function newState()
return BookOrbitState.snapshot({ books = {}, unmatched = {}, files = {}, statsRows = {}, global = {} })
end
-- The match records the flag, a later compatible match clears it, and a new
-- server identity starts from a clean record.
local state = newState()
state:setMatched("digest", 10, 20, "/books/copy.epub", "v1", false)
assertEqual(state:getBook("digest").positionIncompatible, true, "an incompatible match is flagged")
state:setMatched("digest", 10, 20, "/books/copy.epub", "v1")
assertEqual(state:getBook("digest").positionIncompatible, nil, "a match without the field (older server, compatible copy) clears it")
state:setMatched("digest", 10, 20, "/books/copy.epub", "v1", false)
state:setMatched("digest", 11, 21, "/books/copy.epub", "v1", true)
assertEqual(state:getBook("digest").positionIncompatible, nil, "a different book or file is a fresh record")
state:markPositionIncompatible("digest")
assertEqual(state:getBook("digest").positionIncompatible, true, "an exchange can record what it learned")
state:markPositionIncompatible("unknown")
assertEqual(state:getBook("unknown"), nil, "marking an unmatched digest does not invent a match")
local function highlight(datetime, pos0)
return { drawer = "lighten", datetime = datetime, pos0 = pos0, pos1 = pos0 .. ".9", text = "text" }
end
-- A flagged book never reaches the server and reports nothing as failed.
local flagged = newState()
flagged:setMatched("digest", 10, 20, "/books/copy.epub", "v1", false)
local requests = 0
local silent_client = {
exchangeAnnotations = function() requests = requests + 1 end,
exchangeAck = function() requests = requests + 1 end,
exchangeBookmarks = function() requests = requests + 1 end,
exchangeBookmarksAck = function() requests = requests + 1 end,
}
local ann = BookOrbitAnnotations.exchangeBook({
client = silent_client,
state = flagged,
digest = "digest",
annotations = { highlight("2026-10-26 08:00:00", "/body/p[1]") },
ann_max_datetime = "2026-10-26 08:00:00",
ann_signature = "sig",
apply_mode = "sidecar",
file = "/books/copy.epub",
})
assertEqual(ann.incompatible, 1, "the highlight exchange reports the skip")
assertEqual(ann.had_errors, false, "a skip is not a failure")
assertEqual(ann.uploaded, 0, "nothing is claimed as uploaded")
local bm = BookOrbitBookmarks.exchangeBook({
client = silent_client,
state = flagged,
digest = "digest",
bookmarks = { { datetime = "2026-10-26 08:00:00", pos = "/body/p[2]", pageno = 2 } },
bm_signature = "sig",
apply_mode = "sidecar",
file = "/books/copy.epub",
})
assertEqual(bm.incompatible, 1, "the bookmark exchange reports the skip")
assertEqual(bm.had_errors, false, "a bookmark skip is not a failure")
assertEqual(requests, 0, "a flagged book sends no request")
assertEqual(flagged:getBook("digest").annWatermark, "", "the highlight watermark stays put for a later compatible exchange")
-- A book the local state still thinks is compatible learns it from the reply,
-- keeps its match and is not counted as failed.
local learning = newState()
learning:setMatched("digest", 10, 20, "/books/copy.epub", "v1")
local ann_reply = BookOrbitAnnotations.exchangeBook({
client = {
exchangeAnnotations = function() return { results = {}, unmatched = {}, positionIncompatible = { "digest" } } end,
exchangeAck = function() error("no ack for a skipped book") end,
},
state = learning,
digest = "digest",
annotations = { highlight("2026-10-26 08:00:00", "/body/p[1]") },
ann_max_datetime = "2026-10-26 08:00:00",
ann_signature = "sig",
apply_mode = "sidecar",
file = "/books/copy.epub",
})
assertEqual(ann_reply.incompatible, 1, "the reply turns into a skip")
assertEqual(ann_reply.uploaded, 0, "the rejected chunk is not counted as uploaded")
assertEqual(learning:getBook("digest") ~= nil, true, "the match survives")
assertEqual(learning.unmatched["digest"], nil, "the book is not recorded as unmatched")
assertEqual(learning:getBook("digest").positionIncompatible, true, "the flag is recorded for later exchanges")
assertEqual(learning:getBook("digest").annWatermark, "", "the watermark does not advance past highlights that never synced")
local bm_learning = newState()
bm_learning:setMatched("digest", 10, 20, "/books/copy.epub", "v1")
local bm_reply = BookOrbitBookmarks.exchangeBook({
client = {
exchangeBookmarks = function() return { results = {}, unmatched = {}, positionIncompatible = { "digest" } } end,
exchangeBookmarksAck = function() error("no ack for a skipped book") end,
},
state = bm_learning,
digest = "digest",
bookmarks = { { datetime = "2026-10-26 08:00:00", pos = "/body/p[2]", pageno = 2 } },
bm_signature = "sig",
apply_mode = "sidecar",
file = "/books/copy.epub",
})
assertEqual(bm_reply.incompatible, 1, "the bookmark reply turns into a skip")
assertEqual(bm_learning.unmatched["digest"], nil, "the bookmark reply keeps the match")
assertEqual(bm_learning:getBook("digest").positionIncompatible, true, "the bookmark reply records the flag")
-- A truly unmatched answer still drops the match, as before.
local dropping = newState()
dropping:setMatched("digest", 10, 20, "/books/copy.epub", "v1")
local dropped, dropped_err = BookOrbitAnnotations.exchangeBook({
client = { exchangeAnnotations = function() return { results = {}, unmatched = { "digest" } } end },
state = dropping,
digest = "digest",
annotations = {},
ann_signature = "sig",
apply_mode = "skip",
})
assertEqual(dropped, nil, "an unmatched answer has no result")
assertEqual(dropped_err, "unmatched", "and reports unmatched")
assertEqual(dropping:getBook("digest"), nil, "and drops the local match")
-- Summaries say what happened without calling it a failure.
local summary = HighlightSummary.add(nil, ann)
summary = HighlightSummary.addBookmarks(summary, bm)
assertEqual(summary.incompatible, 1, "one skipped book is counted once, not per exchange")
assertEqual(summary.failed, 0, "no highlight failure")
assertEqual(summary.bm_failed, 0, "no bookmark failure")
assertEqual(HighlightSummary.actionableError(summary, nil), nil, "no partial failure is reported")
assert(HighlightSummary.message(summary):find("different file from the one in BookOrbit", 1, true),
"the message names the reason")
assertEqual(HighlightSummary.normalize({}).incompatible, 0, "older persisted summaries read as zero")
assertEqual(Diagnostics.openHighlightsText{
annotation_sync = true,
has_open_book = true,
matched = true,
position_incompatible = true,
}, "not synced: this copy differs from BookOrbit's file", "diagnostics name the state")
assertEqual(Diagnostics.openHighlightsText{
annotation_sync = true,
has_open_book = true,
matched = true,
}, "matched, idle", "a compatible match reads as before")
assertEqual(Diagnostics.lastSyncText({ incompatible = 2, at = 0 }),
"0 uploaded, 0 applied, 0 deleted, 0 failed, 0 skipped, 2 not synced (different copy)",
"the last-sync row counts different copies apart from skips")
assertEqual(Diagnostics.lastSyncText({ uploaded = 1, at = 0 }),
"1 uploaded, 0 applied, 0 deleted, 0 failed, 0 skipped", "a compatible sync reads as before")
print("bookorbit_position_incompatible_test: ok")
@@ -0,0 +1,87 @@
-- A place BookOrbit holds without a position can only be reached by percentage,
-- which lands on a page boundary, so a jump there must not leave a prompt that
-- offers the same place again on the next sync.
local function read(path)
local file = assert(io.open(path))
local source = file:read("*a")
file:close()
return source
end
local source = read("koreader-plugin/bookorbit.koplugin/bookorbit_progress_sync.lua")
local same = assert(source:match("(function ProgressSync:isSamePlace.-\nend)\n"))
local chunk = assert(loadstring("local ProgressSync = {}\n" .. same .. "\nreturn ProgressSync"))
local ProgressSync = chunk()
local function plugin(pages)
local p = setmetatable({}, { __index = ProgressSync })
p.ui = { document = { getPageCount = function() return pages end } }
return p
end
local p = plugin(167)
-- 60% of 167 pages is page 100.2: the jump lands on page 100, 59.88%.
assert(p:isSamePlace({ percentage = 0.6, progress = nil }, 100 / 167, "/body/p[3]"),
"a percentage-only place within a page is where the reader already is")
assert(p:isSamePlace({ percentage = 0.6, progress = "" }, 100 / 167, "/body/p[3]"),
"an empty position counts as none")
-- Another device on page 135 (0.8083) reaches this copy on page 134 (0.8024).
assert(p:isSamePlace({ percentage = 0.8083, progress = nil }, 134 / 167, "/body/p[3]"),
"a percentage jump that rounds down to the page start is still the same place")
assert(not p:isSamePlace({ percentage = 0.62, progress = nil }, 100 / 167, "/body/p[3]"),
"more than a page away is a different place")
assert(not p:isSamePlace({ percentage = 0.6, progress = "/body/p[9]" }, 100 / 167, "/body/p[3]"),
"an exact position elsewhere is never rounded into this one")
assert(p:isSamePlace({ percentage = 0.4, progress = "/body/p[3]" }, 0.6, "/body/p[3]"),
"the same exact position is the same place whatever the percentages say")
assert(p:isSamePlace({ percentage = 0.5, progress = nil }, 0.5, nil),
"equal percentages are the same place, as before")
local unknown = plugin(nil)
assert(not unknown:isSamePlace({ percentage = 0.6, progress = nil }, 0.5988, "/body/p[3]"),
"without a page count only the exact comparison applies")
local closed = setmetatable({}, { __index = ProgressSync })
assert(not closed:isSamePlace({ percentage = 0.6, progress = nil }, 0.5988, "/body/p[3]"),
"without an open document only the exact comparison applies")
-- The jump itself: page mode goes to the page the other device reported.
local sync_to = assert(source:match("(function ProgressSync:syncToProgress.-\nend)\n"))
local function jumper(view_mode, pages, has_pages)
local events = {}
local env = setmetatable({
Event = { new = function(_, name, value) return { name = name, value = value } end },
logger = { dbg = function() end },
}, { __index = _G })
local loaded = assert(loadstring("local ProgressSync = {}\n" .. sync_to .. "\nreturn ProgressSync"))
setfenv(loaded, env)
local jp = setmetatable({}, { __index = loaded() })
jp.ui = {
handleEvent = function(_, event) table.insert(events, event) end,
document = { info = { number_of_pages = pages, has_pages = has_pages } },
rolling = not has_pages and { view = { view_mode = view_mode } } or nil,
}
return jp, events
end
local paged, paged_events = jumper("page", 167, false)
paged:syncToProgress(nil, 135 / 167)
assert(paged_events[1].name == "GotoPage" and paged_events[1].value == 135, "page mode lands on the reported page, not one short")
paged:syncToProgress("", 0)
assert(paged_events[2].value == 1, "the start of the book is page 1")
paged:syncToProgress(nil, 1)
assert(paged_events[3].value == 167, "the end of the book is the last page")
local scrolled, scrolled_events = jumper("scroll", 167, false)
scrolled:syncToProgress(nil, 0.6)
assert(scrolled_events[1].name == "GotoPercent" and math.abs(scrolled_events[1].value - 60) < 1e-9, "scroll mode keeps the offset jump")
local pdf, pdf_events = jumper(nil, 40, true)
pdf:syncToProgress(nil, 0.5)
assert(pdf_events[1].name == "GotoPercent", "a paged document keeps the percentage jump")
local exact, exact_events = jumper("page", 167, false)
exact:syncToProgress("/body/p[3]", 0.6)
assert(exact_events[1].name == "GotoXPointer", "an exact position still wins")
print("bookorbit_same_place_test: ok")
+25
View File
@@ -120,10 +120,35 @@ export interface KoreaderManualHashLink {
koreaderTitle: string | null;
koreaderAuthors: string | null;
koreaderLastOpen: number | null;
/**
* False when the device copy's bytes differ from the linked library file: reading status, rating, reading time
* and progress (as a percentage) sync; highlights, bookmarks and exact positions do not.
*/
positionCompatible: boolean;
createdAt: string;
updatedAt: string;
}
/** A library book whose title and authors equal an unmatched KOReader book's, offered for the user to confirm. */
export interface KoreaderLinkSuggestionCandidate {
bookId: number;
title: string | null;
subtitle: string | null;
authors: string[];
/** Format of the book's primary file, which is what a link targets. */
format: string | null;
language: string | null;
publishedYear: number | null;
libraryName: string | null;
}
export interface KoreaderUnmatchedBookSuggestion {
hash: string;
candidates: KoreaderLinkSuggestionCandidate[];
/** More library books share the title than were checked, so none of the candidates may be presented as the only match. */
moreCandidates: boolean;
}
export interface LinkKoreaderUnmatchedBookPayload {
bookId: number;
}
@@ -127,8 +127,9 @@ describe('KoreaderAnnotationExchangeService', () => {
books: [{ hash: HASH_A, applied: [], deleted: [] }],
});
expect(result).toEqual({ results: [], unmatched: [HASH_A] });
expect(ack).toEqual({ results: [], unmatched: [HASH_A] });
// Reported apart from `unmatched`: plugins drop the match of a book listed there.
expect(result).toEqual({ results: [], unmatched: [], positionIncompatible: [HASH_A] });
expect(ack).toEqual({ results: [], unmatched: [], positionIncompatible: [HASH_A] });
expect(annotationSync.ingestDeviceAnnotations).not.toHaveBeenCalled();
expect(annotationSync.applyExchangeAck).not.toHaveBeenCalled();
});
@@ -88,11 +88,17 @@ export interface ExchangeBookResult {
export interface ExchangeResponse {
results: ExchangeBookResult[];
unmatched: string[];
/**
* Matched books whose device copy differs from the library file, so its positions cannot be placed.
* Reported apart from `unmatched`, which plugins read as "not in your library" and act on.
*/
positionIncompatible: string[];
}
export interface ExchangeAckResponse {
results: { hash: string; acked: number }[];
unmatched: string[];
positionIncompatible: string[];
}
@Injectable()
@@ -125,15 +131,20 @@ export class KoreaderAnnotationExchangeService {
const results: ExchangeBookResult[] = [];
const unmatched: string[] = [];
const positionIncompatible: string[] = [];
let pushedTotal = 0;
for (const book of dto.books) {
const hash = book.hash.toLowerCase();
const match = matches.get(hash);
if (!match || match.progressCompatible === false) {
if (!match) {
unmatched.push(hash);
continue;
}
if (match.progressCompatible === false) {
positionIncompatible.push(hash);
continue;
}
const result = await this.exchangeBook(
user.id,
exchangeDeviceId(dto.deviceId, hash),
@@ -149,9 +160,9 @@ export class KoreaderAnnotationExchangeService {
}
this.logger.log(
`[${EXCHANGE_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} books=${results.length} pushed=${pushedTotal} unmatched=${unmatched.length} - annotation exchange completed`,
`[${EXCHANGE_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} books=${results.length} pushed=${pushedTotal} unmatched=${unmatched.length} positionIncompatible=${positionIncompatible.length} - annotation exchange completed`,
);
return { results, unmatched };
return { results, unmatched, positionIncompatible };
} catch (error) {
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
this.logger.warn(
@@ -198,13 +209,18 @@ export class KoreaderAnnotationExchangeService {
const results: { hash: string; acked: number }[] = [];
const unmatched: string[] = [];
const positionIncompatible: string[] = [];
for (const book of dto.books) {
const hash = book.hash.toLowerCase();
const match = matches.get(hash);
if (!match || match.progressCompatible === false) {
if (!match) {
unmatched.push(hash);
continue;
}
if (match.progressCompatible === false) {
positionIncompatible.push(hash);
continue;
}
const { acked } = await this.annotationSync.applyExchangeAck({
userId: user.id,
source: 'koreader',
@@ -229,9 +245,9 @@ export class KoreaderAnnotationExchangeService {
}
this.logger.log(
`[${ACK_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} books=${results.length} unmatched=${unmatched.length} - annotation exchange ack applied`,
`[${ACK_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} books=${results.length} unmatched=${unmatched.length} positionIncompatible=${positionIncompatible.length} - annotation exchange ack applied`,
);
return { results, unmatched };
return { results, unmatched, positionIncompatible };
} catch (error) {
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
this.logger.warn(
@@ -100,10 +100,26 @@ describe('KoreaderBookmarkExchangeService', () => {
const result = await service.exchange(makeUser(), makeExchangeDto([makeBook({ hash: HASH_B })]));
expect(result.unmatched).toEqual([HASH_B]);
expect(result.positionIncompatible).toEqual([]);
expect(result.results).toEqual([]);
expect(bookmarkSync.listForSync).not.toHaveBeenCalled();
});
it('reports a matched copy whose bytes differ from the library file apart from unmatched books', async () => {
koreaderRepo.resolveBookFilesByHashes.mockResolvedValue(
new Map([[HASH_A, { bookFileId: 10, bookId: 20, libraryId: 1, progressCompatible: false }]]),
);
const result = await service.exchange(
makeUser(),
makeExchangeDto([makeBook({ changes: [{ datetime: DATETIME, pos: POS, pageno: 42, chapter: 'Chapter 3' }] }), makeBook({ hash: HASH_B })]),
);
expect(result).toEqual({ results: [], unmatched: [HASH_B], positionIncompatible: [HASH_A] });
expect(bookmarkSync.upsertFromDevice).not.toHaveBeenCalled();
expect(bookmarkSync.listForSync).not.toHaveBeenCalled();
});
it('creates a bookmark from an unknown device key and links it', async () => {
const created = makeBookmarkRow({ id: 101, origin: 'koreader', devicePos: POS, pageno: 42 });
bookmarkSync.upsertFromDevice.mockResolvedValue({ row: created, outcome: 'created' });
@@ -373,6 +389,20 @@ describe('KoreaderBookmarkExchangeService', () => {
return { deviceId: DEVICE_ID, deviceModel: 'Kobo', pluginVersion: '0.5.0', books } as BookmarkExchangeAckDto;
}
it('acknowledges nothing for a copy whose bytes differ from the library file', async () => {
koreaderRepo.resolveBookFilesByHashes.mockResolvedValue(
new Map([[HASH_A, { bookFileId: 10, bookId: 20, libraryId: 1, progressCompatible: false }]]),
);
const result = await service.exchangeAck(
makeUser(),
makeAckDto([{ hash: HASH_A, applied: [{ serverId: 100, status: 'applied', key: 'f'.repeat(32), datetime: DATETIME }], deleted: [] }]),
);
expect(result).toEqual({ results: [], unmatched: [], positionIncompatible: [HASH_A] });
expect(bookmarkRepo.upsertLink).not.toHaveBeenCalled();
});
it('links an applied bookmark under the identity the device reported', async () => {
bookmarkSync.listForSync.mockResolvedValue([makeBookmarkRow()]);
@@ -55,11 +55,14 @@ export interface BookmarkExchangeBookResult {
export interface BookmarkExchangeResponse {
results: BookmarkExchangeBookResult[];
unmatched: string[];
/** Matched books whose device copy differs from the library file; see the annotation exchange. */
positionIncompatible: string[];
}
export interface BookmarkExchangeAckResponse {
results: { hash: string; acked: number }[];
unmatched: string[];
positionIncompatible: string[];
}
/** The device identity of a bookmark: md5 of `${datetime}|${pos}`, matching the plugin. */
@@ -89,15 +92,20 @@ export class KoreaderBookmarkExchangeService {
const matches = await this.resolveBooks(user.id, dto.books);
const results: BookmarkExchangeBookResult[] = [];
const unmatched: string[] = [];
const positionIncompatible: string[] = [];
let pushedTotal = 0;
for (const book of dto.books) {
const hash = book.hash.toLowerCase();
const match = matches.get(hash);
if (!match || match.progressCompatible === false) {
if (!match) {
unmatched.push(hash);
continue;
}
if (match.progressCompatible === false) {
positionIncompatible.push(hash);
continue;
}
const result = await this.exchangeBook(user.id, exchangeDeviceId(dto.deviceId, hash), hash, match.bookId, match.bookFileId, book);
pushedTotal += result.toApply.add.length + result.toApply.delete.length;
results.push(result);
@@ -106,9 +114,9 @@ export class KoreaderBookmarkExchangeService {
await this.purgeExpiredTombstones(user.id);
this.logger.log(
`[${EXCHANGE_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} books=${results.length} pushed=${pushedTotal} unmatched=${unmatched.length} - bookmark exchange completed`,
`[${EXCHANGE_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} books=${results.length} pushed=${pushedTotal} unmatched=${unmatched.length} positionIncompatible=${positionIncompatible.length} - bookmark exchange completed`,
);
return { results, unmatched };
return { results, unmatched, positionIncompatible };
} catch (error) {
this.logFailure(EXCHANGE_EVENT, user.id, dto.deviceId, startedAtMs, error);
throw error;
@@ -121,22 +129,27 @@ export class KoreaderBookmarkExchangeService {
const matches = await this.resolveBooks(user.id, dto.books);
const results: { hash: string; acked: number }[] = [];
const unmatched: string[] = [];
const positionIncompatible: string[] = [];
for (const book of dto.books) {
const hash = book.hash.toLowerCase();
const match = matches.get(hash);
if (!match || match.progressCompatible === false) {
if (!match) {
unmatched.push(hash);
continue;
}
if (match.progressCompatible === false) {
positionIncompatible.push(hash);
continue;
}
const acked = await this.ackBook(user.id, exchangeDeviceId(dto.deviceId, hash), match.bookId, book);
results.push({ hash, acked });
}
this.logger.log(
`[${ACK_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} books=${results.length} unmatched=${unmatched.length} - bookmark exchange ack applied`,
`[${ACK_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} books=${results.length} unmatched=${unmatched.length} positionIncompatible=${positionIncompatible.length} - bookmark exchange ack applied`,
);
return { results, unmatched };
return { results, unmatched, positionIncompatible };
} catch (error) {
this.logFailure(ACK_EVENT, user.id, dto.deviceId, startedAtMs, error);
throw error;
@@ -26,6 +26,7 @@ describe('KoreaderHashLinkService', () => {
findBookFileIdByBookId: ReturnType<typeof vi.fn>;
dismissUnmatchedBook: ReturnType<typeof vi.fn>;
dismissAllUnmatchedBooks: ReturnType<typeof vi.fn>;
findBooksByTitleKeys: ReturnType<typeof vi.fn>;
};
let mockBookService: {
verifyBookAccess: ReturnType<typeof vi.fn>;
@@ -69,6 +70,7 @@ describe('KoreaderHashLinkService', () => {
findBookFileIdByBookId: vi.fn(),
dismissUnmatchedBook: vi.fn().mockResolvedValue(null),
dismissAllUnmatchedBooks: vi.fn().mockResolvedValue(0),
findBooksByTitleKeys: vi.fn().mockResolvedValue([]),
};
mockBookService = {
@@ -164,6 +166,149 @@ describe('KoreaderHashLinkService', () => {
});
});
describe('listUnmatchedBookSuggestions', () => {
function makeLibraryBook(overrides: Record<string, unknown> = {}) {
return {
bookId: 501,
titleKey: 'lab alpha',
title: 'Lab Alpha',
subtitle: null,
language: 'en',
publishedYear: 2026,
format: 'epub',
role: 'content',
libraryName: 'Novels',
authors: ['Ada Tester'],
...overrides,
};
}
it('suggests the one library book with the same title and the same set of authors', async () => {
mockRepo.listUnmatchedBooks.mockResolvedValue([makeUnmatchedRow({ title: ' Lab ALPHA ', authors: 'Bo Sample\nAda Tester' })]);
mockRepo.findBooksByTitleKeys.mockResolvedValue([makeLibraryBook({ authors: ['Ada Tester', 'Bo Sample'] })]);
await expect(service.listUnmatchedBookSuggestions(user)).resolves.toEqual([
{
hash: HASH_A,
candidates: [
{
bookId: 501,
title: 'Lab Alpha',
subtitle: null,
authors: ['Ada Tester', 'Bo Sample'],
format: 'epub',
language: 'en',
publishedYear: 2026,
libraryName: 'Novels',
},
],
moreCandidates: false,
},
]);
expect(mockRepo.findBooksByTitleKeys).toHaveBeenCalledWith(['lab alpha'], [1, 2], null, 26);
});
it('lists every edition that shares the title and authors, so none is presented as the only match', async () => {
mockRepo.listUnmatchedBooks.mockResolvedValue([makeUnmatchedRow({ title: 'Lab Alpha', authors: 'Ada Tester' })]);
mockRepo.findBooksByTitleKeys.mockResolvedValue([
makeLibraryBook({ bookId: 501 }),
makeLibraryBook({ bookId: 502, subtitle: 'Second edition' }),
]);
const [suggestion] = await service.listUnmatchedBookSuggestions(user);
expect(suggestion!.candidates.map((candidate) => candidate.bookId)).toEqual([501, 502]);
expect(suggestion!.moreCandidates).toBe(false);
});
it('suggests nothing when the author sets differ in any way', async () => {
mockRepo.listUnmatchedBooks.mockResolvedValue([
makeUnmatchedRow({ hash: HASH_A, title: 'Lab Alpha', authors: 'Ada Tester' }),
makeUnmatchedRow({ hash: HASH_B, title: 'Lab Alpha', authors: 'Ada Tester\nBo Sample\nCy Third' }),
]);
mockRepo.findBooksByTitleKeys.mockResolvedValue([makeLibraryBook({ authors: ['Ada Tester', 'Bo Sample'] })]);
await expect(service.listUnmatchedBookSuggestions(user)).resolves.toEqual([]);
});
it('suggests nothing for a different title, even with the same author', async () => {
mockRepo.listUnmatchedBooks.mockResolvedValue([makeUnmatchedRow({ title: 'Lab Alpha!', authors: 'Ada Tester' })]);
mockRepo.findBooksByTitleKeys.mockResolvedValue([makeLibraryBook({ titleKey: 'lab alpha!', title: 'Lab Alpha' })]);
await expect(service.listUnmatchedBookSuggestions(user)).resolves.toEqual([]);
});
it('only considers rows a link would accept, with a title and authors', async () => {
mockRepo.listUnmatchedBooks.mockResolvedValue([
makeUnmatchedRow({ hash: 'c'.repeat(32), source: 'statistics' }),
makeUnmatchedRow({ hash: 'd'.repeat(32), metadataAmbiguous: true }),
makeUnmatchedRow({ hash: 'e'.repeat(32), manualLinkRequested: true }),
makeUnmatchedRow({ hash: 'f'.repeat(32), title: null }),
makeUnmatchedRow({ hash: '1'.repeat(32), authors: ' ' }),
]);
await expect(service.listUnmatchedBookSuggestions(user)).resolves.toEqual([]);
expect(mockRepo.findBooksByTitleKeys).not.toHaveBeenCalled();
expect(mockRepo.getAccessibleLibraryIds).not.toHaveBeenCalled();
});
it('never offers a book whose primary file is audio or not content, because the link targets that file', async () => {
mockRepo.listUnmatchedBooks.mockResolvedValue([makeUnmatchedRow({ title: 'Lab Alpha', authors: 'Ada Tester' })]);
mockRepo.findBooksByTitleKeys.mockResolvedValue([
makeLibraryBook({ bookId: 601, format: 'm4b' }),
makeLibraryBook({ bookId: 602, role: 'supplement' }),
makeLibraryBook({ bookId: 603, format: 'pdf' }),
]);
const [suggestion] = await service.listUnmatchedBookSuggestions(user);
expect(suggestion!.candidates.map((candidate) => candidate.bookId)).toEqual([603]);
});
it('flags more candidates when the title was shared by more books than were checked', async () => {
mockRepo.listUnmatchedBooks.mockResolvedValue([makeUnmatchedRow({ title: 'Lab Alpha', authors: 'Ada Tester' })]);
const others = Array.from({ length: 25 }, (_, index) => makeLibraryBook({ bookId: 700 + index, authors: ['Someone Else'] }));
mockRepo.findBooksByTitleKeys.mockResolvedValue([makeLibraryBook({ bookId: 699 }), ...others]);
const [suggestion] = await service.listUnmatchedBookSuggestions(user);
expect(suggestion!.candidates.map((candidate) => candidate.bookId)).toEqual([699]);
expect(suggestion!.moreCandidates).toBe(true);
});
it('caps the candidates and reports that more exist', async () => {
mockRepo.listUnmatchedBooks.mockResolvedValue([makeUnmatchedRow({ title: 'Lab Alpha', authors: 'Ada Tester' })]);
mockRepo.findBooksByTitleKeys.mockResolvedValue(Array.from({ length: 7 }, (_, index) => makeLibraryBook({ bookId: 800 + index })));
const [suggestion] = await service.listUnmatchedBookSuggestions(user);
expect(suggestion!.candidates).toHaveLength(5);
expect(suggestion!.moreCandidates).toBe(true);
});
it('applies content filters for regular users and skips them for superusers', async () => {
const filters = { includeTagIds: [3], includeGenreIds: [], excludeTagIds: [], excludeGenreIds: [] };
mockRepo.listUnmatchedBooks.mockResolvedValue([makeUnmatchedRow({ title: 'Lab Alpha', authors: 'Ada Tester' })]);
await service.listUnmatchedBookSuggestions({ id: 7, settings: {}, contentFilters: filters } as never);
await service.listUnmatchedBookSuggestions({ id: 7, settings: {}, contentFilters: filters, isSuperuser: true } as never);
expect(mockRepo.findBooksByTitleKeys).toHaveBeenNthCalledWith(1, ['lab alpha'], [1, 2], filters, 26);
expect(mockRepo.findBooksByTitleKeys).toHaveBeenNthCalledWith(2, ['lab alpha'], [1, 2], null, 26);
});
it('never writes', async () => {
mockRepo.listUnmatchedBooks.mockResolvedValue([makeUnmatchedRow({ title: 'Lab Alpha', authors: 'Ada Tester' })]);
mockRepo.findBooksByTitleKeys.mockResolvedValue([makeLibraryBook()]);
await service.listUnmatchedBookSuggestions(user);
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
expect(mockRepo.clearUnmatchedBooks).not.toHaveBeenCalled();
expect(mockRepo.resolveBookFilesByHashes).not.toHaveBeenCalled();
});
});
describe('linkUnmatchedBook', () => {
it('links an unmatched hash to an accessible book file', async () => {
mockRepo.getUnmatchedBook.mockResolvedValue(makeUnmatchedRow());
@@ -258,6 +403,7 @@ describe('KoreaderHashLinkService', () => {
koreaderTitle: 'KOReader Title',
koreaderAuthors: 'KOReader Author',
koreaderLastOpen: 100,
positionCompatible: false,
createdAt,
updatedAt,
},
@@ -273,6 +419,7 @@ describe('KoreaderHashLinkService', () => {
koreaderTitle: 'KOReader Title',
koreaderAuthors: 'KOReader Author',
koreaderLastOpen: 100,
positionCompatible: false,
createdAt: '2026-06-01T10:00:00.000Z',
updatedAt: '2026-06-02T11:00:00.000Z',
},
@@ -1,16 +1,20 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { isAudioFormat } from '@bookorbit/types';
import type {
DismissAllKoreaderUnmatchedBooksResult,
DismissKoreaderUnmatchedBookResult,
KoreaderLinkSuggestionCandidate,
KoreaderManualHashLink,
KoreaderUnmatchedBook,
KoreaderUnmatchedBookSuggestion,
LinkKoreaderUnmatchedBookResult,
UnlinkKoreaderManualHashLinkResult,
} from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { BookService } from '../book/book.service';
import { KoreaderRepository } from './koreader.repository';
import { authorKeys, koreaderAuthorKeys, linkSuggestionTextKey, sameAuthorKeys } from './koreader-link-suggestion.utils';
const MD5_HEX = /^[0-9a-f]{32}$/;
const UNMATCHED_BOOK_LIMIT = 100;
@@ -20,6 +24,10 @@ const HASH_LINK_UPDATE_EVENT = 'koreader.hash_link_update';
const HASH_LINK_DELETE_EVENT = 'koreader.hash_link_delete';
const UNMATCHED_BOOK_DISMISS_EVENT = 'koreader.unmatched_book_dismiss';
const UNMATCHED_BOOK_DISMISS_ALL_EVENT = 'koreader.unmatched_book_dismiss_all';
const HASH_LINK_SUGGEST_EVENT = 'koreader.hash_link_suggest';
const SUGGESTION_CANDIDATE_LIMIT = 5;
/** Books checked per title. A title shared by more books than this never yields a single suggestion. */
const SUGGESTION_TITLE_SCAN_LIMIT = 25;
/**
* Manages the KOReader "unmatched book" queue and the manual hash links used
@@ -60,6 +68,72 @@ export class KoreaderHashLinkService {
}));
}
/**
* Library books with the same title and the same set of authors as a linkable book to review. Read
* only: nothing is linked until the user confirms, through the same route as a manual link.
*/
async listUnmatchedBookSuggestions(user: RequestUser): Promise<KoreaderUnmatchedBookSuggestion[]> {
const startedAtMs = Date.now();
const rows = await this.repo.listUnmatchedBooks(user.id, UNMATCHED_BOOK_LIMIT);
const wanted = rows.flatMap((row) => {
if (row.manualLinkRequested || !isLinkableUnmatchedBook(row)) return [];
const titleKey = linkSuggestionTextKey(row.title);
const authors = koreaderAuthorKeys(row.authors);
return titleKey && authors.length > 0 ? [{ hash: row.hash, titleKey, authors }] : [];
});
if (wanted.length === 0) return [];
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(user.id);
const books = await this.repo.findBooksByTitleKeys(
[...new Set(wanted.map((entry) => entry.titleKey))],
accessibleLibraryIds,
user.isSuperuser ? null : user.contentFilters,
SUGGESTION_TITLE_SCAN_LIMIT + 1,
);
const booksByTitle = new Map<string, typeof books>();
for (const book of books) {
const list = booksByTitle.get(book.titleKey) ?? [];
list.push(book);
booksByTitle.set(book.titleKey, list);
}
const suggestions: KoreaderUnmatchedBookSuggestion[] = [];
for (const entry of wanted) {
const titleBooks = booksByTitle.get(entry.titleKey) ?? [];
const truncated = titleBooks.length > SUGGESTION_TITLE_SCAN_LIMIT;
const candidates: KoreaderLinkSuggestionCandidate[] = titleBooks
.slice(0, SUGGESTION_TITLE_SCAN_LIMIT)
.filter(
(book) =>
book.role === 'content' &&
!(book.format && isAudioFormat(book.format)) &&
linkSuggestionTextKey(book.title) === entry.titleKey &&
sameAuthorKeys(authorKeys(book.authors), entry.authors),
)
.map((book) => ({
bookId: book.bookId,
title: book.title ?? null,
subtitle: book.subtitle ?? null,
authors: book.authors,
format: book.format ?? null,
language: book.language ?? null,
publishedYear: book.publishedYear ?? null,
libraryName: book.libraryName ?? null,
}));
if (candidates.length === 0) continue;
suggestions.push({
hash: entry.hash,
candidates: candidates.slice(0, SUGGESTION_CANDIDATE_LIMIT),
moreCandidates: truncated || candidates.length > SUGGESTION_CANDIDATE_LIMIT,
});
}
this.logger.log(
`[${HASH_LINK_SUGGEST_EVENT}] [end] userId=${user.id} reviewed=${wanted.length} suggested=${suggestions.length} single=${suggestions.filter((entry) => entry.candidates.length === 1 && !entry.moreCandidates).length} durationMs=${Date.now() - startedAtMs} - link suggestions listed`,
);
return suggestions;
}
async linkUnmatchedBook(user: RequestUser, hash: string, bookId: number): Promise<LinkKoreaderUnmatchedBookResult> {
const startedAtMs = Date.now();
const normalizedHash = this.normalizeKoreaderHash(hash);
@@ -105,6 +179,7 @@ export class KoreaderHashLinkService {
koreaderTitle: row.koreaderTitle ?? null,
koreaderAuthors: row.koreaderAuthors ?? null,
koreaderLastOpen: row.koreaderLastOpen ?? null,
positionCompatible: row.positionCompatible !== false,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}));
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { authorKeys, koreaderAuthorKeys, linkSuggestionTextKey, sameAuthorKeys } from './koreader-link-suggestion.utils';
describe('linkSuggestionTextKey', () => {
it('folds case, compatibility forms and whitespace only', () => {
expect(linkSuggestionTextKey(' The Hobbit\t')).toBe('the hobbit');
expect(linkSuggestionTextKey('THE Hobbit')).toBe('the hobbit');
expect(linkSuggestionTextKey('Office')).toBe('office');
});
it('keeps punctuation, so near-identical titles stay distinct', () => {
expect(linkSuggestionTextKey("Ender's Game")).not.toBe(linkSuggestionTextKey('Ender’s Game'));
expect(linkSuggestionTextKey('It')).not.toBe(linkSuggestionTextKey('It!'));
expect(linkSuggestionTextKey('Dune: Messiah')).not.toBe(linkSuggestionTextKey('Dune Messiah'));
});
it('returns null for missing or blank values', () => {
expect(linkSuggestionTextKey(null)).toBeNull();
expect(linkSuggestionTextKey(undefined)).toBeNull();
expect(linkSuggestionTextKey(' ')).toBeNull();
});
});
describe('author keys', () => {
it('splits KOReader authors on new lines and ignores order, case and duplicates', () => {
expect(koreaderAuthorKeys('Bo Sample\nAda Tester\n\nada tester')).toEqual(['ada tester', 'bo sample']);
expect(authorKeys(['Ada Tester', 'Bo Sample'])).toEqual(['ada tester', 'bo sample']);
});
it('does not split on commas, which belong to "Last, First" names', () => {
expect(koreaderAuthorKeys('Tester, Ada')).toEqual(['tester, ada']);
});
it('compares author sets exactly', () => {
expect(sameAuthorKeys(['a', 'b'], ['a', 'b'])).toBe(true);
expect(sameAuthorKeys(['a'], ['a', 'b'])).toBe(false);
expect(sameAuthorKeys(['a', 'b'], ['a'])).toBe(false);
expect(sameAuthorKeys([], [])).toBe(false);
});
});
@@ -0,0 +1,28 @@
/**
* Strict comparison keys for suggesting a manual link from an unmatched KOReader book's title and
* authors. Only case, Unicode compatibility forms and whitespace are folded: a missed suggestion
* costs one manual search, while a wrong one sends a device copy's reading history to another book.
*/
export function linkSuggestionTextKey(value: string | null | undefined): string | null {
if (!value) return null;
const key = value.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
return key.length > 0 ? key : null;
}
/** KOReader reports several authors on separate lines. */
export function koreaderAuthorKeys(authors: string | null | undefined): string[] {
return authorKeys((authors ?? '').split('\n'));
}
export function authorKeys(names: readonly (string | null | undefined)[]): string[] {
const keys = new Set<string>();
for (const name of names) {
const key = linkSuggestionTextKey(name);
if (key) keys.add(key);
}
return [...keys].sort();
}
export function sameAuthorKeys(a: readonly string[], b: readonly string[]): boolean {
return a.length > 0 && a.length === b.length && a.every((key, index) => key === b[index]);
}
@@ -349,7 +349,7 @@ describe('KOReader plugin update source wiring', () => {
expect(bookSync).toContain('if not ctx.snap.stats_row_ambiguous then return book and book.fileId or nil end');
expect(bookSync).toContain('metadata_ambiguous = ctx.snap.metadata_ambiguous');
expect(bookSync).toContain('if ctx.snap.stats_metadata_ambiguous then');
expect(bookSync).toContain('ctx.state:setMatched(match.hash, match.bookFileId, match.bookId, ctx.snap.file)');
expect(bookSync).toContain('ctx.state:setMatched(match.hash, match.bookFileId, match.bookId, ctx.snap.file, nil, match.positionCompatible)');
expect(bookSync).toContain('if ctx.snap.stats_row_ambiguous then upload.bookFileId = stats_target.bookFileId end');
expect(bookSync).not.toContain(
'if book then\n if ctx.snap.file and not book.file then\n book.file = ctx.snap.file\n end\n return step(ctx, stepStats)\n end',
@@ -126,6 +126,23 @@ describe('KoreaderPluginService', () => {
expect(result.libraryVersion).toMatch(/^[0-9a-f]{16}$/);
});
it('marks a match whose device copy cannot place positions, and only that one', async () => {
koreaderRepo.resolveBookFilesByHashes.mockResolvedValue(
new Map([
[HASH_A, { bookFileId: 10, bookId: 20, libraryId: 1, progressCompatible: false }],
[HASH_B, { bookFileId: 11, bookId: 21, libraryId: 1 }],
]),
);
const dto = { ...deviceFields(), hashes: [HASH_A, HASH_B] } as MatchCheckDto;
const result = await service.matchCheck(makeUser(), dto);
expect(result.matches).toEqual([
{ hash: HASH_A, bookId: 20, bookFileId: 10, positionCompatible: false },
{ hash: HASH_B, bookId: 21, bookFileId: 11 },
]);
});
it('links an explicitly downloaded catalog file when its hash resolved to another file', async () => {
koreaderRepo.resolveBookFilesByHashes
.mockResolvedValueOnce(new Map([[HASH_A, { bookFileId: 10, bookId: 20, libraryId: 1 }]]))
@@ -43,7 +43,8 @@ const READ_STATUS_TO_DEVICE_STATUS: Record<ReadStatus, string> = {
};
export interface MatchCheckResult {
matches: { hash: string; bookId: number; bookFileId: number }[];
/** `positionCompatible: false` marks a copy whose bytes differ from the library file: progress syncs as a percentage and highlights do not sync. */
matches: { hash: string; bookId: number; bookFileId: number; positionCompatible?: false }[];
libraryVersion: string;
}
@@ -140,6 +141,7 @@ export class KoreaderPluginService {
hash,
bookId: match.bookId,
bookFileId: match.bookFileId,
...(match.progressCompatible === false ? { positionCompatible: false as const } : {}),
}));
const libraryVersion = await this.computeLibraryVersion(user.id, accessibleLibraryIds);
@@ -174,6 +174,12 @@ export class KoreaderController {
return this.hashLinkService.listUnmatchedBooks(user);
}
@RequirePermission(Permission.KoreaderSync)
@Get('unmatched-books/suggestions')
listUnmatchedBookSuggestions(@CurrentUser() user: RequestUser) {
return this.hashLinkService.listUnmatchedBookSuggestions(user);
}
@RequirePermission(Permission.KoreaderSync)
@Post('unmatched-books/:hash/link')
linkUnmatchedBook(@CurrentUser() user: RequestUser, @Param('hash') hash: string, @Body() dto: LinkKoreaderUnmatchedBookDto) {
@@ -1438,3 +1438,58 @@ describe('KoreaderRepository', () => {
});
});
});
describe('KoreaderRepository.findBooksByTitleKeys', () => {
it('short-circuits without a title or an accessible library', async () => {
const captured = createCapturingDb();
const repository = await makeRepository(captured.db);
await expect(repository.findBooksByTitleKeys([], null, null, 26)).resolves.toEqual([]);
await expect(repository.findBooksByTitleKeys(['lab alpha'], [], null, 26)).resolves.toEqual([]);
expect(captured.queries).toHaveLength(0);
});
it('looks titles up by the indexed lowercase title, capped per title and scoped to present books in the libraries', async () => {
const captured = createCapturingDb();
const repository = await makeRepository(captured.db);
await repository.findBooksByTitleKeys(['lab alpha', 'lab beta'], [3, 4], null, 26);
expect(captured.queries).toHaveLength(1);
const query = captured.queries[0]!;
expect(query.sql).toContain('lower("book_metadata"."title") in');
expect(query.sql).toContain('row_number() over (partition by lower("book_metadata"."title")');
expect(query.sql).toContain('"books"."status" =');
expect(query.sql).toContain('"books"."library_id" in');
expect(query.sql).toContain('"books"."primary_file_id"');
expect(query.sql).not.toContain('"book_tags"');
expect(query.params).toEqual(expect.arrayContaining(['lab alpha', 'lab beta', 'present', 3, 4, 26]));
});
it('applies the content filters it is given', async () => {
const captured = createCapturingDb();
const repository = await makeRepository(captured.db);
await repository.findBooksByTitleKeys(
['lab alpha'],
null,
{ includeTagIds: [9], includeGenreIds: [], excludeTagIds: [], excludeGenreIds: [] },
26,
);
expect(captured.queries[0]!.sql).toContain('"book_tags"');
expect(captured.queries[0]!.sql).not.toContain('"books"."library_id" in');
});
});
describe('KoreaderRepository.listBookHashLinks', () => {
it('reports whether each linked copy can place positions in the linked file', async () => {
const captured = createCapturingDb();
const repository = await makeRepository(captured.db);
await repository.listBookHashLinks(7, 100, [1]);
expect(captured.queries[0]!.sql).toContain('"book_files"."file_hash" = "koreader_book_hash_links"."hash"');
expect(captured.queries[0]!.sql).toContain("'kobo_download'");
});
});
@@ -1,9 +1,10 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { and, asc, desc, eq, inArray, notExists, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
import { and, asc, desc, eq, inArray, lte, notExists, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { ReadAloudDeliveryPreference } from '@bookorbit/types';
import type { ContentFilterRules, ReadAloudDeliveryPreference } from '@bookorbit/types';
import { chunk } from '../../common/utils/batch.utils';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
@@ -500,6 +501,7 @@ export class KoreaderRepository {
koreaderTitle: schema.koreaderBookHashLinks.koreaderTitle,
koreaderAuthors: schema.koreaderBookHashLinks.koreaderAuthors,
koreaderLastOpen: schema.koreaderBookHashLinks.koreaderLastOpen,
positionCompatible: compatibleProgressHash(schema.koreaderBookHashLinks.hash),
createdAt: schema.koreaderBookHashLinks.createdAt,
updatedAt: schema.koreaderBookHashLinks.updatedAt,
})
@@ -532,6 +534,80 @@ export class KoreaderRepository {
return rows.map((row) => ({ ...row, bookAuthors: authorsByBook.get(row.bookId) ?? [] }));
}
/**
* Present books in the given libraries whose lowercased title is one of `titleKeys`, at most
* `perTitleLimit` per title (lowest ids first), with their primary file and authors. Pass
* `contentFilters` for a non-superuser so hidden books are never offered.
*/
async findBooksByTitleKeys(
titleKeys: string[],
accessibleLibraryIds: number[] | null,
contentFilters: ContentFilterRules | null,
perTitleLimit: number,
) {
if (titleKeys.length === 0) return [];
if (accessibleLibraryIds !== null && accessibleLibraryIds.length === 0) return [];
const titleKey = sql<string>`lower(${schema.bookMetadata.title})`;
const ranked = this.db
.select({
bookId: sql<number>`${schema.books.id}`.as('ranked_book_id'),
titleKey: sql<string>`lower(${schema.bookMetadata.title})`.as('ranked_title_key'),
rank: sql<number>`row_number() over (partition by lower(${schema.bookMetadata.title}) order by ${schema.books.id})`.as('ranked_rank'),
})
.from(schema.bookMetadata)
.innerJoin(schema.books, eq(schema.books.id, schema.bookMetadata.bookId))
.where(
and(
inArray(titleKey, titleKeys),
eq(schema.books.status, 'present'),
accessibleLibraryIds ? inArray(schema.books.libraryId, accessibleLibraryIds) : undefined,
...(contentFilters ? buildContentFilterClauses(contentFilters, this.db) : []),
),
)
.as('ranked');
const rows = await this.db
.select({
bookId: ranked.bookId,
titleKey: ranked.titleKey,
title: schema.bookMetadata.title,
subtitle: schema.bookMetadata.subtitle,
language: schema.bookMetadata.language,
publishedYear: schema.bookMetadata.publishedYear,
format: schema.bookFiles.format,
role: schema.bookFiles.role,
libraryName: schema.libraries.name,
})
.from(ranked)
.innerJoin(schema.books, eq(schema.books.id, ranked.bookId))
.innerJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId))
.innerJoin(schema.libraries, eq(schema.libraries.id, schema.books.libraryId))
.where(lte(ranked.rank, perTitleLimit))
.orderBy(asc(ranked.bookId));
const authorsByBook = new Map<number, string[]>();
for (const ids of chunk(
rows.map((row) => row.bookId),
BATCH_QUERY_SIZE,
)) {
const authorRows = await this.db
.select({ bookId: schema.bookAuthors.bookId, name: schema.authors.name })
.from(schema.bookAuthors)
.innerJoin(schema.authors, eq(schema.authors.id, schema.bookAuthors.authorId))
.where(inArray(schema.bookAuthors.bookId, ids))
.orderBy(schema.bookAuthors.bookId, schema.bookAuthors.displayOrder);
for (const row of authorRows) {
const list = authorsByBook.get(row.bookId) ?? [];
list.push(row.name);
authorsByBook.set(row.bookId, list);
}
}
return rows.map((row) => ({ ...row, authors: authorsByBook.get(row.bookId) ?? [] }));
}
async upsertBookHashLink(userId: number, hash: string, bookFileId: number, metadata: KoreaderHashLinkMetadata = {}) {
const now = new Date();
await this.db
@@ -109,6 +109,7 @@
"GET /koreader/plugin/version",
"GET /koreader/syncs/progress/:document",
"GET /koreader/unmatched-books",
"GET /koreader/unmatched-books/suggestions",
"GET /koreader/users/auth",
"GET /libraries/:id|:libraryId",
"GET /libraries/:id|:libraryId/access",
@@ -419,7 +419,10 @@ describe('Kobo download identity in KOReader (e2e)', { timeout: 180_000 }, () =>
await ctx.db.insert(schema.koreaderBookHashLinks).values({ userId: user.userId, hash: deliveredHash, bookFileId: other.bookFileId });
try {
expect((await download()).statusCode).toBe(200);
expect((await match(deliveredHash)).matches).toEqual([{ hash: deliveredHash, bookId: other.bookId, bookFileId: other.bookFileId }]);
// The delivered bytes belong to the source book, so the linked file cannot place their positions.
expect((await match(deliveredHash)).matches).toEqual([
{ hash: deliveredHash, bookId: other.bookId, bookFileId: other.bookFileId, positionCompatible: false },
]);
const response = await ctx.app.inject({
method: 'PUT',
url: '/api/v1/koreader/syncs/progress',