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