diff --git a/client/src/features/koreader/composables/__tests__/useKoreaderSync.spec.ts b/client/src/features/koreader/composables/__tests__/useKoreaderSync.spec.ts index abb085230..aff835541 100644 --- a/client/src/features/koreader/composables/__tests__/useKoreaderSync.spec.ts +++ b/client/src/features/koreader/composables/__tests__/useKoreaderSync.spec.ts @@ -76,6 +76,7 @@ function makeManualHashLink(overrides: Partial = {}): 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 })) diff --git a/client/src/features/koreader/composables/useKoreaderSync.ts b/client/src/features/koreader/composables/useKoreaderSync.ts index 6e5fb5627..e884389e3 100644 --- a/client/src/features/koreader/composables/useKoreaderSync.ts +++ b/client/src/features/koreader/composables/useKoreaderSync.ts @@ -8,6 +8,7 @@ import type { KoreaderManualHashLink, KoreaderSyncStatus, KoreaderUnmatchedBook, + KoreaderUnmatchedBookSuggestion, LinkKoreaderUnmatchedBookPayload, LinkKoreaderUnmatchedBookResult, UnlinkKoreaderManualHashLinkResult, @@ -19,6 +20,7 @@ const credentials = ref(null) const syncStatus = ref(null) const unmatchedBooks = ref([]) const manualHashLinks = ref([]) +const linkSuggestions = ref([]) 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 { 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 { @@ -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 { + 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): 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 { 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 { + const linked = new Set() + const failed: string[] = [] + let next = 0 + async function worker(): Promise { + 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 { 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, diff --git a/client/src/features/settings/KoreaderSettings.vue b/client/src/features/settings/KoreaderSettings.vue index 06f754bb9..1a6b9a7a7 100644 --- a/client/src/features/settings/KoreaderSettings.vue +++ b/client/src/features/settings/KoreaderSettings.vue @@ -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(null) const selectedManualLink = ref(null) const linkSearchQuery = ref('') const linkingBookId = ref(null) -const pendingLinkTarget = ref(null) +type LinkTarget = { id: number; title: string | null; detail: string } +const pendingLinkTarget = ref(null) +const bulkLinkOpen = ref(false) +const bulkLinking = ref(false) const unlinkConfirmLink = ref(null) const unlinkingHash = ref(null) const removeDeviceConfirmTarget = ref(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() {

{{ t('settings.reader.koreader.hashLinks.reviewTitle') }}

-
+
+
diff --git a/client/src/features/settings/__tests__/KoreaderSettings.spec.ts b/client/src/features/settings/__tests__/KoreaderSettings.spec.ts index 93492a606..adfef168e 100644 --- a/client/src/features/settings/__tests__/KoreaderSettings.spec.ts +++ b/client/src/features/settings/__tests__/KoreaderSettings.spec.ts @@ -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>(), fetchUnmatchedBooks: vi.fn<() => Promise>(), fetchManualHashLinks: vi.fn<() => Promise>(), + fetchLinkSuggestions: vi.fn<() => Promise>(), createCredentials: vi.fn<() => Promise>(), updateCredentials: vi.fn<() => Promise>(), deleteCredentials: vi.fn<() => Promise>(), getSyncUrl: vi.fn<() => string>(), downloadPluginPackage: vi.fn<() => Promise>(), linkUnmatchedBook: vi.fn<() => Promise>(), + linkSuggestedBooks: vi.fn<(links: { hash: string; bookId: number }[]) => Promise<{ linked: number; failed: string[] }>>(), dismissUnmatchedBook: vi.fn<() => Promise>(), dismissAllUnmatchedBooks: vi.fn<() => Promise<{ count: number }>>(), relinkManualHashLink: vi.fn<() => Promise>(), @@ -164,12 +175,31 @@ function makeManualHashLink(overrides: Partial = {}): 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 { + 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 { + return { hash: 'a'.repeat(32), candidates: [makeCandidate()], moreCandidates: false, ...overrides } +} + function makeBookCard(overrides: Partial = {}): 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 diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 998fb0292..462a4ca8e 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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...", diff --git a/docs/KOREADER_HASH_MATCHING.md b/docs/KOREADER_HASH_MATCHING.md index 185066713..5043871ec 100644 --- a/docs/KOREADER_HASH_MATCHING.md +++ b/docs/KOREADER_HASH_MATCHING.md @@ -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. diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_annotations.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_annotations.lua index b80e43d13..09bb0b129 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_annotations.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_annotations.lua @@ -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 diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_book_sheet.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_book_sheet.lua index 0fdcc069b..8452bb623 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_book_sheet.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_book_sheet.lua @@ -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), } diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_book_sync.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_book_sync.lua index 3e966359e..41e71b091 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_book_sync.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_book_sync.lua @@ -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 diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_bookmarks.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_bookmarks.lua index d1ed4dee4..b689adc34 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_bookmarks.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_bookmarks.lua @@ -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 diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_highlight_diagnostics.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_highlight_diagnostics.lua index 65ff10aca..bc73d6a48 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_highlight_diagnostics.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_highlight_diagnostics.lua @@ -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") diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_highlight_summary.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_highlight_summary.lua index fcb5ca3cb..04c150e5f 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_highlight_summary.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_highlight_summary.lua @@ -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 diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua index b61a4fe55..2edf285dc 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua @@ -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, } }, diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_progress_sync.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_progress_sync.lua index af3f7273b..9d3ae9c0b 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_progress_sync.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_progress_sync.lua @@ -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. - -- kosync percentage is 0-1; GotoPercent expects 0-100. - self.ui:handleEvent(Event:new("GotoPercent", percentage_fallback * 100)) + -- 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")) diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_state.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_state.lua index 869ddb233..0bcbfd3a4 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_state.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_state.lua @@ -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() diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_sweep.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_sweep.lua index f0e47fbfd..728315756 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_sweep.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_sweep.lua @@ -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 - BookOrbitAnnotations.advanceWatermark(book, pending.ann_max_datetime, device_now) + -- 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 diff --git a/koreader-plugin/bookorbit.koplugin/main.lua b/koreader-plugin/bookorbit.koplugin/main.lua index aeed28924..c440ead3f 100644 --- a/koreader-plugin/bookorbit.koplugin/main.lua +++ b/koreader-plugin/bookorbit.koplugin/main.lua @@ -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() diff --git a/koreader-plugin/spec/bookorbit_position_incompatible_test.lua b/koreader-plugin/spec/bookorbit_position_incompatible_test.lua new file mode 100644 index 000000000..060539a44 --- /dev/null +++ b/koreader-plugin/spec/bookorbit_position_incompatible_test.lua @@ -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") diff --git a/koreader-plugin/spec/bookorbit_same_place_test.lua b/koreader-plugin/spec/bookorbit_same_place_test.lua new file mode 100644 index 000000000..fc521b744 --- /dev/null +++ b/koreader-plugin/spec/bookorbit_same_place_test.lua @@ -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") diff --git a/packages/types/src/koreader.ts b/packages/types/src/koreader.ts index 2f9f99635..4dc2c02c3 100644 --- a/packages/types/src/koreader.ts +++ b/packages/types/src/koreader.ts @@ -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; } diff --git a/server/src/modules/koreader/koreader-annotation-exchange.service.test.ts b/server/src/modules/koreader/koreader-annotation-exchange.service.test.ts index b2d435d6e..e0efc6387 100644 --- a/server/src/modules/koreader/koreader-annotation-exchange.service.test.ts +++ b/server/src/modules/koreader/koreader-annotation-exchange.service.test.ts @@ -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(); }); diff --git a/server/src/modules/koreader/koreader-annotation-exchange.service.ts b/server/src/modules/koreader/koreader-annotation-exchange.service.ts index 05c5762fb..a3d041968 100644 --- a/server/src/modules/koreader/koreader-annotation-exchange.service.ts +++ b/server/src/modules/koreader/koreader-annotation-exchange.service.ts @@ -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( diff --git a/server/src/modules/koreader/koreader-bookmark-exchange.service.test.ts b/server/src/modules/koreader/koreader-bookmark-exchange.service.test.ts index 81e20eabd..bd427f8a4 100644 --- a/server/src/modules/koreader/koreader-bookmark-exchange.service.test.ts +++ b/server/src/modules/koreader/koreader-bookmark-exchange.service.test.ts @@ -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()]); diff --git a/server/src/modules/koreader/koreader-bookmark-exchange.service.ts b/server/src/modules/koreader/koreader-bookmark-exchange.service.ts index d6d1f7b07..81835a802 100644 --- a/server/src/modules/koreader/koreader-bookmark-exchange.service.ts +++ b/server/src/modules/koreader/koreader-bookmark-exchange.service.ts @@ -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; diff --git a/server/src/modules/koreader/koreader-hash-link.service.test.ts b/server/src/modules/koreader/koreader-hash-link.service.test.ts index a0195679a..6cb317777 100644 --- a/server/src/modules/koreader/koreader-hash-link.service.test.ts +++ b/server/src/modules/koreader/koreader-hash-link.service.test.ts @@ -26,6 +26,7 @@ describe('KoreaderHashLinkService', () => { findBookFileIdByBookId: ReturnType; dismissUnmatchedBook: ReturnType; dismissAllUnmatchedBooks: ReturnType; + findBooksByTitleKeys: ReturnType; }; let mockBookService: { verifyBookAccess: ReturnType; @@ -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 = {}) { + 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', }, diff --git a/server/src/modules/koreader/koreader-hash-link.service.ts b/server/src/modules/koreader/koreader-hash-link.service.ts index 8654b9a82..804600087 100644 --- a/server/src/modules/koreader/koreader-hash-link.service.ts +++ b/server/src/modules/koreader/koreader-hash-link.service.ts @@ -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 { + 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(); + 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 { 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(), })); diff --git a/server/src/modules/koreader/koreader-link-suggestion.utils.test.ts b/server/src/modules/koreader/koreader-link-suggestion.utils.test.ts new file mode 100644 index 000000000..5f1dfe7be --- /dev/null +++ b/server/src/modules/koreader/koreader-link-suggestion.utils.test.ts @@ -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); + }); +}); diff --git a/server/src/modules/koreader/koreader-link-suggestion.utils.ts b/server/src/modules/koreader/koreader-link-suggestion.utils.ts new file mode 100644 index 000000000..8588914e4 --- /dev/null +++ b/server/src/modules/koreader/koreader-link-suggestion.utils.ts @@ -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(); + 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]); +} diff --git a/server/src/modules/koreader/koreader-plugin-source.test.ts b/server/src/modules/koreader/koreader-plugin-source.test.ts index dc4d748c6..5f4c5cd34 100644 --- a/server/src/modules/koreader/koreader-plugin-source.test.ts +++ b/server/src/modules/koreader/koreader-plugin-source.test.ts @@ -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', diff --git a/server/src/modules/koreader/koreader-plugin.service.test.ts b/server/src/modules/koreader/koreader-plugin.service.test.ts index b6400923d..18b201116 100644 --- a/server/src/modules/koreader/koreader-plugin.service.test.ts +++ b/server/src/modules/koreader/koreader-plugin.service.test.ts @@ -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 }]])) diff --git a/server/src/modules/koreader/koreader-plugin.service.ts b/server/src/modules/koreader/koreader-plugin.service.ts index c065bc9e0..37e467e1f 100644 --- a/server/src/modules/koreader/koreader-plugin.service.ts +++ b/server/src/modules/koreader/koreader-plugin.service.ts @@ -43,7 +43,8 @@ const READ_STATUS_TO_DEVICE_STATUS: Record = { }; 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); diff --git a/server/src/modules/koreader/koreader.controller.ts b/server/src/modules/koreader/koreader.controller.ts index 19770b851..b23fa229f 100644 --- a/server/src/modules/koreader/koreader.controller.ts +++ b/server/src/modules/koreader/koreader.controller.ts @@ -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) { diff --git a/server/src/modules/koreader/koreader.repository.test.ts b/server/src/modules/koreader/koreader.repository.test.ts index 953d6ed43..189631b85 100644 --- a/server/src/modules/koreader/koreader.repository.test.ts +++ b/server/src/modules/koreader/koreader.repository.test.ts @@ -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'"); + }); +}); diff --git a/server/src/modules/koreader/koreader.repository.ts b/server/src/modules/koreader/koreader.repository.ts index 2a405396a..5cbe347e5 100644 --- a/server/src/modules/koreader/koreader.repository.ts +++ b/server/src/modules/koreader/koreader.repository.ts @@ -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`lower(${schema.bookMetadata.title})`; + const ranked = this.db + .select({ + bookId: sql`${schema.books.id}`.as('ranked_book_id'), + titleKey: sql`lower(${schema.bookMetadata.title})`.as('ranked_title_key'), + rank: sql`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(); + 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 diff --git a/server/test/e2e/authorization-matrix/route-inventory/uncovered-routes.json b/server/test/e2e/authorization-matrix/route-inventory/uncovered-routes.json index e54801dcc..c5db6726f 100644 --- a/server/test/e2e/authorization-matrix/route-inventory/uncovered-routes.json +++ b/server/test/e2e/authorization-matrix/route-inventory/uncovered-routes.json @@ -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", diff --git a/server/test/kobo-koreader-download.e2e-spec.ts b/server/test/kobo-koreader-download.e2e-spec.ts index 772e45b5e..091c18bff 100644 --- a/server/test/kobo-koreader-download.e2e-spec.ts +++ b/server/test/kobo-koreader-download.e2e-spec.ts @@ -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',