feat(kobo): add manual KOReader book hash linking and unmatched tracking

When KOReader syncs progress or annotations, it identifies books by a document
hash. BookOrbit normally resolves this automatically, but some books cannot be
matched. This adds a tracking table for unmatched hashes and a UI to manually
link them to existing library books.

Closes #458
This commit is contained in:
neon
2026-07-02 00:32:02 +00:00
parent c48c4a0dad
commit a0dad23087
37 changed files with 15654 additions and 59 deletions
@@ -1,5 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { CreateKoreaderCredentialsPayload, KoreaderCredentials, KoreaderSyncStatus, UpdateKoreaderCredentialsPayload } from '@bookorbit/types'
import type {
CreateKoreaderCredentialsPayload,
KoreaderCredentials,
KoreaderManualHashLink,
KoreaderSyncStatus,
KoreaderUnmatchedBook,
UpdateKoreaderCredentialsPayload,
} from '@bookorbit/types'
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>())
@@ -32,7 +39,43 @@ function makeSyncStatus(overrides: Partial<KoreaderSyncStatus> = {}): KoreaderSy
latestPluginVersion: '0.5.0',
pluginUpdateAvailable: false,
sweeps: [],
pluginTotals: { matchedBooks: 0, trashedAnnotations: 0, pendingDeletes: 0, failedPositions: 0, pageStatEvents: 0, annotations: 0 },
pluginTotals: {
matchedBooks: 0,
trashedAnnotations: 0,
pendingDeletes: 0,
failedPositions: 0,
pageStatEvents: 0,
annotations: 0,
unmatchedBooks: 0,
},
...overrides,
}
}
function makeUnmatchedBook(overrides: Partial<KoreaderUnmatchedBook> = {}): KoreaderUnmatchedBook {
return {
hash: 'a'.repeat(32),
title: 'Unmatched Title',
authors: 'Author One',
lastOpen: 1700000000,
firstSeenAt: '2026-06-01T00:00:00.000Z',
lastSeenAt: '2026-06-02T00:00:00.000Z',
...overrides,
}
}
function makeManualHashLink(overrides: Partial<KoreaderManualHashLink> = {}): KoreaderManualHashLink {
return {
hash: 'b'.repeat(32),
bookId: 70,
bookFileId: 71,
bookTitle: 'Linked Book',
bookAuthors: ['Linked Author'],
koreaderTitle: 'KOReader Title',
koreaderAuthors: 'Device Author',
koreaderLastOpen: 1700000001,
createdAt: '2026-06-03T00:00:00.000Z',
updatedAt: '2026-06-04T00:00:00.000Z',
...overrides,
}
}
@@ -201,15 +244,19 @@ describe('useKoreaderSync', () => {
apiMock.mockResolvedValueOnce(makeResponse({}))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { syncStatus, credentials, deleteCredentials } = useKoreaderSync()
const { syncStatus, credentials, unmatchedBooks, manualHashLinks, deleteCredentials } = useKoreaderSync()
syncStatus.value = makeSyncStatus()
credentials.value = syncStatus.value.credentials
unmatchedBooks.value = [makeUnmatchedBook()]
manualHashLinks.value = [makeManualHashLink()]
await deleteCredentials()
expect(apiMock).toHaveBeenCalledWith('/api/v1/koreader/credentials', { method: 'DELETE' })
expect(syncStatus.value).toBeNull()
expect(credentials.value).toBeNull()
expect(unmatchedBooks.value).toEqual([])
expect(manualHashLinks.value).toEqual([])
})
it('deleteCredentials throws when the delete request fails', async () => {
@@ -297,4 +344,143 @@ describe('useKoreaderSync', () => {
await expect(downloadPluginPackage()).rejects.toThrow('Failed to download the plugin')
})
it('fetchUnmatchedBooks updates unmatched state', async () => {
const rows = [makeUnmatchedBook()]
apiMock.mockResolvedValueOnce(makeResponse(rows))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { unmatchedBooks, fetchUnmatchedBooks } = useKoreaderSync()
await fetchUnmatchedBooks()
expect(apiMock).toHaveBeenCalledWith('/api/v1/koreader/unmatched-books')
expect(unmatchedBooks.value).toEqual(rows)
})
it('fetchUnmatchedBooks throws the server message on failure', async () => {
apiMock.mockResolvedValueOnce(makeResponse({ message: 'No permission' }, { ok: false, status: 403 }))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { fetchUnmatchedBooks } = useKoreaderSync()
await expect(fetchUnmatchedBooks()).rejects.toThrow('No permission')
})
it('fetchManualHashLinks updates manual link state', async () => {
const rows = [makeManualHashLink()]
apiMock.mockResolvedValueOnce(makeResponse(rows))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { manualHashLinks, fetchManualHashLinks } = useKoreaderSync()
await fetchManualHashLinks()
expect(apiMock).toHaveBeenCalledWith('/api/v1/koreader/hash-links')
expect(manualHashLinks.value).toEqual(rows)
})
it('fetchManualHashLinks throws the server message on failure', async () => {
apiMock.mockResolvedValueOnce(makeResponse({ message: 'No permission' }, { ok: false, status: 403 }))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { fetchManualHashLinks } = useKoreaderSync()
await expect(fetchManualHashLinks()).rejects.toThrow('No permission')
})
it('linkUnmatchedBook posts the target book, removes the linked row, and refreshes status and manual links', async () => {
const linked = { hash: 'a'.repeat(32), bookId: 55, bookFileId: 44 }
const refreshedStatus = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 0 } })
const manualLinks = [makeManualHashLink({ hash: linked.hash, bookId: linked.bookId, bookFileId: linked.bookFileId })]
apiMock
.mockResolvedValueOnce(makeResponse(linked))
.mockResolvedValueOnce(makeResponse(refreshedStatus))
.mockResolvedValueOnce(makeResponse(manualLinks))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { unmatchedBooks, syncStatus, manualHashLinks, linkUnmatchedBook } = useKoreaderSync()
unmatchedBooks.value = [makeUnmatchedBook({ hash: linked.hash }), makeUnmatchedBook({ hash: 'b'.repeat(32) })]
await expect(linkUnmatchedBook(linked.hash, { bookId: linked.bookId })).resolves.toEqual(linked)
const [url, request] = apiMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe(`/api/v1/koreader/unmatched-books/${linked.hash}/link`)
expect(request.method).toBe('POST')
expect(request.headers).toEqual({ 'Content-Type': 'application/json' })
expect(JSON.parse(String(request.body))).toEqual({ bookId: linked.bookId })
expect(unmatchedBooks.value).toEqual([makeUnmatchedBook({ hash: 'b'.repeat(32) })])
expect(syncStatus.value).toEqual(refreshedStatus)
expect(manualHashLinks.value).toEqual(manualLinks)
})
it('linkUnmatchedBook throws the server message on failure', async () => {
apiMock.mockResolvedValueOnce(makeResponse({ message: 'KOReader hash already matches a different book' }, { ok: false, status: 409 }))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { linkUnmatchedBook } = useKoreaderSync()
await expect(linkUnmatchedBook('a'.repeat(32), { bookId: 55 })).rejects.toThrow('KOReader hash already matches a different book')
})
it('relinkManualHashLink patches the target book and refreshes status and manual links', async () => {
const linked = { hash: 'b'.repeat(32), bookId: 88, bookFileId: 89 }
const refreshedStatus = makeSyncStatus()
const manualLinks = [makeManualHashLink({ hash: linked.hash, bookId: linked.bookId, bookFileId: linked.bookFileId })]
apiMock
.mockResolvedValueOnce(makeResponse(linked))
.mockResolvedValueOnce(makeResponse(refreshedStatus))
.mockResolvedValueOnce(makeResponse(manualLinks))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { syncStatus, manualHashLinks, relinkManualHashLink } = useKoreaderSync()
await expect(relinkManualHashLink(linked.hash, { bookId: linked.bookId })).resolves.toEqual(linked)
const [url, request] = apiMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe(`/api/v1/koreader/hash-links/${linked.hash}`)
expect(request.method).toBe('PATCH')
expect(JSON.parse(String(request.body))).toEqual({ bookId: linked.bookId })
expect(syncStatus.value).toEqual(refreshedStatus)
expect(manualHashLinks.value).toEqual(manualLinks)
})
it('relinkManualHashLink throws the server message on failure', async () => {
apiMock.mockResolvedValueOnce(makeResponse({ message: 'KOReader hash already matches a different book' }, { ok: false, status: 409 }))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { relinkManualHashLink } = useKoreaderSync()
await expect(relinkManualHashLink('b'.repeat(32), { bookId: 88 })).rejects.toThrow('KOReader hash already matches a different book')
})
it('unlinkManualHashLink deletes the link, removes it locally, and refreshes unmatched books', async () => {
const hash = 'b'.repeat(32)
const refreshedStatus = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
const unmatched = [makeUnmatchedBook({ hash })]
apiMock
.mockResolvedValueOnce(makeResponse({ hash }))
.mockResolvedValueOnce(makeResponse(refreshedStatus))
.mockResolvedValueOnce(makeResponse(unmatched))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { syncStatus, manualHashLinks, unmatchedBooks, unlinkManualHashLink } = useKoreaderSync()
manualHashLinks.value = [makeManualHashLink({ hash }), makeManualHashLink({ hash: 'c'.repeat(32) })]
await expect(unlinkManualHashLink(hash)).resolves.toEqual({ hash })
expect(apiMock).toHaveBeenCalledWith(`/api/v1/koreader/hash-links/${hash}`, { method: 'DELETE' })
expect(manualHashLinks.value).toEqual([makeManualHashLink({ hash: 'c'.repeat(32) })])
expect(syncStatus.value).toEqual(refreshedStatus)
expect(unmatchedBooks.value).toEqual(unmatched)
})
it('unlinkManualHashLink throws the server message on failure', async () => {
apiMock.mockResolvedValueOnce(makeResponse({ message: 'KOReader manual link not found' }, { ok: false, status: 404 }))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { unlinkManualHashLink } = useKoreaderSync()
await expect(unlinkManualHashLink('b'.repeat(32))).rejects.toThrow('KOReader manual link not found')
})
})
@@ -1,10 +1,25 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { KoreaderCredentials, KoreaderSyncStatus, CreateKoreaderCredentialsPayload, UpdateKoreaderCredentialsPayload } from '@bookorbit/types'
import type {
CreateKoreaderCredentialsPayload,
KoreaderCredentials,
KoreaderManualHashLink,
KoreaderSyncStatus,
KoreaderUnmatchedBook,
LinkKoreaderUnmatchedBookPayload,
LinkKoreaderUnmatchedBookResult,
UnlinkKoreaderManualHashLinkResult,
UpdateKoreaderCredentialsPayload,
UpdateKoreaderManualHashLinkPayload,
} from '@bookorbit/types'
const credentials = ref<KoreaderCredentials | null>(null)
const syncStatus = ref<KoreaderSyncStatus | null>(null)
const unmatchedBooks = ref<KoreaderUnmatchedBook[]>([])
const manualHashLinks = ref<KoreaderManualHashLink[]>([])
const loading = ref(false)
const unmatchedLoading = ref(false)
const manualLinksLoading = ref(false)
export function useKoreaderSync() {
async function fetchSyncStatus(): Promise<void> {
@@ -50,6 +65,8 @@ export function useKoreaderSync() {
if (!res.ok) throw new Error('Failed to delete credentials')
credentials.value = null
syncStatus.value = null
unmatchedBooks.value = []
manualHashLinks.value = []
}
async function testConnection(username: string, password: string): Promise<boolean> {
@@ -78,17 +95,97 @@ export function useKoreaderSync() {
triggerBlobDownload(blob, 'bookorbit.koplugin.zip')
}
async function fetchUnmatchedBooks(): Promise<void> {
unmatchedLoading.value = true
try {
const res = await api('/api/v1/koreader/unmatched-books')
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || 'Failed to fetch unmatched KOReader books')
}
unmatchedBooks.value = await res.json()
} finally {
unmatchedLoading.value = false
}
}
async function fetchManualHashLinks(): Promise<void> {
manualLinksLoading.value = true
try {
const res = await api('/api/v1/koreader/hash-links')
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || 'Failed to fetch KOReader manual links')
}
manualHashLinks.value = await res.json()
} finally {
manualLinksLoading.value = false
}
}
async function linkUnmatchedBook(hash: string, payload: LinkKoreaderUnmatchedBookPayload): Promise<LinkKoreaderUnmatchedBookResult> {
const res = await api(`/api/v1/koreader/unmatched-books/${hash}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
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)
await Promise.all([fetchSyncStatus(), fetchManualHashLinks()])
return result
}
async function relinkManualHashLink(hash: string, payload: UpdateKoreaderManualHashLinkPayload): Promise<LinkKoreaderUnmatchedBookResult> {
const res = await api(`/api/v1/koreader/hash-links/${hash}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || 'Failed to update KOReader manual link')
}
const result: LinkKoreaderUnmatchedBookResult = await res.json()
await Promise.all([fetchSyncStatus(), fetchManualHashLinks()])
return result
}
async function unlinkManualHashLink(hash: string): Promise<UnlinkKoreaderManualHashLinkResult> {
const res = await api(`/api/v1/koreader/hash-links/${hash}`, { method: 'DELETE' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || 'Failed to unlink KOReader manual link')
}
const result: UnlinkKoreaderManualHashLinkResult = await res.json()
manualHashLinks.value = manualHashLinks.value.filter((link) => link.hash !== result.hash)
await Promise.all([fetchSyncStatus(), fetchUnmatchedBooks()])
return result
}
return {
credentials,
syncStatus,
unmatchedBooks,
manualHashLinks,
loading,
unmatchedLoading,
manualLinksLoading,
fetchSyncStatus,
fetchUnmatchedBooks,
fetchManualHashLinks,
createCredentials,
updateCredentials,
deleteCredentials,
testConnection,
getSyncUrl,
downloadPluginPackage,
linkUnmatchedBook,
relinkManualHashLink,
unlinkManualHashLink,
}
}
@@ -1,40 +1,56 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import {
AlertTriangle,
BookOpen,
Calendar,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Copy,
Download,
Eye,
EyeOff,
Library,
Link2,
RefreshCw,
Search,
Smartphone,
Trash2,
User,
X,
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import type { BookCard, KoreaderManualHashLink, KoreaderUnmatchedBook } from '@bookorbit/types'
import SettingsPageHeader from './SettingsPageHeader.vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { copyToClipboard } from '@/lib/clipboard'
import { useKoreaderSync } from '@/features/koreader/composables/useKoreaderSync'
import { useGlobalSearch } from '@/features/book/composables/useGlobalSearch'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
const {
credentials,
syncStatus,
unmatchedBooks,
manualHashLinks,
loading,
unmatchedLoading,
manualLinksLoading,
fetchSyncStatus,
fetchUnmatchedBooks,
fetchManualHashLinks,
createCredentials,
updateCredentials,
deleteCredentials,
getSyncUrl,
downloadPluginPackage,
linkUnmatchedBook,
relinkManualHashLink,
unlinkManualHashLink,
} = useKoreaderSync()
const error = ref<string | null>(null)
@@ -46,6 +62,24 @@ const showPassword = ref(false)
const deleteConfirmOpen = ref(false)
const helpOpen = ref(false)
const urlCopied = ref(false)
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)
const unlinkConfirmLink = ref<KoreaderManualHashLink | null>(null)
const unlinkingHash = ref<string | null>(null)
const unmatchedPage = ref(1)
const unmatchedPageSize = 6
const {
results: linkSearchResults,
loading: linkSearchLoading,
loadingMore: linkSearchLoadingMore,
settled: linkSearchSettled,
hasMore: linkSearchHasMore,
loadMore: loadMoreLinkSearch,
clear: clearLinkSearch,
} = useGlobalSearch(linkSearchQuery)
let urlCopiedTimer: ReturnType<typeof setTimeout> | null = null
@@ -67,6 +101,7 @@ const pluginTotals = computed(
trashedAnnotations: 0,
pendingDeletes: 0,
failedPositions: 0,
unmatchedBooks: 0,
},
)
const latestPluginVersion = computed(() => syncStatus.value?.latestPluginVersion ?? null)
@@ -74,6 +109,16 @@ const pluginUpdateAvailable = computed(() => syncStatus.value?.pluginUpdateAvail
const latestPluginLabel = computed(() => (latestPluginVersion.value ? `Latest plugin: v${latestPluginVersion.value}` : 'Latest plugin unavailable'))
const pendingDeletes = computed(() => pluginTotals.value.pendingDeletes)
const failedPositions = computed(() => pluginTotals.value.failedPositions)
const unmatchedCount = computed(() => Math.max(pluginTotals.value.unmatchedBooks, unmatchedBooks.value.length))
const unmatchedTotalPages = computed(() => Math.max(1, Math.ceil(unmatchedBooks.value.length / unmatchedPageSize)))
const clampedUnmatchedPage = computed(() => Math.min(unmatchedPage.value, unmatchedTotalPages.value))
const pagedUnmatchedBooks = computed(() => {
const start = (clampedUnmatchedPage.value - 1) * unmatchedPageSize
return unmatchedBooks.value.slice(start, start + unmatchedPageSize)
})
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 hasPluginActivity = computed(
() =>
sweeps.value.length > 0 ||
@@ -82,9 +127,28 @@ const hasPluginActivity = computed(
pluginTotals.value.annotations > 0 ||
pluginTotals.value.trashedAnnotations > 0 ||
pendingDeletes.value > 0 ||
failedPositions.value > 0,
failedPositions.value > 0 ||
unmatchedCount.value > 0,
)
const createDisabled = computed(() => creating.value || !newUsername.value || newPassword.value.length < 6)
const linkDialogOpen = computed(() => !!selectedUnmatched.value || !!selectedManualLink.value)
const selectedLinkHash = computed(() => selectedUnmatched.value?.hash ?? selectedManualLink.value?.hash ?? '')
const selectedLinkLabel = computed(() => {
if (selectedUnmatched.value) return unmatchedBookTitle(selectedUnmatched.value)
if (selectedManualLink.value) return manualLinkTitle(selectedManualLink.value)
return ''
})
const selectedLinkSubtitle = computed(() => {
if (selectedUnmatched.value) return unmatchedBookSubtitle(selectedUnmatched.value)
if (selectedManualLink.value) return manualLinkSubtitle(selectedManualLink.value)
return ''
})
const selectedLinkLastOpen = computed(() => selectedUnmatched.value?.lastOpen ?? selectedManualLink.value?.koreaderLastOpen ?? null)
const linkDialogTitle = computed(() => (selectedManualLink.value ? 'Change KOReader link' : 'Link KOReader book'))
watch(unmatchedTotalPages, (totalPages) => {
if (unmatchedPage.value > totalPages) unmatchedPage.value = totalPages
})
function formatLastSync(dateStr: string | null): string {
if (!dateStr) return 'Never'
@@ -105,6 +169,46 @@ function formatDate(dateStr: string | null | undefined): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(dateStr))
}
function formatDateTime(dateStr: string | null | undefined): string {
if (!dateStr) return 'Unknown'
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(dateStr))
}
function formatEpochSeconds(seconds: number | null | undefined): string {
if (!seconds) return 'Unknown'
return formatDateTime(new Date(seconds * 1000).toISOString())
}
function shortHash(hash: string): string {
return hash.slice(0, 8)
}
function unmatchedBookTitle(book: KoreaderUnmatchedBook): string {
return book.title?.trim() || `Unknown KOReader file ${shortHash(book.hash)}`
}
function unmatchedBookSubtitle(book: KoreaderUnmatchedBook): string {
if (book.authors?.trim()) return book.authors
return book.title?.trim() ? 'Unknown author' : 'No title or author reported'
}
function manualLinkTitle(link: KoreaderManualHashLink): string {
return link.koreaderTitle?.trim() || `Unknown KOReader file ${shortHash(link.hash)}`
}
function manualLinkSubtitle(link: KoreaderManualHashLink): string {
if (link.koreaderAuthors?.trim()) return link.koreaderAuthors
return link.koreaderTitle?.trim() ? 'Unknown author' : 'No title or author reported'
}
function linkedBookTitle(link: KoreaderManualHashLink): string {
return link.bookTitle?.trim() || `BookOrbit book #${link.bookId}`
}
function linkedBookAuthors(link: KoreaderManualHashLink): string {
return link.bookAuthors.length > 0 ? link.bookAuthors.join(', ') : 'Unknown author'
}
function formatCount(value: number, singular: string, plural = `${singular}s`): string {
return `${value} ${value === 1 ? singular : plural}`
}
@@ -124,6 +228,7 @@ function pluginUpdateClass(updateAvailable: boolean | null): string {
onMounted(async () => {
try {
await fetchSyncStatus()
if (credentials.value) await Promise.all([fetchUnmatchedBooks(), fetchManualHashLinks()])
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load KOReader settings'
}
@@ -207,12 +312,129 @@ async function handleCopyUrl() {
async function handleRefresh() {
try {
await fetchSyncStatus()
if (credentials.value) await Promise.all([fetchUnmatchedBooks(), fetchManualHashLinks()])
toast.success('Sync status refreshed')
} catch {
toast.error('Failed to refresh')
}
}
async function handleRefreshUnmatched() {
try {
await fetchUnmatchedBooks()
unmatchedPage.value = 1
toast.success('Unmatched KOReader books refreshed')
} catch {
toast.error('Failed to refresh unmatched books')
}
}
function handlePreviousUnmatchedPage() {
unmatchedPage.value = Math.max(1, clampedUnmatchedPage.value - 1)
}
function handleNextUnmatchedPage() {
unmatchedPage.value = Math.min(unmatchedTotalPages.value, clampedUnmatchedPage.value + 1)
}
async function handleRefreshManualLinks() {
try {
await fetchManualHashLinks()
toast.success('Manual KOReader links refreshed')
} catch {
toast.error('Failed to refresh manual links')
}
}
function handleOpenLink(book: KoreaderUnmatchedBook) {
selectedUnmatched.value = book
selectedManualLink.value = null
pendingLinkTarget.value = null
clearLinkSearch()
linkSearchQuery.value = book.title ?? ''
}
function handleOpenRelink(link: KoreaderManualHashLink) {
selectedManualLink.value = link
selectedUnmatched.value = null
pendingLinkTarget.value = null
clearLinkSearch()
linkSearchQuery.value = link.koreaderTitle ?? link.bookTitle ?? ''
}
function handleCloseLink() {
selectedUnmatched.value = null
selectedManualLink.value = null
pendingLinkTarget.value = null
linkingBookId.value = null
linkSearchQuery.value = ''
clearLinkSearch()
}
function handleChooseLinkTarget(book: BookCard) {
pendingLinkTarget.value = book
}
function handleClearLinkTarget() {
pendingLinkTarget.value = null
}
async function handleLoadMoreLinkSearch() {
await loadMoreLinkSearch()
}
async function handleConfirmLinkTarget() {
if (!pendingLinkTarget.value || !selectedLinkHash.value) return
const target = pendingLinkTarget.value
linkingBookId.value = target.id
try {
if (selectedManualLink.value) {
await relinkManualHashLink(selectedLinkHash.value, { bookId: target.id })
toast.success('KOReader link updated')
} else {
await linkUnmatchedBook(selectedLinkHash.value, { bookId: target.id })
toast.success('KOReader book linked')
}
handleCloseLink()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to link KOReader book')
} finally {
linkingBookId.value = null
}
}
function handleOpenUnlink(link: KoreaderManualHashLink) {
unlinkConfirmLink.value = link
}
function handleCloseUnlink() {
unlinkConfirmLink.value = null
unlinkingHash.value = null
}
async function handleUnlinkManualLink() {
if (!unlinkConfirmLink.value) return
unlinkingHash.value = unlinkConfirmLink.value.hash
try {
await unlinkManualHashLink(unlinkConfirmLink.value.hash)
toast.success('KOReader link removed')
handleCloseUnlink()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to unlink KOReader book')
} finally {
unlinkingHash.value = null
}
}
function bookSearchSubtitle(book: BookCard): string {
const authors = book.authors.length > 0 ? book.authors.join(', ') : 'Unknown author'
const formats = book.files
.map((file) => file.format)
.filter(Boolean)
.join(', ')
return formats ? `${authors} - ${formats}` : authors
}
const downloadingPlugin = ref(false)
async function handleDownloadPlugin() {
@@ -477,7 +699,7 @@ async function handleDownloadPlugin() {
<Library :size="14" class="text-muted-foreground shrink-0" />
<p class="settings-label">Synced totals</p>
</div>
<div class="grid gap-2 text-xs text-muted-foreground sm:grid-cols-2 lg:grid-cols-4">
<div class="grid gap-2 text-xs text-muted-foreground sm:grid-cols-2 lg:grid-cols-5">
<div class="rounded-md border border-border bg-background px-3 py-2">
<p class="font-medium text-foreground">{{ pluginTotals.matchedBooks }}</p>
<p>Matched books</p>
@@ -494,6 +716,10 @@ async function handleDownloadPlugin() {
<p class="font-medium text-foreground">{{ pluginTotals.trashedAnnotations }}</p>
<p>Trashed highlights</p>
</div>
<div class="rounded-md border border-border bg-background px-3 py-2">
<p class="font-medium text-foreground">{{ unmatchedCount }}</p>
<p>Unmatched books</p>
</div>
</div>
<div v-if="pendingDeletes > 0" class="mt-3 flex gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-foreground">
<AlertTriangle :size="14" class="mt-0.5 shrink-0 text-muted-foreground" />
@@ -507,6 +733,128 @@ async function handleDownloadPlugin() {
</div>
</div>
<div class="mb-8">
<div class="flex items-center justify-between gap-3 mb-3">
<p class="settings-group-label mb-0">Unmatched KOReader Books</p>
<button class="settings-btn-outline" :disabled="unmatchedLoading" @click="handleRefreshUnmatched">
<RefreshCw :size="12" />
Refresh
</button>
</div>
<div class="border border-border rounded-lg overflow-hidden shadow-xs divide-y divide-border">
<div v-if="unmatchedLoading" class="px-4 py-5 bg-card text-sm text-muted-foreground md:px-5">Loading unmatched books...</div>
<div v-else-if="unmatchedBooks.length === 0" class="px-4 py-5 bg-card text-sm text-muted-foreground md:px-5">
No unmatched KOReader books.
</div>
<template v-else>
<div v-for="book in pagedUnmatchedBooks" :key="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">
<p class="settings-label truncate">{{ unmatchedBookTitle(book) }}</p>
<p class="settings-hint truncate">{{ unmatchedBookSubtitle(book) }}</p>
<div class="mt-2 grid gap-1 text-xs text-muted-foreground sm:grid-cols-2">
<p class="min-w-0 truncate">
Hash: <span class="font-mono text-foreground/75">{{ book.hash }}</span>
</p>
<p>
Last opened: <span class="text-foreground/75">{{ formatEpochSeconds(book.lastOpen) }}</span>
</p>
<p>
First seen: <span class="text-foreground/75">{{ formatDateTime(book.firstSeenAt) }}</span>
</p>
<p>
Last seen: <span class="text-foreground/75">{{ formatDateTime(book.lastSeenAt) }}</span>
</p>
</div>
</div>
<button class="settings-btn-primary self-start md:self-auto" @click="handleOpenLink(book)">
<Link2 :size="12" />
Link
</button>
</div>
</div>
<div
v-if="showUnmatchedPager"
class="flex flex-col gap-3 px-4 py-3 bg-card text-xs text-muted-foreground md:flex-row md:items-center md:justify-between md:px-5"
>
<p>Showing {{ unmatchedPageStart }}-{{ unmatchedPageEnd }} of {{ unmatchedBooks.length }}</p>
<div class="flex flex-wrap items-center gap-2">
<button class="settings-btn-outline" :disabled="clampedUnmatchedPage <= 1" @click="handlePreviousUnmatchedPage">
<ChevronLeft :size="12" />
Previous
</button>
<span class="min-w-20 text-center">Page {{ clampedUnmatchedPage }} of {{ unmatchedTotalPages }}</span>
<button class="settings-btn-outline" :disabled="clampedUnmatchedPage >= unmatchedTotalPages" @click="handleNextUnmatchedPage">
Next
<ChevronRight :size="12" />
</button>
</div>
</div>
</template>
</div>
</div>
<div class="mb-8">
<div class="flex items-center justify-between gap-3 mb-3">
<p class="settings-group-label mb-0">Manual KOReader Links</p>
<button class="settings-btn-outline" :disabled="manualLinksLoading" @click="handleRefreshManualLinks">
<RefreshCw :size="12" />
Refresh
</button>
</div>
<div class="border border-border rounded-lg overflow-hidden shadow-xs divide-y divide-border">
<div v-if="manualLinksLoading" class="px-4 py-5 bg-card text-sm text-muted-foreground md:px-5">Loading manual links...</div>
<div v-else-if="manualHashLinks.length === 0" class="px-4 py-5 bg-card text-sm text-muted-foreground md:px-5">
No manual KOReader links.
</div>
<template v-else>
<div v-for="link in manualHashLinks" :key="link.hash" class="px-4 py-4 bg-card md:px-5">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="grid min-w-0 flex-1 gap-4 md:grid-cols-2">
<div class="min-w-0">
<p class="settings-label truncate">{{ manualLinkTitle(link) }}</p>
<p class="settings-hint truncate">{{ manualLinkSubtitle(link) }}</p>
<div class="mt-2 grid gap-1 text-xs text-muted-foreground">
<p class="min-w-0 truncate">
Hash: <span class="font-mono text-foreground/75">{{ link.hash }}</span>
</p>
<p>
Last opened: <span class="text-foreground/75">{{ formatEpochSeconds(link.koreaderLastOpen) }}</span>
</p>
</div>
</div>
<div class="min-w-0">
<p class="settings-label truncate">Linked to {{ linkedBookTitle(link) }}</p>
<p class="settings-hint truncate">{{ linkedBookAuthors(link) }}</p>
<div class="mt-2 grid gap-1 text-xs text-muted-foreground">
<p>
Linked: <span class="text-foreground/75">{{ formatDateTime(link.createdAt) }}</span>
</p>
<p>
Updated: <span class="text-foreground/75">{{ formatDateTime(link.updatedAt) }}</span>
</p>
</div>
</div>
</div>
<div class="flex gap-2 self-start">
<button class="settings-btn-outline" @click="handleOpenRelink(link)">
<Link2 :size="12" />
Change
</button>
<button
class="flex items-center gap-1.5 rounded-md border border-destructive/30 px-3 py-2 text-xs font-medium text-destructive hover:bg-destructive/10 transition-colors"
@click="handleOpenUnlink(link)"
>
<Trash2 :size="12" />
Unlink
</button>
</div>
</div>
</div>
</template>
</div>
</div>
<div class="mb-8">
<p class="settings-group-label">Setup Guide</p>
<div class="border border-border rounded-lg bg-card shadow-xs">
@@ -600,5 +948,135 @@ async function handleDownloadPlugin() {
</div>
</div>
</div>
<div v-if="unlinkConfirmLink" class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4" @click.self="handleCloseUnlink">
<button class="absolute inset-0 bg-black/45" @click="handleCloseUnlink" />
<div class="relative w-full rounded-t-lg border border-border bg-card p-4 shadow-xl md:max-w-md md:rounded-lg md:p-5">
<p class="text-base font-semibold text-foreground">Unlink KOReader book?</p>
<p class="mt-1 text-sm text-muted-foreground">
Future syncs for this hash will stop resolving to {{ linkedBookTitle(unlinkConfirmLink) }} until it is linked again. Already synced stats
will stay on their current BookOrbit book.
</p>
<div class="mt-4 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<p class="font-mono text-foreground/75 truncate">{{ unlinkConfirmLink.hash }}</p>
<p class="mt-1 truncate">{{ manualLinkTitle(unlinkConfirmLink) }}</p>
</div>
<div class="mt-4 flex items-center justify-end gap-2">
<button
class="rounded-md border border-border px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
@click="handleCloseUnlink"
>
Cancel
</button>
<button
class="rounded-md bg-destructive px-3 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:opacity-60"
:disabled="unlinkingHash !== null"
@click="handleUnlinkManualLink"
>
{{ unlinkingHash === unlinkConfirmLink.hash ? 'Unlinking...' : 'Unlink' }}
</button>
</div>
</div>
</div>
<div
v-if="linkDialogOpen"
class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4"
@click.self="handleCloseLink"
@keydown.esc="handleCloseLink"
>
<button class="absolute inset-0 bg-black/45" @click="handleCloseLink" />
<div class="relative w-full rounded-t-lg border border-border bg-card p-4 shadow-xl md:max-w-2xl md:rounded-lg md:p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-base font-semibold text-foreground">{{ linkDialogTitle }}</p>
<p class="mt-1 text-sm text-muted-foreground truncate">{{ selectedLinkLabel }}</p>
</div>
<button class="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground" @click="handleCloseLink">
<X :size="16" />
</button>
</div>
<div class="mt-4">
<label class="block text-xs font-medium text-muted-foreground mb-1.5">BookOrbit book</label>
<div class="relative">
<Search :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
v-model="linkSearchQuery"
type="search"
class="input-field w-full pl-9"
autocomplete="off"
placeholder="Search your BookOrbit library"
/>
</div>
</div>
<div class="mt-4 max-h-[55vh] overflow-y-auto rounded-md border border-border divide-y divide-border">
<div v-if="linkSearchQuery.trim().length < 2" class="px-3 py-4 text-sm text-muted-foreground">Enter at least 2 characters.</div>
<div v-else-if="linkSearchLoading" class="px-3 py-4 text-sm text-muted-foreground">Searching...</div>
<div v-else-if="linkSearchSettled && linkSearchResults.length === 0" class="px-3 py-4 text-sm text-muted-foreground">No books found.</div>
<template v-else>
<button
v-for="book in linkSearchResults"
:key="book.id"
class="flex w-full items-start justify-between gap-3 px-3 py-3 text-left hover:bg-muted/70 disabled:opacity-60"
:disabled="linkingBookId !== null"
@click="handleChooseLinkTarget(book)"
>
<span class="min-w-0">
<span class="block text-sm font-medium text-foreground truncate">{{ book.title ?? 'Untitled book' }}</span>
<span class="block text-xs text-muted-foreground truncate">{{ bookSearchSubtitle(book) }}</span>
</span>
<span class="shrink-0 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground">
{{ pendingLinkTarget?.id === book.id ? 'Selected' : 'Select' }}
</span>
</button>
<button
v-if="linkSearchHasMore"
class="flex w-full items-center justify-center gap-2 px-3 py-3 text-sm font-medium text-foreground hover:bg-muted/70 disabled:opacity-60"
:disabled="linkSearchLoadingMore"
@click="handleLoadMoreLinkSearch"
>
<ChevronDown :size="14" />
{{ linkSearchLoadingMore ? 'Loading...' : 'Load more' }}
</button>
</template>
</div>
<div v-if="pendingLinkTarget" class="mt-4 rounded-md border border-border bg-muted/30 p-3">
<p class="text-sm font-medium text-foreground">Confirm KOReader link</p>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<div class="min-w-0 rounded-md border border-border bg-background px-3 py-2">
<p class="text-xs font-medium text-muted-foreground">KOReader</p>
<p class="mt-1 text-sm font-medium text-foreground truncate">{{ selectedLinkLabel }}</p>
<p class="text-xs text-muted-foreground truncate">{{ selectedLinkSubtitle }}</p>
<p class="mt-1 text-xs text-muted-foreground">
Last opened: <span class="text-foreground/75">{{ formatEpochSeconds(selectedLinkLastOpen) }}</span>
</p>
<p class="mt-1 font-mono text-xs text-muted-foreground truncate">{{ selectedLinkHash }}</p>
</div>
<div class="min-w-0 rounded-md border border-border bg-background px-3 py-2">
<p class="text-xs font-medium text-muted-foreground">BookOrbit</p>
<p class="mt-1 text-sm font-medium text-foreground truncate">{{ pendingLinkTarget.title ?? 'Untitled book' }}</p>
<p class="text-xs text-muted-foreground truncate">{{ bookSearchSubtitle(pendingLinkTarget) }}</p>
<p class="mt-1 text-xs text-muted-foreground">Book ID: {{ pendingLinkTarget.id }}</p>
</div>
</div>
<div class="mt-3 flex gap-2 rounded-md border border-border bg-background px-3 py-2 text-xs text-muted-foreground">
<AlertTriangle :size="14" class="mt-0.5 shrink-0" />
<p>Already synced stats will stay on their current BookOrbit book.</p>
</div>
<div class="mt-3 flex items-center justify-end gap-2">
<button
class="rounded-md border border-border px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
:disabled="linkingBookId !== null"
@click="handleClearLinkTarget"
>
Choose different
</button>
<button class="settings-btn-primary" :disabled="linkingBookId !== null" @click="handleConfirmLinkTarget">
{{ linkingBookId === pendingLinkTarget.id ? 'Saving...' : 'Confirm link' }}
</button>
</div>
</div>
</div>
</div>
</template>
</template>
@@ -1,25 +1,49 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import KoreaderSettings from '../KoreaderSettings.vue'
import type { KoreaderCredentials, KoreaderSyncStatus } from '@bookorbit/types'
import type { BookCard, KoreaderCredentials, KoreaderManualHashLink, KoreaderSyncStatus, KoreaderUnmatchedBook } from '@bookorbit/types'
import { copyToClipboard } from '@/lib/clipboard'
const koreaderMock = vi.hoisted(() => ({
credentials: { __v_isRef: true, value: null as KoreaderCredentials | null },
syncStatus: { __v_isRef: true, value: null as KoreaderSyncStatus | null },
unmatchedBooks: { __v_isRef: true, value: [] as KoreaderUnmatchedBook[] },
manualHashLinks: { __v_isRef: true, value: [] as KoreaderManualHashLink[] },
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>>(),
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>>(),
relinkManualHashLink: vi.fn<() => Promise<void>>(),
unlinkManualHashLink: vi.fn<() => Promise<void>>(),
}))
const searchMock = vi.hoisted(() => ({
results: { __v_isRef: true, value: [] as BookCard[] },
total: { __v_isRef: true, value: 0 },
loading: { __v_isRef: true, value: false },
loadingMore: { __v_isRef: true, value: false },
settled: { __v_isRef: true, value: true },
hasMore: { __v_isRef: true, value: false },
loadMore: vi.fn<() => Promise<void>>(),
clear: vi.fn<() => void>(),
}))
vi.mock('@/features/koreader/composables/useKoreaderSync', () => ({
useKoreaderSync: () => koreaderMock,
}))
vi.mock('@/features/book/composables/useGlobalSearch', () => ({
useGlobalSearch: () => searchMock,
}))
vi.mock('vue-sonner', () => ({
toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() },
}))
@@ -69,11 +93,83 @@ function makeSyncStatus(overrides: Partial<KoreaderSyncStatus> = {}): KoreaderSy
lastSweepAnnotations: 8,
},
],
pluginTotals: { matchedBooks: 12, pageStatEvents: 30, annotations: 8, trashedAnnotations: 1, pendingDeletes: 2, failedPositions: 3 },
pluginTotals: {
matchedBooks: 12,
pageStatEvents: 30,
annotations: 8,
trashedAnnotations: 1,
pendingDeletes: 2,
failedPositions: 3,
unmatchedBooks: 0,
},
...overrides,
}
}
function makeUnmatchedBook(overrides: Partial<KoreaderUnmatchedBook> = {}): KoreaderUnmatchedBook {
return {
hash: 'a'.repeat(32),
title: 'KOReader Stats Title',
authors: 'Device Author',
lastOpen: 1700000000,
firstSeenAt: '2026-06-01T00:00:00.000Z',
lastSeenAt: '2026-06-02T00:00:00.000Z',
...overrides,
}
}
function makeHash(index: number): string {
return index.toString(16).padStart(32, '0')
}
function makeManualHashLink(overrides: Partial<KoreaderManualHashLink> = {}): KoreaderManualHashLink {
return {
hash: 'b'.repeat(32),
bookId: 70,
bookFileId: 71,
bookTitle: 'Linked BookOrbit Title',
bookAuthors: ['Linked Author'],
koreaderTitle: 'Linked KOReader Title',
koreaderAuthors: 'Device Author',
koreaderLastOpen: 1700000001,
createdAt: '2026-06-03T00:00:00.000Z',
updatedAt: '2026-06-04T00:00:00.000Z',
...overrides,
}
}
function makeBookCard(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 55,
title: 'BookOrbit Title',
authors: ['BookOrbit Author'],
files: [{ id: 44, format: 'epub', role: 'main', sizeBytes: 123 }],
status: 'present',
seriesName: null,
seriesIndex: null,
publishedYear: null,
language: null,
genres: [],
rating: null,
readingProgress: null,
readStatus: null,
addedAt: '2026-01-01T00:00:00.000Z',
updatedAt: null,
metadataScore: null,
hasCover: false,
hasMetadataLocks: false,
lockedFields: [],
subtitle: null,
publisher: null,
pageCount: null,
isbn13: null,
narrators: [],
tags: [],
customMetadata: [],
...overrides,
} as BookCard
}
function mountComponent() {
return mount(KoreaderSettings, { props: { embedded: true } })
}
@@ -87,13 +183,29 @@ describe('KoreaderSettings', () => {
vi.clearAllMocks()
koreaderMock.credentials.value = null
koreaderMock.syncStatus.value = null
koreaderMock.unmatchedBooks.value = []
koreaderMock.manualHashLinks.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.createCredentials.mockResolvedValue(undefined)
koreaderMock.updateCredentials.mockResolvedValue(undefined)
koreaderMock.deleteCredentials.mockResolvedValue(undefined)
koreaderMock.getSyncUrl.mockReturnValue('https://bookorbit.example')
koreaderMock.downloadPluginPackage.mockResolvedValue(undefined)
koreaderMock.linkUnmatchedBook.mockResolvedValue(undefined)
koreaderMock.relinkManualHashLink.mockResolvedValue(undefined)
koreaderMock.unlinkManualHashLink.mockResolvedValue(undefined)
searchMock.results.value = []
searchMock.loading.value = false
searchMock.loadingMore.value = false
searchMock.settled.value = true
searchMock.hasMore.value = false
searchMock.loadMore.mockClear()
searchMock.clear.mockClear()
})
it('shows loading state', () => {
@@ -144,6 +256,8 @@ describe('KoreaderSettings', () => {
'Setup',
'Devices',
'Plugin Activity',
'Unmatched KOReader Books',
'Manual KOReader Links',
'Setup Guide',
'Danger Zone',
])
@@ -158,6 +272,8 @@ describe('KoreaderSettings', () => {
expect(wrapper.text()).toContain('Update available')
expect(wrapper.text()).toContain('latest plugin v0.5.0')
expect(wrapper.text()).toContain('Matched books')
expect(wrapper.text()).toContain('No unmatched KOReader books.')
expect(wrapper.text()).toContain('No manual KOReader links.')
expect(wrapper.text()).toContain('2 deleted highlights awaiting KOReader plugin acknowledgement.')
expect(wrapper.text()).toContain('3 highlight positions need attention.')
expect(wrapper.text()).not.toContain('Download the preconfigured plugin above.')
@@ -251,6 +367,8 @@ describe('KoreaderSettings', () => {
await flushPromises()
expect(koreaderMock.fetchSyncStatus).toHaveBeenCalledTimes(2)
expect(koreaderMock.fetchUnmatchedBooks).toHaveBeenCalledTimes(2)
expect(koreaderMock.fetchManualHashLinks).toHaveBeenCalledTimes(2)
expect(koreaderMock.updateCredentials).toHaveBeenCalledWith({ syncEnabled: false })
expect(vi.mocked(copyToClipboard)).toHaveBeenCalledWith('https://bookorbit.example')
expect(koreaderMock.downloadPluginPackage).toHaveBeenCalledTimes(1)
@@ -261,4 +379,236 @@ describe('KoreaderSettings', () => {
expect(koreaderMock.deleteCredentials).toHaveBeenCalledTimes(1)
})
it('opens the unmatched link dialog and submits the selected BookOrbit book', 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')
expect(wrapper.text()).toContain('Link KOReader book')
expect(wrapper.text()).toContain('BookOrbit Title')
const bookResult = wrapper.findAll('button').find((button) => button.text().includes('BookOrbit Title'))!
await bookResult.trigger('click')
expect(wrapper.text()).toContain('Confirm KOReader link')
expect(wrapper.text()).toContain('Already synced stats will stay on their current BookOrbit book.')
await buttonByText(wrapper, 'Confirm link')!.trigger('click')
await flushPromises()
expect(koreaderMock.linkUnmatchedBook).toHaveBeenCalledWith('a'.repeat(32), { bookId: 55 })
})
it('loads more BookOrbit search results from the link dialog', 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()]
searchMock.hasMore.value = true
const wrapper = mountComponent()
await flushPromises()
await buttonByText(wrapper, 'Link')!.trigger('click')
await buttonByText(wrapper, 'Load more')!.trigger('click')
expect(searchMock.loadMore).toHaveBeenCalledTimes(1)
})
it('renders manual links and can relink with confirmation', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.manualHashLinks.value = [makeManualHashLink()]
searchMock.results.value = [makeBookCard({ id: 88, title: 'Replacement Book', authors: ['Replacement Author'] })]
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('Linked KOReader Title')
expect(wrapper.text()).toContain('Linked to Linked BookOrbit Title')
await buttonByText(wrapper, 'Change')!.trigger('click')
await wrapper
.findAll('button')
.find((button) => button.text().includes('Replacement Book'))!
.trigger('click')
await buttonByText(wrapper, 'Confirm link')!.trigger('click')
await flushPromises()
expect(koreaderMock.relinkManualHashLink).toHaveBeenCalledWith('b'.repeat(32), { bookId: 88 })
})
it('confirms before unlinking a manual hash link', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.manualHashLinks.value = [makeManualHashLink()]
const wrapper = mountComponent()
await flushPromises()
await buttonByText(wrapper, 'Unlink')!.trigger('click')
expect(wrapper.text()).toContain('Unlink KOReader book?')
expect(wrapper.text()).toContain('Already synced stats will stay on their current BookOrbit book.')
const unlinkButtons = wrapper.findAll('button').filter((button) => button.text() === 'Unlink')
await unlinkButtons[unlinkButtons.length - 1]!.trigger('click')
await flushPromises()
expect(koreaderMock.unlinkManualHashLink).toHaveBeenCalledWith('b'.repeat(32))
})
it('renders hash-only unmatched rows with a distinguishable fallback label', async () => {
const hash = 'c1aa241d98bcdbc5c52b2eff31c4920e'
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook({ hash, title: null, authors: null, lastOpen: null })]
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('Unknown KOReader file c1aa241d')
expect(wrapper.text()).toContain('No title or author reported')
})
it('paginates unmatched KOReader rows six at a time', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 7 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = Array.from({ length: 7 }, (_, index) =>
makeUnmatchedBook({
hash: makeHash(index + 1),
title: `KOReader Row ${index + 1}`,
}),
)
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('KOReader Row 1')
expect(wrapper.text()).toContain('KOReader Row 6')
expect(wrapper.text()).not.toContain('KOReader Row 7')
expect(wrapper.text()).toContain('Showing 1-6 of 7')
expect(wrapper.text()).toContain('Page 1 of 2')
await buttonByText(wrapper, 'Next')!.trigger('click')
expect(wrapper.text()).not.toContain('KOReader Row 1')
expect(wrapper.text()).toContain('KOReader Row 7')
expect(wrapper.text()).toContain('Showing 7-7 of 7')
expect(wrapper.text()).toContain('Page 2 of 2')
await buttonByText(wrapper, 'Previous')!.trigger('click')
expect(wrapper.text()).toContain('KOReader Row 1')
expect(wrapper.text()).not.toContain('KOReader Row 7')
expect(wrapper.text()).toContain('Showing 1-6 of 7')
expect(wrapper.text()).toContain('Page 1 of 2')
})
it('refreshes unmatched books and resets to the first page', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 7 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = Array.from({ length: 7 }, (_, index) => makeUnmatchedBook({ hash: makeHash(index + 1) }))
koreaderMock.fetchUnmatchedBooks.mockResolvedValue(undefined)
const wrapper = mountComponent()
await flushPromises()
await buttonByText(wrapper, 'Next')!.trigger('click')
expect(wrapper.text()).toContain('Page 2 of 2')
const refreshButtons = wrapper.findAll('button').filter((button) => button.text() === 'Refresh')
await refreshButtons[1]!.trigger('click')
await flushPromises()
expect(koreaderMock.fetchUnmatchedBooks).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('Page 1 of 2')
})
it('shows an error toast when refreshing unmatched books fails', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
koreaderMock.fetchUnmatchedBooks.mockReset()
koreaderMock.fetchUnmatchedBooks.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('network down'))
const wrapper = mountComponent()
await flushPromises()
const refreshButtons = wrapper.findAll('button').filter((button) => button.text() === 'Refresh')
await refreshButtons[1]!.trigger('click')
await flushPromises()
expect(koreaderMock.fetchUnmatchedBooks).toHaveBeenCalledTimes(2)
})
it('refreshes manual KOReader links', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.manualHashLinks.value = [makeManualHashLink()]
koreaderMock.fetchManualHashLinks.mockResolvedValue(undefined)
const wrapper = mountComponent()
await flushPromises()
const refreshButtons = wrapper.findAll('button').filter((button) => button.text() === 'Refresh')
await refreshButtons[2]!.trigger('click')
await flushPromises()
expect(koreaderMock.fetchManualHashLinks).toHaveBeenCalledTimes(2)
})
it('shows an error toast when refreshing manual KOReader links fails', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.manualHashLinks.value = [makeManualHashLink()]
koreaderMock.fetchManualHashLinks.mockReset()
koreaderMock.fetchManualHashLinks.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('network down'))
const wrapper = mountComponent()
await flushPromises()
const refreshButtons = wrapper.findAll('button').filter((button) => button.text() === 'Refresh')
await refreshButtons[2]!.trigger('click')
await flushPromises()
expect(koreaderMock.fetchManualHashLinks).toHaveBeenCalledTimes(2)
})
it('returns to search results when choosing a different book from the confirm step', 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')
const bookResult = wrapper.findAll('button').find((button) => button.text().includes('BookOrbit Title'))!
await bookResult.trigger('click')
expect(wrapper.text()).toContain('Confirm KOReader link')
expect(wrapper.text()).toContain('Book ID: 55')
await buttonByText(wrapper, 'Choose different')!.trigger('click')
expect(wrapper.text()).not.toContain('Confirm KOReader link')
expect(wrapper.text()).not.toContain('Book ID: 55')
expect(wrapper.text()).toContain('BookOrbit Title')
})
})
@@ -210,8 +210,23 @@ end
-- BookOrbit plugin endpoints (camelCase wire format)
function BookOrbitApi:matchCheck(hashes)
return self:request("POST", "/koreader/plugin/match-check", self:withDevice({ hashes = hashes }))
function BookOrbitApi:matchCheck(hashes, candidates)
local payload = { hashes = hashes }
if candidates then
payload.books = {}
for _, hash in ipairs(hashes) do
local cand = candidates[hash] or {}
table.insert(payload.books, {
hash = hash,
title = cand.title,
authors = cand.authors,
lastOpen = cand.last_open,
source = cand.source,
metadataAmbiguous = cand.metadata_ambiguous,
})
end
end
return self:request("POST", "/koreader/plugin/match-check", self:withDevice(payload))
end
function BookOrbitApi:uploadPageStats(books)
@@ -32,6 +32,15 @@ local BookOrbitBookSync = {
running = false,
}
local function titleFromFile(file)
if not file or file == "" then return nil end
local name = file:gsub(".*/", "")
if name == "" then return nil end
local title = name:gsub("%.[^%.]+$", "")
if title == "" then return name end
return title
end
function BookOrbitBookSync.isRunning()
return BookOrbitBookSync.running
end
@@ -55,9 +64,17 @@ function BookOrbitBookSync.capture(plugin)
end
local file = ui.document.file
local ts = os.time()
local metadata = BookOrbitStatsReader.getBook(digest) or {}
local stats_ambiguous = metadata.metadata_ambiguous == true
return {
digest = digest,
file = file,
title = stats_ambiguous and titleFromFile(file) or (metadata.title or titleFromFile(file)),
authors = stats_ambiguous and nil or metadata.authors,
last_open = metadata.last_open or ts,
metadata_ambiguous = false,
stats_metadata_ambiguous = stats_ambiguous,
percentage = plugin:getLastPercent(),
progress = plugin:getLastProgress(),
status = summary.status,
@@ -67,7 +84,7 @@ function BookOrbitBookSync.capture(plugin)
ann_count = #annotations,
ann_max_datetime = ann_max,
mtime_at_capture = file and BookOrbitSidecar.sidecarMtime(file) or nil,
ts = os.time(),
ts = ts,
}
end
@@ -82,6 +99,15 @@ local function isTransportError(err)
return type(err) ~= "number"
end
local function applyLibraryVersion(ctx, version)
if not version then return end
local known = ctx.state.global.libraryVersion
if known and version ~= known then
ctx.state.global.needsFullRecheck = true
end
ctx.state.global.libraryVersion = version
end
local function isUnmatched(body, md5)
for _, hash in ipairs(body.unmatched or {}) do
if hash == md5 then return true end
@@ -161,14 +187,19 @@ local stepMatch, stepStats, stepAnnotations, stepAnnotationsLegacy, stepState, s
stepMatch = function(ctx)
local book = ctx.state:getBook(ctx.snap.digest)
if book then
if ctx.snap.file and not book.file then
book.file = ctx.snap.file
end
return step(ctx, stepStats)
if book and ctx.snap.file and not book.file then
book.file = ctx.snap.file
end
local body, err = ctx.client:matchCheck({ ctx.snap.digest })
local body, err = ctx.client:matchCheck({ ctx.snap.digest }, {
[ctx.snap.digest] = {
title = ctx.snap.title,
authors = ctx.snap.authors,
last_open = ctx.snap.last_open,
source = "current_file",
metadata_ambiguous = ctx.snap.metadata_ambiguous,
},
})
if not body then
if isAuthError(err) then return finish(ctx, "auth") end
return finish(ctx, "network")
@@ -177,10 +208,13 @@ 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)
applyLibraryVersion(ctx, body.libraryVersion)
return step(ctx, stepStats)
end
end
applyLibraryVersion(ctx, body.libraryVersion)
ctx.state:setUnmatched(ctx.snap.digest)
return finish(ctx, "unmatched")
end
@@ -189,6 +223,10 @@ stepStats = function(ctx)
local book = ctx.state:getBook(ctx.snap.digest)
if not book then return finish(ctx, "unmatched") end
if ctx.snap.stats_metadata_ambiguous then
return step(ctx, stepAnnotations)
end
if ctx.stat_ids == nil then
ctx.stat_ids = BookOrbitStatsReader.getBookIds(ctx.snap.digest)
end
@@ -281,7 +281,7 @@ function CatalogDownload.install(Catalog)
return false
end
local body, err = self.client:matchCheck({ digest })
local body, err = self.client:matchCheck({ digest }, { [digest] = { source = "file" } })
if not body then
logger.warn("BookOrbit: downloaded file match-check failed", err)
return false
@@ -11,6 +11,34 @@ local logger = require("logger")
local BookOrbitStatsReader = {}
local function metadataKey(title, authors)
return (title or "") .. "\0" .. (authors or "")
end
local function applyRow(entry, id, title, authors, last_open)
if title == "" then title = nil end
if authors == "" then authors = nil end
table.insert(entry.ids, id)
if not entry._variant_seen[metadataKey(title, authors)] then
entry._variant_seen[metadataKey(title, authors)] = true
entry._variant_count = entry._variant_count + 1
end
if last_open > (entry.last_open or 0) then
entry.last_open = last_open
if title then entry.title = title end
if authors then entry.authors = authors end
end
if not entry.title and title then entry.title = title end
if not entry.authors and authors then entry.authors = authors end
end
local function finalizeEntry(entry)
entry.metadata_ambiguous = (entry._variant_count or 0) > 1
entry._variant_seen = nil
entry._variant_count = nil
return entry
end
local function dbPath()
return DataStorage:getSettingsDir() .. "/statistics.sqlite3"
end
@@ -30,34 +58,57 @@ local function withConn(fn)
return result
end
-- Returns an array of { md5, ids = {stat book row ids}, last_open, title }.
-- Returns an array of { md5, ids = {stat book row ids}, last_open, title, authors }.
-- Multiple stats rows can share one md5 (the stats unique key is
-- title+authors+md5), so rows are grouped per md5 here.
function BookOrbitStatsReader.getBooks()
return withConn(function(conn)
local res = conn:exec("SELECT id, md5, title, last_open FROM book WHERE md5 IS NOT NULL AND md5 != '';")
local res = conn:exec("SELECT id, md5, title, authors, last_open FROM book WHERE md5 IS NOT NULL AND md5 != '';")
local by_md5 = {}
local list = {}
if res then
for i = 1, #res[1] do
local md5 = res[2][i]
local title = res[3][i]
local authors = res[4][i]
local entry = by_md5[md5]
if not entry then
entry = { md5 = md5, ids = {}, last_open = 0, title = res[3][i] }
entry = { md5 = md5, ids = {}, last_open = 0, _variant_seen = {}, _variant_count = 0 }
by_md5[md5] = entry
table.insert(list, entry)
end
table.insert(entry.ids, tonumber(res[1][i]))
local last_open = tonumber(res[4][i]) or 0
if last_open > entry.last_open then
entry.last_open = last_open
end
applyRow(entry, tonumber(res[1][i]), title, authors, tonumber(res[5][i]) or 0)
end
end
for _, entry in ipairs(list) do
finalizeEntry(entry)
end
return list
end)
end
-- Returns { last_open, title, authors, metadata_ambiguous } for one md5,
-- merged the same way as getBooks(). Used by live single-book sync before
-- match-check.
function BookOrbitStatsReader.getBook(md5)
if type(md5) ~= "string" or not md5:match("^%x+$") then return nil end
return withConn(function(conn)
local res = conn:exec(string.format("SELECT title, authors, last_open FROM book WHERE md5 = '%s';", md5))
if not res then return nil end
local book = { ids = {}, last_open = 0, _variant_seen = {}, _variant_count = 0 }
for i = 1, #res[1] do
local title = res[1][i]
local authors = res[2][i]
applyRow(book, i, title, authors, tonumber(res[3][i]) or 0)
end
if not book.title and not book.authors and book.last_open == 0 then return nil end
finalizeEntry(book)
book.ids = nil
return book
end)
end
-- Returns the stat book row ids sharing one md5 (the stats unique key is
-- title+authors+md5, so a single file can map to several rows).
function BookOrbitStatsReader.getBookIds(md5)
@@ -16,6 +16,7 @@ local Notification = require("ui/widget/notification")
local ReadHistory = require("readhistory")
local UIManager = require("ui/uimanager")
local logger = require("logger")
local lfs = require("libs/libkoreader-lfs")
local util = require("util")
local T = require("ffi/util").template
local _ = require("gettext")
@@ -38,6 +39,17 @@ local BookOrbitSweep = {
running = false,
}
local function titleFromHistoryItem(item)
local name = item.text
if (not name or name == "") and item.file then
name = item.file:gsub(".*/", "")
end
if not name or name == "" then return nil end
local title = name:gsub("%.[^%.]+$", "")
if title == "" then return name end
return title
end
function BookOrbitSweep.isRunning()
return BookOrbitSweep.running
end
@@ -137,14 +149,23 @@ local function stepEnumerate(ctx)
local stats_books = BookOrbitStatsReader.getBooks()
for _, entry in ipairs(stats_books or {}) do
ctx.candidates[entry.md5] = { stat_ids = entry.ids, last_open = entry.last_open }
ctx.candidates[entry.md5] = {
stat_ids = entry.ids,
last_open = entry.last_open,
title = entry.title,
authors = entry.authors,
source = "statistics",
metadata_ambiguous = entry.metadata_ambiguous,
stats_metadata_ambiguous = entry.metadata_ambiguous,
}
end
for _, item in ipairs(ReadHistory.hist or {}) do
local file = item.file
if file then
local file_exists = lfs.attributes(file, "mode") == "file"
local md5 = ctx.state.files[file]
if not md5 and DocSettings:hasSidecarFile(file) then
if not md5 and file_exists and DocSettings:hasSidecarFile(file) then
local doc_settings = DocSettings:open(file)
md5 = doc_settings:readSetting("partial_md5_checksum")
if not md5 then
@@ -155,13 +176,23 @@ local function stepEnumerate(ctx)
end
if md5 then
local cand = ctx.candidates[md5] or {}
cand.file = file
if file_exists then
cand.file = file
cand.source = "file"
if cand.metadata_ambiguous then
cand.title = titleFromHistoryItem(item)
cand.authors = nil
elseif not cand.title then
cand.title = titleFromHistoryItem(item)
end
cand.metadata_ambiguous = false
end
if (item.time or 0) > (cand.last_open or 0) then
cand.last_open = item.time
end
ctx.candidates[md5] = cand
local book = ctx.state:getBook(md5)
if book and not book.file then
if book and file_exists and not book.file then
book.file = file
end
end
@@ -173,7 +204,7 @@ end
-- Phase 2: ask the server which hashes it knows. Only never-seen hashes,
-- unmatched hashes with new activity, and (when the library version token
-- changed or on manual sync) the whole unmatched backlog are checked.
-- changed or on manual sync) all known local matches are checked.
local function stepMatch(ctx)
local to_check = {}
local queued = {}
@@ -185,7 +216,9 @@ local function stepMatch(ctx)
end
for md5, cand in pairs(ctx.candidates) do
if not ctx.state:getBook(md5) then
if ctx.full_recheck then
queue(md5)
elseif not ctx.state:getBook(md5) then
local last_check = ctx.state.unmatched[md5]
if not last_check then
queue(md5)
@@ -196,6 +229,9 @@ local function stepMatch(ctx)
end
if ctx.full_recheck then
for md5 in pairs(ctx.state.books) do
queue(md5)
end
for md5 in pairs(ctx.state.unmatched) do
queue(md5)
end
@@ -224,7 +260,7 @@ local function stepMatchNext(ctx)
ctx.state:flush()
ctx.stats_queue = {}
for md5, cand in pairs(ctx.candidates) do
if cand.stat_ids and ctx.state:getBook(md5) then
if cand.stat_ids and not cand.stats_metadata_ambiguous and ctx.state:getBook(md5) then
table.insert(ctx.stats_queue, { md5 = md5, ids = cand.stat_ids })
end
end
@@ -234,7 +270,7 @@ local function stepMatchNext(ctx)
end
setProgress(ctx, _("BookOrbit sync: matching books..."))
local body, err = ctx.client:matchCheck(batch)
local body, err = ctx.client:matchCheck(batch, ctx.candidates)
if not body then
if isAuthError(err) then return finish(ctx, "auth") end
return finish(ctx, "network")
@@ -656,7 +692,7 @@ local function stepDone(ctx)
if ctx.full_recheck or known == nil then
ctx.state.global.needsFullRecheck = false
elseif ctx.server_library_version ~= known then
-- The library changed; the next sweep rechecks the unmatched backlog.
-- The library changed; the next sweep rechecks local hash mappings.
ctx.state.global.needsFullRecheck = true
end
ctx.state.global.libraryVersion = ctx.server_library_version
+41
View File
@@ -41,6 +41,7 @@ export interface KoreaderPluginTotals {
failedPositions: number;
pageStatEvents: number;
annotations: number;
unmatchedBooks: number;
}
export interface KoreaderSyncStatus {
@@ -66,6 +67,46 @@ export interface KoreaderBookSyncInfo {
fileModifiedSinceLastSync: boolean;
}
export interface KoreaderUnmatchedBook {
hash: string;
title: string | null;
authors: string | null;
lastOpen: number | null;
firstSeenAt: string;
lastSeenAt: string;
}
export interface KoreaderManualHashLink {
hash: string;
bookId: number;
bookFileId: number;
bookTitle: string | null;
bookAuthors: string[];
koreaderTitle: string | null;
koreaderAuthors: string | null;
koreaderLastOpen: number | null;
createdAt: string;
updatedAt: string;
}
export interface LinkKoreaderUnmatchedBookPayload {
bookId: number;
}
export interface LinkKoreaderUnmatchedBookResult {
hash: string;
bookId: number;
bookFileId: number;
}
export interface UpdateKoreaderManualHashLinkPayload {
bookId: number;
}
export interface UnlinkKoreaderManualHashLinkResult {
hash: string;
}
export interface CreateKoreaderCredentialsPayload {
username: string;
password: string;
@@ -0,0 +1,17 @@
/**
* Renders the plain-text SQL for a drizzle-orm `SQL` template (e.g. from `sql\`...\``)
* so tests can assert on the generated query without a live database connection.
*/
export function sqlChunkText(value: unknown): string {
if (!value || typeof value !== 'object') return '';
const chunks = (value as { queryChunks?: unknown[] }).queryChunks;
if (!Array.isArray(chunks)) return '';
return chunks
.map((chunk) => {
if (!chunk || typeof chunk !== 'object') return '';
const text = (chunk as { value?: unknown }).value;
if (Array.isArray(text)) return text.join('');
return sqlChunkText(chunk);
})
.join('');
}
@@ -0,0 +1,35 @@
CREATE TABLE "koreader_book_hash_links" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"hash" varchar(32) NOT NULL,
"book_file_id" integer NOT NULL,
"koreader_title" varchar(500),
"koreader_authors" text,
"koreader_last_open" bigint,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "koreader_book_hash_links_hash_chk" CHECK ("koreader_book_hash_links"."hash" ~ '^[0-9a-f]{32}$')
);
--> statement-breakpoint
CREATE TABLE "koreader_unmatched_books" (
"user_id" integer NOT NULL,
"hash" varchar(32) NOT NULL,
"title" varchar(500),
"authors" text,
"last_open" bigint,
"source" varchar(20) DEFAULT 'statistics' NOT NULL,
"metadata_ambiguous" boolean DEFAULT false NOT NULL,
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "koreader_unmatched_books_user_id_hash_pk" PRIMARY KEY("user_id","hash"),
CONSTRAINT "koreader_unmatched_books_hash_chk" CHECK ("koreader_unmatched_books"."hash" ~ '^[0-9a-f]{32}$'),
CONSTRAINT "koreader_unmatched_books_source_chk" CHECK ("koreader_unmatched_books"."source" in ('current_file', 'file', 'statistics'))
);
--> statement-breakpoint
ALTER TABLE "koreader_book_hash_links" ADD CONSTRAINT "koreader_book_hash_links_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "koreader_book_hash_links" ADD CONSTRAINT "koreader_book_hash_links_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "koreader_unmatched_books" ADD CONSTRAINT "koreader_unmatched_books_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "koreader_book_hash_links_user_hash_uidx" ON "koreader_book_hash_links" USING btree ("user_id","hash");--> statement-breakpoint
CREATE INDEX "koreader_book_hash_links_user_updated_idx" ON "koreader_book_hash_links" USING btree ("user_id","updated_at" desc);--> statement-breakpoint
CREATE INDEX "koreader_book_hash_links_book_file_id_idx" ON "koreader_book_hash_links" USING btree ("book_file_id");--> statement-breakpoint
CREATE INDEX "koreader_unmatched_books_user_seen_idx" ON "koreader_unmatched_books" USING btree ("user_id","last_seen_at" desc);
File diff suppressed because it is too large Load Diff
@@ -239,6 +239,13 @@
"when": 1782775423934,
"tag": "0033_community-rating-filter-indexes",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1782943263105,
"tag": "0034_koreader-manual-hash-links",
"breakpoints": true
}
]
}
+57
View File
@@ -102,6 +102,63 @@ export const bookFileHashHistory = pgTable(
export type BookFileHashHistory = typeof bookFileHashHistory.$inferSelect;
export type NewBookFileHashHistory = typeof bookFileHashHistory.$inferInsert;
export const koreaderBookHashLinks = pgTable(
'koreader_book_hash_links',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
hash: varchar('hash', { length: 32 }).notNull(),
bookFileId: integer('book_file_id')
.notNull()
.references(() => bookFiles.id, { onDelete: 'cascade' }),
koreaderTitle: varchar('koreader_title', { length: 500 }),
koreaderAuthors: text('koreader_authors'),
koreaderLastOpen: bigint('koreader_last_open', { mode: 'number' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
uniqueIndex('koreader_book_hash_links_user_hash_uidx').on(t.userId, t.hash),
index('koreader_book_hash_links_user_updated_idx').on(t.userId, sql`${t.updatedAt} desc`),
index('koreader_book_hash_links_book_file_id_idx').on(t.bookFileId),
check('koreader_book_hash_links_hash_chk', sql`${t.hash} ~ '^[0-9a-f]{32}$'`),
],
);
export type KoreaderBookHashLink = typeof koreaderBookHashLinks.$inferSelect;
export type NewKoreaderBookHashLink = typeof koreaderBookHashLinks.$inferInsert;
export const koreaderUnmatchedBooks = pgTable(
'koreader_unmatched_books',
{
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
hash: varchar('hash', { length: 32 }).notNull(),
title: varchar('title', { length: 500 }),
authors: text('authors'),
lastOpen: bigint('last_open', { mode: 'number' }),
source: varchar('source', { length: 20 }).notNull().default('statistics'),
metadataAmbiguous: boolean('metadata_ambiguous').notNull().default(false),
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).defaultNow().notNull(),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
primaryKey({ columns: [t.userId, t.hash] }),
index('koreader_unmatched_books_user_seen_idx').on(t.userId, sql`${t.lastSeenAt} desc`),
check('koreader_unmatched_books_hash_chk', sql`${t.hash} ~ '^[0-9a-f]{32}$'`),
check('koreader_unmatched_books_source_chk', sql`${t.source} in ('current_file', 'file', 'statistics')`),
],
);
export type KoreaderUnmatchedBook = typeof koreaderUnmatchedBooks.$inferSelect;
export type NewKoreaderUnmatchedBook = typeof koreaderUnmatchedBooks.$inferInsert;
export const bookFileChapters = pgTable(
'book_file_chapters',
{
+1
View File
@@ -1,5 +1,6 @@
export * from './koreader-catalog-query.dto';
export * from './koreader-exchange.dto';
export * from './koreader-link.dto';
export * from './koreader-package.dto';
export * from './koreader-plugin.dto';
export * from './koreader-progress.dto';
@@ -0,0 +1,13 @@
import { IsInt, Min } from 'class-validator';
export class LinkKoreaderUnmatchedBookDto {
@IsInt()
@Min(1)
bookId!: number;
}
export class UpdateKoreaderManualHashLinkDto {
@IsInt()
@Min(1)
bookId!: number;
}
@@ -2,6 +2,7 @@ import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsBoolean,
IsArray,
IsIn,
IsInt,
@@ -42,6 +43,35 @@ export class PluginDeviceDto {
deviceTime?: string;
}
export class MatchCheckBookDto {
@IsString()
@Matches(MD5_HEX)
hash!: string;
@IsOptional()
@IsString()
@MaxLength(500)
title?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
authors?: string;
@IsOptional()
@IsInt()
@Min(0)
lastOpen?: number;
@IsOptional()
@IsIn(['current_file', 'file', 'statistics'])
source?: 'current_file' | 'file' | 'statistics';
@IsOptional()
@IsBoolean()
metadataAmbiguous?: boolean;
}
export class MatchCheckDto extends PluginDeviceDto {
@IsArray()
@ArrayMinSize(1)
@@ -49,6 +79,13 @@ export class MatchCheckDto extends PluginDeviceDto {
@IsString({ each: true })
@Matches(MD5_HEX, { each: true })
hashes!: string[];
@IsOptional()
@IsArray()
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => MatchCheckBookDto)
books?: MatchCheckBookDto[];
}
export class PageStatEventDto {
@@ -98,7 +98,7 @@ export class KoreaderAnnotationExchangeService {
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
const hashes = [...new Set(dto.books.map((book) => book.hash.toLowerCase()))];
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
const deviceClockOffsetMs = this.deviceClockOffsetMs(dto.deviceTime);
const results: ExchangeBookResult[] = [];
@@ -135,7 +135,7 @@ export class KoreaderAnnotationExchangeService {
try {
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
const hashes = [...new Set(dto.books.map((book) => book.hash.toLowerCase()))];
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
const results: { hash: string; acked: number }[] = [];
const unmatched: string[] = [];
@@ -0,0 +1,351 @@
import { BadRequestException, ConflictException, Logger, NotFoundException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KoreaderHashLinkService } from './koreader-hash-link.service';
import { KoreaderRepository } from './koreader.repository';
const HASH_A = 'a'.repeat(32);
const HASH_B = 'b'.repeat(32);
describe('KoreaderHashLinkService', () => {
let service: KoreaderHashLinkService;
let mockRepo: {
getAccessibleLibraryIds: ReturnType<typeof vi.fn>;
resolveBookFileByHash: ReturnType<typeof vi.fn>;
resolveBookFilesByHashes: ReturnType<typeof vi.fn>;
listUnmatchedBooks: ReturnType<typeof vi.fn>;
getUnmatchedBook: ReturnType<typeof vi.fn>;
clearUnmatchedBooks: ReturnType<typeof vi.fn>;
listBookHashLinks: ReturnType<typeof vi.fn>;
upsertBookHashLink: ReturnType<typeof vi.fn>;
getBookHashLink: ReturnType<typeof vi.fn>;
deleteBookHashLink: ReturnType<typeof vi.fn>;
upsertUnmatchedBooks: ReturnType<typeof vi.fn>;
findBookFileIdByBookId: ReturnType<typeof vi.fn>;
};
let mockBookService: {
verifyBookAccess: ReturnType<typeof vi.fn>;
};
const user = { id: 7, settings: {}, contentFilters: null } as never;
const firstSeenAt = new Date('2026-06-01T10:00:00.000Z');
const lastSeenAt = new Date('2026-06-02T11:00:00.000Z');
function makeUnmatchedRow(overrides: Record<string, unknown> = {}) {
return {
userId: 7,
hash: HASH_A,
title: 'Stats title',
authors: 'Stats author',
lastOpen: 100,
source: 'file',
metadataAmbiguous: false,
firstSeenAt,
lastSeenAt,
...overrides,
};
}
beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
mockRepo = {
getAccessibleLibraryIds: vi.fn().mockResolvedValue([1, 2]),
resolveBookFileByHash: vi.fn(),
resolveBookFilesByHashes: vi.fn().mockResolvedValue(new Map()),
listUnmatchedBooks: vi.fn().mockResolvedValue([]),
getUnmatchedBook: vi.fn().mockResolvedValue(null),
clearUnmatchedBooks: vi.fn().mockResolvedValue(undefined),
listBookHashLinks: vi.fn().mockResolvedValue([]),
upsertBookHashLink: vi.fn().mockResolvedValue(undefined),
getBookHashLink: vi.fn().mockResolvedValue(null),
deleteBookHashLink: vi.fn().mockResolvedValue(null),
upsertUnmatchedBooks: vi.fn().mockResolvedValue(undefined),
findBookFileIdByBookId: vi.fn(),
};
mockBookService = {
verifyBookAccess: vi.fn().mockResolvedValue(undefined),
};
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
service = new KoreaderHashLinkService(mockRepo as unknown as KoreaderRepository, mockBookService as never);
});
describe('listUnmatchedBooks', () => {
it('returns an empty array without further lookups when there are no unmatched rows', async () => {
mockRepo.listUnmatchedBooks.mockResolvedValue([]);
await expect(service.listUnmatchedBooks(user)).resolves.toEqual([]);
expect(mockRepo.getAccessibleLibraryIds).not.toHaveBeenCalled();
expect(mockRepo.resolveBookFilesByHashes).not.toHaveBeenCalled();
});
it('lists unmatched books and clears rows that now resolve', async () => {
mockRepo.listUnmatchedBooks.mockResolvedValue([
{
userId: 7,
hash: HASH_A,
title: 'Already matched',
authors: 'Author A',
lastOpen: 100,
source: 'file',
metadataAmbiguous: false,
firstSeenAt,
lastSeenAt,
},
{
userId: 7,
hash: HASH_B,
title: 'Needs link',
authors: null,
lastOpen: null,
source: 'file',
metadataAmbiguous: false,
firstSeenAt,
lastSeenAt,
},
]);
mockRepo.resolveBookFilesByHashes.mockResolvedValue(new Map([[HASH_A, { bookFileId: 10, bookId: 20, libraryId: 1 }]]));
await expect(service.listUnmatchedBooks(user)).resolves.toEqual([
{
hash: HASH_B,
title: 'Needs link',
authors: null,
lastOpen: null,
firstSeenAt: '2026-06-01T10:00:00.000Z',
lastSeenAt: '2026-06-02T11:00:00.000Z',
},
]);
expect(mockRepo.resolveBookFilesByHashes).toHaveBeenCalledWith([HASH_A, HASH_B], [1, 2], 7);
expect(mockRepo.clearUnmatchedBooks).toHaveBeenCalledWith(7, [HASH_A]);
});
it('does not call clearUnmatchedBooks when nothing resolves', async () => {
mockRepo.listUnmatchedBooks.mockResolvedValue([makeUnmatchedRow()]);
mockRepo.resolveBookFilesByHashes.mockResolvedValue(new Map());
await expect(service.listUnmatchedBooks(user)).resolves.toEqual([
{
hash: HASH_A,
title: 'Stats title',
authors: 'Stats author',
lastOpen: 100,
firstSeenAt: '2026-06-01T10:00:00.000Z',
lastSeenAt: '2026-06-02T11:00:00.000Z',
},
]);
expect(mockRepo.clearUnmatchedBooks).not.toHaveBeenCalled();
});
});
describe('linkUnmatchedBook', () => {
it('links an unmatched hash to an accessible book file', async () => {
mockRepo.getUnmatchedBook.mockResolvedValue(makeUnmatchedRow());
mockRepo.findBookFileIdByBookId.mockResolvedValue(44);
mockRepo.resolveBookFileByHash.mockResolvedValue(null);
await expect(service.linkUnmatchedBook(user, HASH_A.toUpperCase(), 55)).resolves.toEqual({ hash: HASH_A, bookId: 55, bookFileId: 44 });
expect(mockBookService.verifyBookAccess).toHaveBeenCalledWith(55, user);
expect(mockRepo.resolveBookFileByHash).toHaveBeenCalledWith(HASH_A, [1, 2]);
expect(mockRepo.getBookHashLink).toHaveBeenCalledWith(7, HASH_A);
expect(mockRepo.upsertBookHashLink).toHaveBeenCalledWith(7, HASH_A, 44, {
title: 'Stats title',
authors: 'Stats author',
lastOpen: 100,
});
expect(mockRepo.clearUnmatchedBooks).toHaveBeenCalledWith(7, [HASH_A]);
});
it('does not create a manual link when the hash already resolves to the same file intrinsically', async () => {
mockRepo.getUnmatchedBook.mockResolvedValue(makeUnmatchedRow());
mockRepo.findBookFileIdByBookId.mockResolvedValue(44);
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 44, bookId: 55, libraryId: 1 });
await service.linkUnmatchedBook(user, HASH_A, 55);
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
expect(mockRepo.clearUnmatchedBooks).toHaveBeenCalledWith(7, [HASH_A]);
});
it('rejects linking historical or ambiguous unmatched rows', async () => {
mockRepo.getUnmatchedBook.mockResolvedValue(makeUnmatchedRow({ source: 'statistics' }));
await expect(service.linkUnmatchedBook(user, HASH_A, 55)).rejects.toThrow(NotFoundException);
expect(mockBookService.verifyBookAccess).not.toHaveBeenCalled();
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
});
it('rejects invalid hashes before checking book access', async () => {
await expect(service.linkUnmatchedBook(user, 'not-a-md5', 55)).rejects.toThrow(BadRequestException);
expect(mockBookService.verifyBookAccess).not.toHaveBeenCalled();
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
});
it('rejects linking when the target book has no primary file', async () => {
mockRepo.getUnmatchedBook.mockResolvedValue(makeUnmatchedRow());
mockRepo.findBookFileIdByBookId.mockResolvedValue(null);
await expect(service.linkUnmatchedBook(user, HASH_A, 55)).rejects.toThrow(BadRequestException);
expect(mockRepo.resolveBookFileByHash).not.toHaveBeenCalled();
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
});
it('rejects links when the hash already belongs to a different accessible book file', async () => {
mockRepo.getUnmatchedBook.mockResolvedValue(makeUnmatchedRow());
mockRepo.findBookFileIdByBookId.mockResolvedValue(44);
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 99, bookId: 88, libraryId: 1 });
await expect(service.linkUnmatchedBook(user, HASH_A, 55)).rejects.toThrow(ConflictException);
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
expect(mockRepo.clearUnmatchedBooks).not.toHaveBeenCalled();
});
it('rejects linking when the hash is already manually linked to a different file', async () => {
mockRepo.getUnmatchedBook.mockResolvedValue(makeUnmatchedRow());
mockRepo.findBookFileIdByBookId.mockResolvedValue(44);
mockRepo.resolveBookFileByHash.mockResolvedValue(null);
mockRepo.getBookHashLink.mockResolvedValue({ bookFileId: 99 });
await expect(service.linkUnmatchedBook(user, HASH_A, 55)).rejects.toThrow(ConflictException);
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
expect(mockRepo.clearUnmatchedBooks).not.toHaveBeenCalled();
});
});
describe('listManualHashLinks', () => {
it('lists manual hash links with BookOrbit and KOReader metadata', async () => {
const createdAt = new Date('2026-06-01T10:00:00.000Z');
const updatedAt = new Date('2026-06-02T11:00:00.000Z');
mockRepo.listBookHashLinks.mockResolvedValue([
{
hash: HASH_A,
bookId: 55,
bookFileId: 44,
bookTitle: 'BookOrbit Title',
bookAuthors: ['BookOrbit Author'],
koreaderTitle: 'KOReader Title',
koreaderAuthors: 'KOReader Author',
koreaderLastOpen: 100,
createdAt,
updatedAt,
},
]);
await expect(service.listManualHashLinks(user)).resolves.toEqual([
{
hash: HASH_A,
bookId: 55,
bookFileId: 44,
bookTitle: 'BookOrbit Title',
bookAuthors: ['BookOrbit Author'],
koreaderTitle: 'KOReader Title',
koreaderAuthors: 'KOReader Author',
koreaderLastOpen: 100,
createdAt: '2026-06-01T10:00:00.000Z',
updatedAt: '2026-06-02T11:00:00.000Z',
},
]);
expect(mockRepo.listBookHashLinks).toHaveBeenCalledWith(7, 100, [1, 2]);
});
});
describe('relinkManualHashLink', () => {
it('relinks an existing manual hash link to a different accessible book file', async () => {
mockRepo.getBookHashLink.mockResolvedValue({ bookFileId: 44 });
mockRepo.findBookFileIdByBookId.mockResolvedValue(66);
mockRepo.resolveBookFileByHash.mockResolvedValue(null);
await expect(service.relinkManualHashLink(user, HASH_A, 77)).resolves.toEqual({ hash: HASH_A, bookId: 77, bookFileId: 66 });
expect(mockBookService.verifyBookAccess).toHaveBeenCalledWith(77, user);
expect(mockRepo.upsertBookHashLink).toHaveBeenCalledWith(7, HASH_A, 66);
expect(mockRepo.clearUnmatchedBooks).toHaveBeenCalledWith(7, [HASH_A]);
});
it('rejects relinking when no manual link exists', async () => {
mockRepo.getBookHashLink.mockResolvedValue(null);
await expect(service.relinkManualHashLink(user, HASH_A, 77)).rejects.toThrow(NotFoundException);
expect(mockBookService.verifyBookAccess).not.toHaveBeenCalled();
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
});
it('rejects relinking when the target book has no primary file', async () => {
mockRepo.getBookHashLink.mockResolvedValue({ bookFileId: 44 });
mockRepo.findBookFileIdByBookId.mockResolvedValue(null);
await expect(service.relinkManualHashLink(user, HASH_A, 77)).rejects.toThrow(BadRequestException);
expect(mockRepo.resolveBookFileByHash).not.toHaveBeenCalled();
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
});
it('rejects relinking when the hash already matches a different book intrinsically', async () => {
mockRepo.getBookHashLink.mockResolvedValue({ bookFileId: 44 });
mockRepo.findBookFileIdByBookId.mockResolvedValue(66);
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 99, bookId: 88, libraryId: 1 });
await expect(service.relinkManualHashLink(user, HASH_A, 77)).rejects.toThrow(ConflictException);
expect(mockRepo.upsertBookHashLink).not.toHaveBeenCalled();
expect(mockRepo.clearUnmatchedBooks).not.toHaveBeenCalled();
});
});
describe('unlinkManualHashLink', () => {
it('unlinks a manual hash link and restores it as unmatched when no intrinsic match exists', async () => {
mockRepo.deleteBookHashLink.mockResolvedValue({
hash: HASH_A,
bookFileId: 44,
koreaderTitle: 'KOReader Title',
koreaderAuthors: 'KOReader Author',
koreaderLastOpen: 100,
});
mockRepo.resolveBookFileByHash.mockResolvedValue(null);
await expect(service.unlinkManualHashLink(user, HASH_A)).resolves.toEqual({ hash: HASH_A });
expect(mockRepo.deleteBookHashLink).toHaveBeenCalledWith(7, HASH_A);
expect(mockRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(7, [
{ hash: HASH_A, title: 'KOReader Title', authors: 'KOReader Author', lastOpen: 100, source: 'file', metadataAmbiguous: false },
]);
});
it('does not restore the hash as unmatched when it still resolves intrinsically', async () => {
mockRepo.deleteBookHashLink.mockResolvedValue({
hash: HASH_A,
bookFileId: 44,
koreaderTitle: 'KOReader Title',
koreaderAuthors: 'KOReader Author',
koreaderLastOpen: 100,
});
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 44, bookId: 55, libraryId: 1 });
await expect(service.unlinkManualHashLink(user, HASH_A)).resolves.toEqual({ hash: HASH_A });
expect(mockRepo.upsertUnmatchedBooks).not.toHaveBeenCalled();
});
it('rejects unlinking when no manual link exists', async () => {
mockRepo.deleteBookHashLink.mockResolvedValue(null);
await expect(service.unlinkManualHashLink(user, HASH_A)).rejects.toThrow(NotFoundException);
expect(mockRepo.upsertUnmatchedBooks).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,184 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type {
KoreaderManualHashLink,
KoreaderUnmatchedBook,
LinkKoreaderUnmatchedBookResult,
UnlinkKoreaderManualHashLinkResult,
} from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { BookService } from '../book/book.service';
import { KoreaderRepository } from './koreader.repository';
const MD5_HEX = /^[0-9a-f]{32}$/;
const UNMATCHED_BOOK_LIMIT = 100;
const MANUAL_HASH_LINK_LIMIT = 100;
const HASH_LINK_CREATE_EVENT = 'koreader.hash_link_create';
const HASH_LINK_UPDATE_EVENT = 'koreader.hash_link_update';
const HASH_LINK_DELETE_EVENT = 'koreader.hash_link_delete';
/**
* Manages the KOReader "unmatched book" queue and the manual hash links used
* to resolve hashes that BookOrbit cannot match intrinsically (via file hash
* or hash history). Split out of KoreaderService to keep that class focused
* on credentials and progress sync.
*/
@Injectable()
export class KoreaderHashLinkService {
private readonly logger = new Logger(KoreaderHashLinkService.name);
constructor(
private readonly repo: KoreaderRepository,
private readonly bookService: BookService,
) {}
async listUnmatchedBooks(user: RequestUser): Promise<KoreaderUnmatchedBook[]> {
const rows = await this.repo.listUnmatchedBooks(user.id, UNMATCHED_BOOK_LIMIT);
if (rows.length === 0) return [];
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(user.id);
const hashes = rows.map((row) => row.hash);
const resolved = await this.repo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
if (resolved.size > 0) {
await this.repo.clearUnmatchedBooks(user.id, [...resolved.keys()]);
}
return rows
.filter((row) => !resolved.has(row.hash))
.map((row) => ({
hash: row.hash,
title: row.title ?? null,
authors: row.authors ?? null,
lastOpen: row.lastOpen ?? null,
firstSeenAt: row.firstSeenAt.toISOString(),
lastSeenAt: row.lastSeenAt.toISOString(),
}));
}
async linkUnmatchedBook(user: RequestUser, hash: string, bookId: number): Promise<LinkKoreaderUnmatchedBookResult> {
const startedAtMs = Date.now();
const normalizedHash = this.normalizeKoreaderHash(hash);
this.logger.log(
`[${HASH_LINK_CREATE_EVENT}] [start] userId=${user.id} hash=${normalizedHash.slice(0, 8)} bookId=${bookId} - hash link create started`,
);
const unmatched = await this.repo.getUnmatchedBook(user.id, normalizedHash);
if (!unmatched || !isLinkableUnmatchedBook(unmatched)) throw new NotFoundException('KOReader unmatched book not found');
await this.bookService.verifyBookAccess(bookId, user);
const bookFileId = await this.repo.findBookFileIdByBookId(bookId);
if (!bookFileId) throw new BadRequestException('Book has no file to link');
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(user.id);
const existingIntrinsicMatch = await this.repo.resolveBookFileByHash(normalizedHash, accessibleLibraryIds);
if (existingIntrinsicMatch && existingIntrinsicMatch.id !== bookFileId) {
throw new ConflictException('KOReader hash already matches a different book');
}
const existingLink = await this.repo.getBookHashLink(user.id, normalizedHash);
if (existingLink && existingLink.bookFileId !== bookFileId) {
throw new ConflictException('KOReader hash is already linked to a different book');
}
if (!existingIntrinsicMatch) {
await this.repo.upsertBookHashLink(user.id, normalizedHash, bookFileId, {
title: unmatched?.title ?? null,
authors: unmatched?.authors ?? null,
lastOpen: unmatched?.lastOpen ?? null,
});
}
await this.repo.clearUnmatchedBooks(user.id, [normalizedHash]);
this.logger.log(
`[${HASH_LINK_CREATE_EVENT}] [end] userId=${user.id} hash=${normalizedHash.slice(0, 8)} bookId=${bookId} bookFileId=${bookFileId} durationMs=${Date.now() - startedAtMs} - hash link create completed`,
);
return { hash: normalizedHash, bookId, bookFileId };
}
async listManualHashLinks(user: RequestUser): Promise<KoreaderManualHashLink[]> {
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(user.id);
const rows = await this.repo.listBookHashLinks(user.id, MANUAL_HASH_LINK_LIMIT, accessibleLibraryIds);
return rows.map((row) => ({
hash: row.hash,
bookId: row.bookId,
bookFileId: row.bookFileId,
bookTitle: row.bookTitle ?? null,
bookAuthors: row.bookAuthors,
koreaderTitle: row.koreaderTitle ?? null,
koreaderAuthors: row.koreaderAuthors ?? null,
koreaderLastOpen: row.koreaderLastOpen ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}));
}
async relinkManualHashLink(user: RequestUser, hash: string, bookId: number): Promise<LinkKoreaderUnmatchedBookResult> {
const startedAtMs = Date.now();
const normalizedHash = this.normalizeKoreaderHash(hash);
this.logger.log(
`[${HASH_LINK_UPDATE_EVENT}] [start] userId=${user.id} hash=${normalizedHash.slice(0, 8)} bookId=${bookId} - hash link update started`,
);
const existingLink = await this.repo.getBookHashLink(user.id, normalizedHash);
if (!existingLink) throw new NotFoundException('KOReader manual link not found');
await this.bookService.verifyBookAccess(bookId, user);
const bookFileId = await this.repo.findBookFileIdByBookId(bookId);
if (!bookFileId) throw new BadRequestException('Book has no file to link');
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(user.id);
const existingIntrinsicMatch = await this.repo.resolveBookFileByHash(normalizedHash, accessibleLibraryIds);
if (existingIntrinsicMatch && existingIntrinsicMatch.id !== bookFileId) {
throw new ConflictException('KOReader hash already matches a different book');
}
await this.repo.upsertBookHashLink(user.id, normalizedHash, bookFileId);
await this.repo.clearUnmatchedBooks(user.id, [normalizedHash]);
this.logger.log(
`[${HASH_LINK_UPDATE_EVENT}] [end] userId=${user.id} hash=${normalizedHash.slice(0, 8)} bookId=${bookId} bookFileId=${bookFileId} durationMs=${Date.now() - startedAtMs} - hash link update completed`,
);
return { hash: normalizedHash, bookId, bookFileId };
}
async unlinkManualHashLink(user: RequestUser, hash: string): Promise<UnlinkKoreaderManualHashLinkResult> {
const startedAtMs = Date.now();
const normalizedHash = this.normalizeKoreaderHash(hash);
this.logger.log(`[${HASH_LINK_DELETE_EVENT}] [start] userId=${user.id} hash=${normalizedHash.slice(0, 8)} - hash link delete started`);
const deleted = await this.repo.deleteBookHashLink(user.id, normalizedHash);
if (!deleted) throw new NotFoundException('KOReader manual link not found');
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(user.id);
const existingIntrinsicMatch = await this.repo.resolveBookFileByHash(normalizedHash, accessibleLibraryIds);
const restoredAsUnmatched = !existingIntrinsicMatch;
if (restoredAsUnmatched) {
await this.repo.upsertUnmatchedBooks(user.id, [
{
hash: normalizedHash,
title: deleted.koreaderTitle,
authors: deleted.koreaderAuthors,
lastOpen: deleted.koreaderLastOpen,
source: 'file',
metadataAmbiguous: false,
},
]);
}
this.logger.log(
`[${HASH_LINK_DELETE_EVENT}] [end] userId=${user.id} hash=${normalizedHash.slice(0, 8)} durationMs=${Date.now() - startedAtMs} restoredAsUnmatched=${restoredAsUnmatched} - hash link delete completed`,
);
return { hash: normalizedHash };
}
private normalizeKoreaderHash(hash: string): string {
const normalized = hash.trim().toLowerCase();
if (!MD5_HEX.test(normalized)) throw new BadRequestException('Invalid KOReader hash');
return normalized;
}
}
function isLinkableUnmatchedBook(row: { source?: string | null; metadataAmbiguous?: boolean | null } | null): boolean {
return !!row && (row.source === 'current_file' || row.source === 'file') && row.metadataAmbiguous === false;
}
@@ -38,7 +38,7 @@ export class KoreaderPluginAnnotationService {
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
const hashes = [...new Set(dto.books.map((book) => book.hash.toLowerCase()))];
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
const results: { hash: string; upserted: number }[] = [];
const unmatched: string[] = [];
@@ -204,4 +204,57 @@ describe('KOReader plugin update source wiring', () => {
expect(downloadIcon).toContain('<svg');
expect(downloadIcon).toContain('M24 7v24');
});
it('revalidates current-book matches and sends metadata when checking live books', async () => {
const bookSync = await readPluginFile('bookorbit_book_sync.lua');
const statsReader = await readPluginFile('bookorbit_stats_reader.lua');
expect(statsReader).toContain('function BookOrbitStatsReader.getBook(md5)');
expect(statsReader).toContain('if title == "" then title = nil end');
expect(statsReader).toContain('if authors == "" then authors = nil end');
expect(statsReader).toContain('if title then entry.title = title end');
expect(statsReader).toContain('if authors then entry.authors = authors end');
expect(bookSync).toContain('local metadata = BookOrbitStatsReader.getBook(digest) or {}');
expect(bookSync).toContain('local stats_ambiguous = metadata.metadata_ambiguous == true');
expect(bookSync).toContain('title = stats_ambiguous and titleFromFile(file) or (metadata.title or titleFromFile(file))');
expect(bookSync).toContain('authors = stats_ambiguous and nil or metadata.authors');
expect(bookSync).toContain('last_open = metadata.last_open or ts');
expect(bookSync).toContain('metadata_ambiguous = false');
expect(bookSync).toContain('stats_metadata_ambiguous = stats_ambiguous');
expect(bookSync).toContain('local body, err = ctx.client:matchCheck({ ctx.snap.digest }, {');
expect(bookSync).toContain('title = ctx.snap.title');
expect(bookSync).toContain('authors = ctx.snap.authors');
expect(bookSync).toContain('last_open = ctx.snap.last_open');
expect(bookSync).toContain('source = "current_file"');
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).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',
);
});
it('rechecks matched local hashes during full-library revalidation', async () => {
const sweep = await readPluginFile('bookorbit_sweep.lua');
expect(sweep).toContain('if ctx.full_recheck then\n queue(md5)\n elseif not ctx.state:getBook(md5) then');
expect(sweep).toContain('for md5 in pairs(ctx.state.books) do\n queue(md5)\n end');
});
it('marks unmatched match-check candidates by source and ambiguity', async () => {
const api = await readPluginFile('bookorbit_api.lua');
const sweep = await readPluginFile('bookorbit_sweep.lua');
const statsReader = await readPluginFile('bookorbit_stats_reader.lua');
expect(api).toContain('source = cand.source');
expect(api).toContain('metadataAmbiguous = cand.metadata_ambiguous');
expect(statsReader).toContain('entry.metadata_ambiguous = (entry._variant_count or 0) > 1');
expect(sweep).toContain('source = "statistics"');
expect(sweep).toContain('metadata_ambiguous = entry.metadata_ambiguous');
expect(sweep).toContain('stats_metadata_ambiguous = entry.metadata_ambiguous');
expect(sweep).toContain('local file_exists = lfs.attributes(file, "mode") == "file"');
expect(sweep).toContain('cand.source = "file"');
expect(sweep).toContain('cand.metadata_ambiguous = false');
expect(sweep).toContain('if cand.stat_ids and not cand.stats_metadata_ambiguous and ctx.state:getBook(md5) then');
});
});
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { sqlChunkText } from '../../common/test-utils/sql-chunk-text';
import { KoreaderPluginRepository } from './koreader-plugin.repository';
function makeQueryChain(result: unknown) {
const chain: Record<string, unknown> = {
then(resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) {
return Promise.resolve(result).then(resolve, reject);
},
};
chain.from = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockReturnValue(chain);
return chain;
}
function makeDb() {
return {
execute: vi.fn(),
select: vi.fn(),
};
}
describe('KoreaderPluginRepository', () => {
let db: ReturnType<typeof makeDb>;
let repo: KoreaderPluginRepository;
beforeEach(() => {
db = makeDb();
repo = new KoreaderPluginRepository(db as never);
});
describe('getPluginTotals', () => {
it('maps aggregate counts including linkable unmatched books', async () => {
db.execute.mockResolvedValue({
rows: [
{
matched_books: '2',
page_stat_events: '10',
annotations: '4',
trashed_annotations: '1',
pending_deletes: '3',
failed_positions: '5',
unmatched_books: '6',
},
],
});
await expect(repo.getPluginTotals(7)).resolves.toEqual({
matchedBooks: 2,
pageStatEvents: 10,
annotations: 4,
trashedAnnotations: 1,
pendingDeletes: 3,
failedPositions: 5,
unmatchedBooks: 6,
});
const sqlText = sqlChunkText(db.execute.mock.calls[0]![0]).replace(/\s+/g, ' ');
expect(sqlText).toContain('from koreader_unmatched_books');
expect(sqlText).toContain("source in ('current_file', 'file')");
expect(sqlText).toContain('metadata_ambiguous = false');
});
it('defaults missing aggregate rows to zeroes', async () => {
db.execute.mockResolvedValue({ rows: [] });
await expect(repo.getPluginTotals(7)).resolves.toEqual({
matchedBooks: 0,
pageStatEvents: 0,
annotations: 0,
trashedAnnotations: 0,
pendingDeletes: 0,
failedPositions: 0,
unmatchedBooks: 0,
});
});
});
describe('getHashLinkVersion', () => {
it('returns manual link count and newest update timestamp', async () => {
db.select.mockReturnValue(makeQueryChain([{ count: '3', maxTs: '2026-06-02T10:00:00.000Z' }]));
await expect(repo.getHashLinkVersion(7)).resolves.toEqual({
count: 3,
maxTs: new Date('2026-06-02T10:00:00.000Z'),
});
});
it('returns an empty version when no link rows exist', async () => {
db.select.mockReturnValue(makeQueryChain([]));
await expect(repo.getHashLinkVersion(7)).resolves.toEqual({ count: 0, maxTs: null });
});
});
});
@@ -348,6 +348,7 @@ export class KoreaderPluginRepository {
trashedAnnotations: number;
pendingDeletes: number;
failedPositions: number;
unmatchedBooks: number;
}> {
const result = await this.db.execute<{
matched_books: string | number;
@@ -356,6 +357,7 @@ export class KoreaderPluginRepository {
trashed_annotations: string | number;
pending_deletes: string | number;
failed_positions: string | number;
unmatched_books: string | number;
}>(sql`
select
(select count(distinct t.book_file_id) from (
@@ -373,7 +375,11 @@ export class KoreaderPluginRepository {
where s.user_id = ${userId} and a.deleted_at is not null and s.delete_acked_at is null) as pending_deletes,
(select count(*) from annotation_positions ap
join annotations a on a.id = ap.annotation_id
where ap.user_id = ${userId} and a.deleted_at is null and ap.status = 'failed') as failed_positions
where ap.user_id = ${userId} and a.deleted_at is null and ap.status = 'failed') as failed_positions,
(select count(*) from koreader_unmatched_books
where user_id = ${userId}
and source in ('current_file', 'file')
and metadata_ambiguous = false) as unmatched_books
`);
const row = result.rows[0];
return {
@@ -383,6 +389,7 @@ export class KoreaderPluginRepository {
trashedAnnotations: Number(row?.trashed_annotations ?? 0),
pendingDeletes: Number(row?.pending_deletes ?? 0),
failedPositions: Number(row?.failed_positions ?? 0),
unmatchedBooks: Number(row?.unmatched_books ?? 0),
};
}
@@ -398,4 +405,16 @@ export class KoreaderPluginRepository {
return row?.maxTs ? new Date(row.maxTs) : null;
}
async getHashLinkVersion(userId: number): Promise<{ count: number; maxTs: Date | null }> {
const [row] = await this.db
.select({
count: sql<number | string>`count(*)`,
maxTs: sql<Date | string | null>`max(${schema.koreaderBookHashLinks.updatedAt})`,
})
.from(schema.koreaderBookHashLinks)
.where(eq(schema.koreaderBookHashLinks.userId, userId));
return { count: Number(row?.count ?? 0), maxTs: row?.maxTs ? new Date(row.maxTs) : null };
}
}
@@ -26,6 +26,8 @@ describe('KoreaderPluginService', () => {
let koreaderRepo: {
getAccessibleLibraryIds: ReturnType<typeof vi.fn>;
resolveBookFilesByHashes: ReturnType<typeof vi.fn>;
upsertUnmatchedBooks: ReturnType<typeof vi.fn>;
clearUnmatchedBooks: ReturnType<typeof vi.fn>;
getAllDeviceProgress: ReturnType<typeof vi.fn>;
getReadingProgress: ReturnType<typeof vi.fn>;
};
@@ -36,6 +38,7 @@ describe('KoreaderPluginService', () => {
listSweeps: ReturnType<typeof vi.fn>;
getPluginTotals: ReturnType<typeof vi.fn>;
getLibraryMaxFileTimestamp: ReturnType<typeof vi.fn>;
getHashLinkVersion: ReturnType<typeof vi.fn>;
};
let koreaderService: { applyProgressForResolvedFile: ReturnType<typeof vi.fn> };
let userBookStatusService: { findOne: ReturnType<typeof vi.fn>; setManual: ReturnType<typeof vi.fn> };
@@ -50,6 +53,8 @@ describe('KoreaderPluginService', () => {
koreaderRepo = {
getAccessibleLibraryIds: vi.fn().mockResolvedValue([1]),
resolveBookFilesByHashes: vi.fn().mockResolvedValue(new Map([[HASH_A, { bookFileId: 10, bookId: 20, libraryId: 1 }]])),
upsertUnmatchedBooks: vi.fn().mockResolvedValue(undefined),
clearUnmatchedBooks: vi.fn().mockResolvedValue(undefined),
getAllDeviceProgress: vi.fn().mockResolvedValue([]),
getReadingProgress: vi.fn().mockResolvedValue(null),
};
@@ -58,8 +63,9 @@ describe('KoreaderPluginService', () => {
upsertRating: vi.fn().mockResolvedValue(undefined),
upsertSweep: vi.fn().mockResolvedValue(new Date('2026-06-09T10:00:00.000Z')),
listSweeps: vi.fn().mockResolvedValue([]),
getPluginTotals: vi.fn().mockResolvedValue({ matchedBooks: 0, pageStatEvents: 0, annotations: 0 }),
getPluginTotals: vi.fn().mockResolvedValue({ matchedBooks: 0, pageStatEvents: 0, annotations: 0, unmatchedBooks: 0 }),
getLibraryMaxFileTimestamp: vi.fn().mockResolvedValue(new Date('2026-06-01T00:00:00.000Z')),
getHashLinkVersion: vi.fn().mockResolvedValue({ count: 0, maxTs: null }),
};
koreaderService = { applyProgressForResolvedFile: vi.fn().mockResolvedValue(undefined) };
userBookStatusService = { findOne: vi.fn().mockResolvedValue(null), setManual: vi.fn().mockResolvedValue(undefined) };
@@ -80,11 +86,64 @@ describe('KoreaderPluginService', () => {
const result = await service.matchCheck(makeUser(), dto);
expect(koreaderRepo.resolveBookFilesByHashes).toHaveBeenCalledWith([HASH_A, HASH_B], [1]);
expect(koreaderRepo.resolveBookFilesByHashes).toHaveBeenCalledWith([HASH_A, HASH_B], [1], 7);
expect(koreaderRepo.clearUnmatchedBooks).toHaveBeenCalledWith(7, [HASH_A]);
expect(koreaderRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(7, [{ hash: HASH_B, source: 'statistics' }]);
expect(result.matches).toEqual([{ hash: HASH_A, bookId: 20, bookFileId: 10 }]);
expect(result.libraryVersion).toMatch(/^[0-9a-f]{16}$/);
});
it('persists unmatched candidate metadata from the device statistics database', async () => {
const dto = {
...deviceFields(),
hashes: [HASH_A, HASH_B],
books: [
{ hash: HASH_A, title: 'Matched title', authors: 'Matched author', lastOpen: 100 },
{ hash: HASH_B.toUpperCase(), title: 'Unmatched title', authors: 'Author One', lastOpen: 200 },
],
} as MatchCheckDto;
await service.matchCheck(makeUser(), dto);
expect(koreaderRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(7, [
{ hash: HASH_B, title: 'Unmatched title', authors: 'Author One', lastOpen: 200, source: 'statistics', metadataAmbiguous: false },
]);
});
it('keeps the strongest unmatched source and ambiguity flag for duplicate candidate metadata', async () => {
const dto = {
...deviceFields(),
hashes: [HASH_B],
books: [
{ hash: HASH_B, title: 'Stats title', lastOpen: 100, source: 'statistics' },
{ hash: HASH_B, title: 'File title', authors: 'File author', lastOpen: 200, source: 'file', metadataAmbiguous: true },
],
} as MatchCheckDto;
await service.matchCheck(makeUser(), dto);
expect(koreaderRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(7, [
{ hash: HASH_B, title: 'File title', authors: 'File author', lastOpen: 200, source: 'file', metadataAmbiguous: true },
]);
});
it('does not overwrite stronger unmatched metadata with a weaker later candidate', async () => {
const dto = {
...deviceFields(),
hashes: [HASH_B],
books: [
{ hash: HASH_B, title: 'Open file title', authors: 'Open file author', lastOpen: 200, source: 'current_file', metadataAmbiguous: false },
{ hash: HASH_B, title: 'Stats title', authors: 'Stats author', lastOpen: 300, source: 'statistics', metadataAmbiguous: true },
],
} as MatchCheckDto;
await service.matchCheck(makeUser(), dto);
expect(koreaderRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(7, [
{ hash: HASH_B, title: 'Open file title', authors: 'Open file author', lastOpen: 300, source: 'current_file', metadataAmbiguous: false },
]);
});
it('changes the library version token when the accessible library set changes', async () => {
const dto = { ...deviceFields(), hashes: [HASH_A] } as MatchCheckDto;
@@ -94,6 +153,28 @@ describe('KoreaderPluginService', () => {
expect(second.libraryVersion).not.toBe(first.libraryVersion);
});
it('changes the library version token when manual hash links change', async () => {
const dto = { ...deviceFields(), hashes: [HASH_A] } as MatchCheckDto;
const first = await service.matchCheck(makeUser(), dto);
pluginRepo.getHashLinkVersion.mockResolvedValue({ count: 1, maxTs: new Date('2026-06-02T00:00:00.000Z') });
const second = await service.matchCheck(makeUser(), dto);
expect(second.libraryVersion).not.toBe(first.libraryVersion);
});
it('changes the library version token when a manual hash link count changes without a newer timestamp', async () => {
const dto = { ...deviceFields(), hashes: [HASH_A] } as MatchCheckDto;
const maxTs = new Date('2026-06-02T00:00:00.000Z');
pluginRepo.getHashLinkVersion.mockResolvedValue({ count: 2, maxTs });
const first = await service.matchCheck(makeUser(), dto);
pluginRepo.getHashLinkVersion.mockResolvedValue({ count: 1, maxTs });
const second = await service.matchCheck(makeUser(), dto);
expect(second.libraryVersion).not.toBe(first.libraryVersion);
});
});
describe('uploadBookStates', () => {
@@ -15,6 +15,7 @@ const MATCH_EVENT = 'koreader.plugin.match_check';
const BOOK_STATES_EVENT = 'koreader.plugin.book_states';
const BULK_PROGRESS_EVENT = 'koreader.plugin.bulk_progress';
const SWEEP_EVENT = 'koreader.plugin.sweep';
const UNMATCHED_SOURCE_RANK = { statistics: 0, file: 1, current_file: 2 } as const;
const DEVICE_STATUS_TO_READ_STATUS: Record<string, ReadStatus> = {
reading: 'reading',
@@ -63,14 +64,20 @@ export class KoreaderPluginService {
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
const hashes = [...new Set(dto.hashes.map((hash) => hash.toLowerCase()))];
const resolved = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
const resolved = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
const matchedHashes = [...resolved.keys()];
const unmatchedCandidates = this.buildUnmatchedCandidates(hashes, matchedHashes, dto);
await Promise.all([
this.koreaderRepo.clearUnmatchedBooks(user.id, matchedHashes),
this.koreaderRepo.upsertUnmatchedBooks(user.id, unmatchedCandidates),
]);
const matches = [...resolved.entries()].map(([hash, match]) => ({
hash,
bookId: match.bookId,
bookFileId: match.bookFileId,
}));
const libraryVersion = await this.computeLibraryVersion(accessibleLibraryIds);
const libraryVersion = await this.computeLibraryVersion(user.id, accessibleLibraryIds);
this.logger.log(
`[${MATCH_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} matched=${matches.length} total=${hashes.length} - match check completed`,
@@ -88,7 +95,7 @@ export class KoreaderPluginService {
try {
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
const hashes = [...new Set(dto.books.map((book) => book.hash.toLowerCase()))];
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
const results: BookStatesUploadResult['results'] = [];
const unmatched: string[] = [];
@@ -135,7 +142,7 @@ export class KoreaderPluginService {
try {
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
const hashes = [...new Set(dto.items.map((item) => item.hash.toLowerCase()))];
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
const results: BulkProgressResult['results'] = [];
const unmatched: string[] = [];
@@ -194,7 +201,7 @@ export class KoreaderPluginService {
});
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
const libraryVersion = await this.computeLibraryVersion(accessibleLibraryIds);
const libraryVersion = await this.computeLibraryVersion(user.id, accessibleLibraryIds);
this.logger.log(
`[${SWEEP_EVENT}] [end] userId=${user.id} deviceId=${dto.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} booksMatched=${dto.booksMatched} pageStats=${dto.pageStatsUploaded} annotations=${dto.annotationsUpserted} - sweep recorded`,
@@ -203,15 +210,53 @@ export class KoreaderPluginService {
return { ok: true, lastSweepAt: lastSweepAt.toISOString(), libraryVersion };
}
private async computeLibraryVersion(accessibleLibraryIds: number[] | null): Promise<string> {
const maxTs = await this.pluginRepo.getLibraryMaxFileTimestamp(accessibleLibraryIds);
private async computeLibraryVersion(userId: number, accessibleLibraryIds: number[] | null): Promise<string> {
const [fileMaxTs, linkVersion] = await Promise.all([
this.pluginRepo.getLibraryMaxFileTimestamp(accessibleLibraryIds),
this.pluginRepo.getHashLinkVersion(userId),
]);
const maxTs = maxDate(fileMaxTs, linkVersion.maxTs);
const libraryKey = accessibleLibraryIds === null ? 'all' : [...accessibleLibraryIds].sort((a, b) => a - b).join(',');
const linkKey = `${linkVersion.count}:${linkVersion.maxTs ? linkVersion.maxTs.toISOString() : 'none'}`;
return createHash('md5') // codeql[js/weak-cryptographic-algorithm] - non-security cache token
.update(`${libraryKey}|${maxTs ? maxTs.toISOString() : 'none'}`)
.update(`${libraryKey}|${maxTs ? maxTs.toISOString() : 'none'}|${linkKey}`)
.digest('hex')
.slice(0, 16);
}
private buildUnmatchedCandidates(hashes: string[], matchedHashes: string[], dto: MatchCheckDto) {
const matched = new Set(matchedHashes);
const metadata = new Map<
string,
{
hash: string;
title?: string | null;
authors?: string | null;
lastOpen?: number | null;
source?: 'current_file' | 'file' | 'statistics';
metadataAmbiguous?: boolean;
}
>();
for (const book of dto.books ?? []) {
const hash = book.hash.toLowerCase();
const existing = metadata.get(hash);
const source = strongerUnmatchedSource(existing?.source, book.source);
const incomingIsStronger = UNMATCHED_SOURCE_RANK[source] > UNMATCHED_SOURCE_RANK[existing?.source ?? 'statistics'];
const incomingKeepsSource = source === (book.source ?? 'statistics');
metadata.set(hash, {
hash,
title: incomingIsStronger || incomingKeepsSource ? (book.title ?? existing?.title ?? null) : (existing?.title ?? book.title ?? null),
authors:
incomingIsStronger || incomingKeepsSource ? (book.authors ?? existing?.authors ?? null) : (existing?.authors ?? book.authors ?? null),
lastOpen: Math.max(book.lastOpen ?? 0, existing?.lastOpen ?? 0) || null,
source,
metadataAmbiguous: incomingIsStronger || incomingKeepsSource ? (book.metadataAmbiguous ?? false) : (existing?.metadataAmbiguous ?? false),
});
}
return hashes.filter((hash) => !matched.has(hash)).map((hash) => metadata.get(hash) ?? { hash, source: 'statistics' as const });
}
private async applyStatus(userId: number, bookId: number, deviceStatus: string, statusModified?: string): Promise<boolean> {
const mapped = DEVICE_STATUS_TO_READ_STATUS[deviceStatus];
if (!mapped) return false;
@@ -261,3 +306,21 @@ export class KoreaderPluginService {
return timestamp < newestKnown;
}
}
function maxDate(...dates: (Date | null)[]): Date | null {
let newest: Date | null = null;
for (const date of dates) {
if (!date) continue;
if (!newest || date > newest) newest = date;
}
return newest;
}
function strongerUnmatchedSource(
existing: 'current_file' | 'file' | 'statistics' | undefined,
incoming: 'current_file' | 'file' | 'statistics' | undefined,
): 'current_file' | 'file' | 'statistics' {
const current = existing ?? 'statistics';
const next = incoming ?? 'statistics';
return UNMATCHED_SOURCE_RANK[next] > UNMATCHED_SOURCE_RANK[current] ? next : current;
}
@@ -154,7 +154,7 @@ describe('KoreaderStatsService', () => {
const result = await service.uploadPageStats(makeUser(), dto);
expect(koreaderRepo.resolveBookFilesByHashes).toHaveBeenCalledWith([HASH_A], [1]);
expect(koreaderRepo.resolveBookFilesByHashes).toHaveBeenCalledWith([HASH_A], [1], 7);
expect(result.results[0]!.hash).toBe(HASH_A);
});
});
@@ -54,7 +54,7 @@ export class KoreaderStatsService {
const accessibleLibraryIds = await this.koreaderRepo.getAccessibleLibraryIds(user.id);
const timeZone = resolveTimeZone((user.settings as unknown as UserSettings | undefined)?.timezone, 'UTC');
const hashes = [...new Set(dto.books.map((book) => book.hash.toLowerCase()))];
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds);
const matches = await this.koreaderRepo.resolveBookFilesByHashes(hashes, accessibleLibraryIds, user.id);
const results: PageStatsBookResult[] = [];
const unmatched: string[] = [];
@@ -0,0 +1,161 @@
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { describe, expect, it, vi } from 'vitest';
import { Permission } from '@bookorbit/types';
import { IS_PUBLIC_KEY } from '../../common/decorators/public.decorator';
import { PERMISSION_KEY } from '../../common/decorators/require-permission.decorator';
import { KoreaderAuthGuard } from './koreader-auth.guard';
import { KoreaderController } from './koreader.controller';
function makeController() {
const koreaderService = {
saveProgress: vi.fn().mockResolvedValue({ document: 'abc', timestamp: 1 }),
getProgress: vi.fn().mockResolvedValue({ document: 'abc', percentage: 0.5 }),
createCredentials: vi.fn().mockResolvedValue(undefined),
updateCredentials: vi.fn().mockResolvedValue(undefined),
deleteCredentials: vi.fn().mockResolvedValue(undefined),
getCredentials: vi.fn().mockResolvedValue({ username: 'reader', syncEnabled: true, createdAt: '2026-01-01' }),
getSyncStatus: vi.fn().mockResolvedValue({ credentials: null }),
getDevices: vi.fn().mockResolvedValue([]),
getBookProgress: vi.fn().mockResolvedValue(null),
testConnection: vi.fn().mockResolvedValue(true),
};
const hashLinkService = {
listUnmatchedBooks: vi.fn().mockResolvedValue([]),
linkUnmatchedBook: vi.fn().mockResolvedValue({ hash: 'a'.repeat(32), bookId: 1, bookFileId: 2 }),
listManualHashLinks: vi.fn().mockResolvedValue([]),
relinkManualHashLink: vi.fn().mockResolvedValue({ hash: 'a'.repeat(32), bookId: 1, bookFileId: 2 }),
unlinkManualHashLink: vi.fn().mockResolvedValue({ hash: 'a'.repeat(32) }),
};
const packageService = {
buildPluginPackage: vi.fn().mockResolvedValue(Buffer.from('fake-zip-content')),
};
return {
controller: new KoreaderController(koreaderService as never, hashLinkService as never, packageService as never),
koreaderService,
hashLinkService,
packageService,
};
}
describe('KoreaderController', () => {
it('uses the KOReader auth guard and marks itself public only on kosync protocol routes', () => {
expect(Reflect.getMetadata(IS_PUBLIC_KEY, KoreaderController.prototype.authenticateKoreader)).toBe(true);
expect(Reflect.getMetadata(GUARDS_METADATA, KoreaderController.prototype.authenticateKoreader)).toEqual([KoreaderAuthGuard]);
expect(Reflect.getMetadata(IS_PUBLIC_KEY, KoreaderController.prototype.saveProgress)).toBe(true);
expect(Reflect.getMetadata(GUARDS_METADATA, KoreaderController.prototype.saveProgress)).toEqual([KoreaderAuthGuard]);
expect(Reflect.getMetadata(IS_PUBLIC_KEY, KoreaderController.prototype.getProgress)).toBe(true);
expect(Reflect.getMetadata(GUARDS_METADATA, KoreaderController.prototype.getProgress)).toEqual([KoreaderAuthGuard]);
expect(Reflect.getMetadata(IS_PUBLIC_KEY, KoreaderController.prototype.getSyncStatus)).toBeUndefined();
expect(Reflect.getMetadata(PERMISSION_KEY, KoreaderController.prototype.getSyncStatus)).toBe(Permission.KoreaderSync);
expect(Reflect.getMetadata(PERMISSION_KEY, KoreaderController.prototype.listUnmatchedBooks)).toBe(Permission.KoreaderSync);
expect(Reflect.getMetadata(PERMISSION_KEY, KoreaderController.prototype.unlinkManualHashLink)).toBe(Permission.KoreaderSync);
});
it('registerKoreader always rejects plugin self-registration', () => {
const { controller } = makeController();
expect(() => controller.registerKoreader()).toThrow('Registration disabled. Create credentials in BookOrbit settings.');
});
it('forwards kosync protocol routes to the service', async () => {
const { controller, koreaderService } = makeController();
const user = { id: 7 } as never;
expect(controller.authenticateKoreader('reader')).toEqual({ authorized: 'OK', username: 'reader' });
const progressDto = { document: 'abc', percentage: 0.5 } as never;
await controller.saveProgress(user, progressDto);
expect(koreaderService.saveProgress).toHaveBeenCalledWith(7, progressDto);
await expect(controller.getProgress(user, 'abc')).resolves.toEqual({ document: 'abc', percentage: 0.5 });
expect(koreaderService.getProgress).toHaveBeenCalledWith(7, 'abc');
});
it('returns an empty object when no progress is found for a document', async () => {
const { controller, koreaderService } = makeController();
koreaderService.getProgress.mockResolvedValue(null);
await expect(controller.getProgress({ id: 7 } as never, 'abc')).resolves.toEqual({});
});
it('forwards credential management routes to the service', async () => {
const { controller, koreaderService } = makeController();
const user = { id: 7 } as never;
await expect(controller.createCredentials(user, { username: 'reader', password: 'secret1' } as never)).resolves.toEqual({ success: true });
expect(koreaderService.createCredentials).toHaveBeenCalledWith(7, 'reader', 'secret1');
await expect(controller.updateCredentials(user, { syncEnabled: false } as never)).resolves.toEqual({ success: true });
expect(koreaderService.updateCredentials).toHaveBeenCalledWith(7, { syncEnabled: false });
await expect(controller.deleteCredentials(user)).resolves.toEqual({ success: true });
expect(koreaderService.deleteCredentials).toHaveBeenCalledWith(7);
await expect(controller.getCredentials(user)).resolves.toEqual({ username: 'reader', syncEnabled: true, createdAt: '2026-01-01' });
expect(koreaderService.getCredentials).toHaveBeenCalledWith(7);
});
it('forwards sync status, devices, and book progress routes to the service', async () => {
const { controller, koreaderService } = makeController();
const user = { id: 7 } as never;
await expect(controller.getSyncStatus(user)).resolves.toEqual({ credentials: null });
expect(koreaderService.getSyncStatus).toHaveBeenCalledWith(7);
await expect(controller.getDevices(user)).resolves.toEqual([]);
expect(koreaderService.getDevices).toHaveBeenCalledWith(7);
await expect(controller.getBookProgress(user, 10)).resolves.toBeNull();
expect(koreaderService.getBookProgress).toHaveBeenCalledWith(7, 10);
});
it('forwards unmatched-book and manual hash link routes to the hash link service', async () => {
const { controller, hashLinkService } = makeController();
const user = { id: 7 } as never;
const hash = 'a'.repeat(32);
await expect(controller.listUnmatchedBooks(user)).resolves.toEqual([]);
expect(hashLinkService.listUnmatchedBooks).toHaveBeenCalledWith(user);
await expect(controller.linkUnmatchedBook(user, hash, { bookId: 55 } as never)).resolves.toEqual({ hash, bookId: 1, bookFileId: 2 });
expect(hashLinkService.linkUnmatchedBook).toHaveBeenCalledWith(user, hash, 55);
await expect(controller.listManualHashLinks(user)).resolves.toEqual([]);
expect(hashLinkService.listManualHashLinks).toHaveBeenCalledWith(user);
await expect(controller.relinkManualHashLink(user, hash, { bookId: 66 } as never)).resolves.toEqual({ hash, bookId: 1, bookFileId: 2 });
expect(hashLinkService.relinkManualHashLink).toHaveBeenCalledWith(user, hash, 66);
await expect(controller.unlinkManualHashLink(user, hash)).resolves.toEqual({ hash });
expect(hashLinkService.unlinkManualHashLink).toHaveBeenCalledWith(user, hash);
});
it('downloads the preconfigured plugin package with the expected headers', async () => {
const { controller, packageService } = makeController();
const user = { id: 7 } as never;
const reply = { header: vi.fn().mockReturnThis(), send: vi.fn() } as never;
await controller.downloadPluginPackage(user, { origin: 'https://bookorbit.example' } as never, reply);
expect(packageService.buildPluginPackage).toHaveBeenCalledWith(7, 'https://bookorbit.example');
expect(reply.header).toHaveBeenCalledWith('Content-Type', 'application/zip');
expect(reply.header).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="bookorbit.koplugin.zip"');
expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'no-store');
expect(reply.send).toHaveBeenCalledWith(Buffer.from('fake-zip-content'));
});
it('forwards test-connection requests and reports the plugin server URL', async () => {
const { controller, koreaderService } = makeController();
const user = { id: 7 } as never;
await expect(controller.testConnection(user, { username: 'reader', password: 'secret1' } as never)).resolves.toEqual({
success: true,
username: 'reader',
serverUrl: '/api/v1/koreader',
});
expect(koreaderService.testConnection).toHaveBeenCalledWith(7, 'reader', 'secret1');
});
});
@@ -22,14 +22,24 @@ import { Public } from '../../common/decorators/public.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { KoreaderAuthGuard } from './koreader-auth.guard';
import { KoreaderHashLinkService } from './koreader-hash-link.service';
import { KoreaderPackageService } from './koreader-package.service';
import { KoreaderService } from './koreader.service';
import { CreateKoreaderUserDto, DownloadPluginPackageDto, KoreaderSaveProgressDto, TestConnectionDto, UpdateKoreaderUserDto } from './dto';
import {
CreateKoreaderUserDto,
DownloadPluginPackageDto,
KoreaderSaveProgressDto,
LinkKoreaderUnmatchedBookDto,
TestConnectionDto,
UpdateKoreaderManualHashLinkDto,
UpdateKoreaderUserDto,
} from './dto';
@Controller('koreader')
export class KoreaderController {
constructor(
private readonly koreaderService: KoreaderService,
private readonly hashLinkService: KoreaderHashLinkService,
private readonly packageService: KoreaderPackageService,
) {}
@@ -104,6 +114,36 @@ export class KoreaderController {
return this.koreaderService.getDevices(user.id);
}
@RequirePermission(Permission.KoreaderSync)
@Get('unmatched-books')
listUnmatchedBooks(@CurrentUser() user: RequestUser) {
return this.hashLinkService.listUnmatchedBooks(user);
}
@RequirePermission(Permission.KoreaderSync)
@Post('unmatched-books/:hash/link')
linkUnmatchedBook(@CurrentUser() user: RequestUser, @Param('hash') hash: string, @Body() dto: LinkKoreaderUnmatchedBookDto) {
return this.hashLinkService.linkUnmatchedBook(user, hash, dto.bookId);
}
@RequirePermission(Permission.KoreaderSync)
@Get('hash-links')
listManualHashLinks(@CurrentUser() user: RequestUser) {
return this.hashLinkService.listManualHashLinks(user);
}
@RequirePermission(Permission.KoreaderSync)
@Patch('hash-links/:hash')
relinkManualHashLink(@CurrentUser() user: RequestUser, @Param('hash') hash: string, @Body() dto: UpdateKoreaderManualHashLinkDto) {
return this.hashLinkService.relinkManualHashLink(user, hash, dto.bookId);
}
@RequirePermission(Permission.KoreaderSync)
@Delete('hash-links/:hash')
unlinkManualHashLink(@CurrentUser() user: RequestUser, @Param('hash') hash: string) {
return this.hashLinkService.unlinkManualHashLink(user, hash);
}
@RequirePermission(Permission.KoreaderSync)
@Get('books/:bookId/progress')
getBookProgress(@CurrentUser() user: RequestUser, @Param('bookId', ParseIntPipe) bookId: number) {
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { KoreaderAnnotationExchangeService } from './koreader-annotation-exchange.service';
import { KoreaderAuthGuard } from './koreader-auth.guard';
import { KoreaderCatalogController } from './koreader-catalog.controller';
import { KoreaderCatalogService } from './koreader-catalog.service';
import { KoreaderChapterExtractorService } from './koreader-chapter-extractor.service';
import { KoreaderChapterService } from './koreader-chapter.service';
import { KoreaderController } from './koreader.controller';
import { KoreaderHashLinkService } from './koreader-hash-link.service';
import { KoreaderModule } from './koreader.module';
import { KoreaderPackageService } from './koreader-package.service';
import { KoreaderPluginAnnotationService } from './koreader-plugin-annotation.service';
import { KoreaderPluginController } from './koreader-plugin.controller';
import { KoreaderPluginRepository } from './koreader-plugin.repository';
import { KoreaderPluginService } from './koreader-plugin.service';
import { KoreaderRepository } from './koreader.repository';
import { KoreaderService } from './koreader.service';
import { KoreaderStatsService } from './koreader-stats.service';
describe('KoreaderModule', () => {
it('registers expected controllers, providers, and exports', () => {
expect(Reflect.getMetadata('controllers', KoreaderModule)).toEqual([KoreaderController, KoreaderPluginController, KoreaderCatalogController]);
expect(Reflect.getMetadata('providers', KoreaderModule)).toEqual([
KoreaderService,
KoreaderHashLinkService,
KoreaderRepository,
KoreaderAuthGuard,
KoreaderCatalogService,
KoreaderPackageService,
KoreaderChapterService,
KoreaderChapterExtractorService,
KoreaderPluginService,
KoreaderPluginRepository,
KoreaderPluginAnnotationService,
KoreaderAnnotationExchangeService,
KoreaderStatsService,
]);
expect(Reflect.getMetadata('exports', KoreaderModule)).toEqual([KoreaderService, KoreaderRepository]);
});
});
@@ -17,6 +17,7 @@ import { KoreaderPackageService } from './koreader-package.service';
import { KoreaderChapterExtractorService } from './koreader-chapter-extractor.service';
import { KoreaderChapterService } from './koreader-chapter.service';
import { KoreaderController } from './koreader.controller';
import { KoreaderHashLinkService } from './koreader-hash-link.service';
import { KoreaderPluginAnnotationService } from './koreader-plugin-annotation.service';
import { KoreaderPluginController } from './koreader-plugin.controller';
import { KoreaderPluginRepository } from './koreader-plugin.repository';
@@ -40,6 +41,7 @@ import { KoreaderStatsService } from './koreader-stats.service';
controllers: [KoreaderController, KoreaderPluginController, KoreaderCatalogController],
providers: [
KoreaderService,
KoreaderHashLinkService,
KoreaderRepository,
KoreaderAuthGuard,
KoreaderCatalogService,
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { sqlChunkText } from '../../common/test-utils/sql-chunk-text';
import { KoreaderRepository } from './koreader.repository';
function makeQueryChain(result: unknown) {
@@ -13,6 +14,7 @@ function makeQueryChain(result: unknown) {
chain.where = vi.fn().mockReturnValue(chain);
chain.orderBy = vi.fn().mockReturnValue(chain);
chain.limit = vi.fn().mockResolvedValue(result);
chain.returning = vi.fn().mockResolvedValue(result);
return chain;
}
@@ -74,6 +76,28 @@ describe('KoreaderRepository', () => {
expect(result).toEqual(file);
expect(db.select).toHaveBeenCalledTimes(2);
});
it('falls back to a user-scoped manual link after direct and history lookups miss', async () => {
const file = { id: 10, bookId: 20, libraryId: 1 };
db.select
.mockReturnValueOnce(makeQueryChain([]))
.mockReturnValueOnce(makeQueryChain([]))
.mockReturnValueOnce(makeQueryChain([file]));
const result = await repo.resolveBookFileByHash('manualhash', [1], 7);
expect(result).toEqual(file);
expect(db.select).toHaveBeenCalledTimes(3);
});
it('returns null when a user-scoped manual link lookup also misses', async () => {
db.select.mockReturnValueOnce(makeQueryChain([])).mockReturnValueOnce(makeQueryChain([])).mockReturnValueOnce(makeQueryChain([]));
const result = await repo.resolveBookFileByHash('manualhash', [1], 7);
expect(result).toBeNull();
expect(db.select).toHaveBeenCalledTimes(3);
});
});
describe('resolveBookFilesByHashes', () => {
@@ -117,6 +141,188 @@ describe('KoreaderRepository', () => {
expect(result.get('current')).toEqual({ bookFileId: 11, bookId: 21, libraryId: 31 });
expect(db.select).toHaveBeenCalledTimes(1);
});
it('resolves remaining hashes from user-scoped manual links', async () => {
db.select
.mockReturnValueOnce(makeQueryChain([{ hash: 'current', bookFileId: 11, bookId: 21, libraryId: 31 }]))
.mockReturnValueOnce(makeQueryChain([]))
.mockReturnValueOnce(makeQueryChain([{ hash: 'manual', bookFileId: 12, bookId: 22, libraryId: 32 }]));
const result = await repo.resolveBookFilesByHashes(['current', 'manual'], [31, 32], 7);
expect(result.get('current')).toEqual({ bookFileId: 11, bookId: 21, libraryId: 31 });
expect(result.get('manual')).toEqual({ bookFileId: 12, bookId: 22, libraryId: 32 });
expect(db.select).toHaveBeenCalledTimes(3);
});
});
describe('unmatched books and manual hash links', () => {
it('upserts unmatched candidates with trimmed nullable metadata', async () => {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
db.insert.mockReturnValue({ values });
await repo.upsertUnmatchedBooks(7, [
{ hash: 'a'.repeat(32), title: ' Title ', authors: ' Author ', lastOpen: 100, source: 'file', metadataAmbiguous: true },
]);
expect(values).toHaveBeenCalledWith([
expect.objectContaining({
userId: 7,
hash: 'a'.repeat(32),
title: 'Title',
authors: 'Author',
lastOpen: 100,
source: 'file',
metadataAmbiguous: true,
lastSeenAt: expect.any(Date),
}),
]);
expect(onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.any(Array),
set: expect.objectContaining({
source: expect.anything(),
metadataAmbiguous: expect.anything(),
lastSeenAt: expect.any(Date),
}),
}),
);
const conflictSet = onConflictDoUpdate.mock.calls[0]![0].set as Record<string, unknown>;
expect(sqlChunkText(conflictSet.source)).toContain("case excluded.source when 'current_file' then 2 when 'file' then 1 else 0 end");
expect(sqlChunkText(conflictSet.source)).toContain('>=');
expect(sqlChunkText(conflictSet.source)).toContain('excluded.source');
expect(sqlChunkText(conflictSet.metadataAmbiguous)).toContain('excluded.metadata_ambiguous');
expect(sqlChunkText(conflictSet.title)).toContain('coalesce');
});
it('clears unmatched books for a user and hash set', async () => {
const where = vi.fn().mockResolvedValue(undefined);
db.delete.mockReturnValue({ where });
await repo.clearUnmatchedBooks(7, ['a'.repeat(32), 'b'.repeat(32)]);
expect(db.delete).toHaveBeenCalledTimes(1);
expect(where).toHaveBeenCalledTimes(1);
});
it('does not issue a delete query when clearing an empty hash set', async () => {
await repo.clearUnmatchedBooks(7, []);
expect(db.delete).not.toHaveBeenCalled();
});
it('lists unmatched books newest first with the requested limit', async () => {
const rows = [{ hash: 'a'.repeat(32), lastSeenAt: new Date() }];
const chain = makeQueryChain(rows);
db.select.mockReturnValue(chain);
await expect(repo.listUnmatchedBooks(7, 25)).resolves.toBe(rows);
expect(chain.orderBy).toHaveBeenCalledTimes(1);
expect(chain.limit).toHaveBeenCalledWith(25);
});
it('returns one unmatched book for a user and hash', async () => {
const row = { hash: 'a'.repeat(32), title: 'Stats title' };
db.select.mockReturnValue(makeQueryChain([row]));
await expect(repo.getUnmatchedBook(7, 'a'.repeat(32))).resolves.toBe(row);
});
it('returns null when no unmatched book exists for the user and hash', async () => {
db.select.mockReturnValue(makeQueryChain([]));
await expect(repo.getUnmatchedBook(7, 'a'.repeat(32))).resolves.toBeNull();
});
it('lists manual hash links with aggregated BookOrbit authors', async () => {
const linkRows = [
{
hash: 'a'.repeat(32),
bookFileId: 44,
bookId: 55,
bookTitle: 'BookOrbit Title',
koreaderTitle: 'KOReader Title',
koreaderAuthors: 'KOReader Author',
koreaderLastOpen: 100,
createdAt: new Date('2026-06-01T10:00:00.000Z'),
updatedAt: new Date('2026-06-02T10:00:00.000Z'),
},
];
db.select.mockReturnValueOnce(makeQueryChain(linkRows)).mockReturnValueOnce(makeQueryChain([{ bookId: 55, name: 'BookOrbit Author' }]));
await expect(repo.listBookHashLinks(7, 25, [1])).resolves.toEqual([{ ...linkRows[0], bookAuthors: ['BookOrbit Author'] }]);
expect(db.select).toHaveBeenCalledTimes(2);
});
it('does not query manual hash links when accessible libraries are empty', async () => {
await expect(repo.listBookHashLinks(7, 25, [])).resolves.toEqual([]);
expect(db.select).not.toHaveBeenCalled();
});
it('upserts a user-scoped manual hash link with KOReader metadata', async () => {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
db.insert.mockReturnValue({ values });
await repo.upsertBookHashLink(7, 'a'.repeat(32), 44, { title: ' KOReader Title ', authors: ' Author ', lastOpen: 100 });
expect(values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 7,
hash: 'a'.repeat(32),
bookFileId: 44,
koreaderTitle: 'KOReader Title',
koreaderAuthors: 'Author',
koreaderLastOpen: 100,
updatedAt: expect.any(Date),
}),
);
expect(onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.any(Array),
set: expect.objectContaining({ bookFileId: 44, updatedAt: expect.any(Date) }),
}),
);
});
it('upserts a manual hash link with null metadata when none is provided', async () => {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
db.insert.mockReturnValue({ values });
await repo.upsertBookHashLink(7, 'a'.repeat(32), 44);
expect(values).toHaveBeenCalledWith(
expect.objectContaining({
koreaderTitle: null,
koreaderAuthors: null,
koreaderLastOpen: null,
}),
);
});
it('returns an existing manual hash link for the user', async () => {
db.select.mockReturnValue(makeQueryChain([{ bookFileId: 44 }]));
await expect(repo.getBookHashLink(7, 'a'.repeat(32))).resolves.toEqual({ bookFileId: 44 });
});
it('returns null when no manual hash link exists for the user', async () => {
db.select.mockReturnValue(makeQueryChain([]));
await expect(repo.getBookHashLink(7, 'a'.repeat(32))).resolves.toBeNull();
});
it('deletes and returns a user-scoped manual hash link', async () => {
const row = { hash: 'a'.repeat(32), bookFileId: 44, koreaderTitle: 'Title', koreaderAuthors: 'Author', koreaderLastOpen: 100 };
const chain = makeQueryChain([row]);
db.delete.mockReturnValue(chain);
await expect(repo.deleteBookHashLink(7, 'a'.repeat(32))).resolves.toEqual(row);
expect(chain.returning).toHaveBeenCalledTimes(1);
});
});
describe('getAccessibleLibraryIds', () => {
@@ -7,6 +7,20 @@ import * as schema from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
type ResolvedBookFileByHash = { id: number; bookId: number; libraryId: number };
type KoreaderUnmatchedSource = 'current_file' | 'file' | 'statistics';
type KoreaderUnmatchedCandidate = {
hash: string;
title?: string | null;
authors?: string | null;
lastOpen?: number | null;
source?: KoreaderUnmatchedSource;
metadataAmbiguous?: boolean;
};
type KoreaderHashLinkMetadata = {
title?: string | null;
authors?: string | null;
lastOpen?: number | null;
};
@Injectable()
export class KoreaderRepository {
@@ -37,7 +51,7 @@ export class KoreaderRepository {
await this.db.delete(schema.koreaderUsers).where(eq(schema.koreaderUsers.userId, userId));
}
async resolveBookFileByHash(hash: string, accessibleLibraryIds: number[] | null): Promise<ResolvedBookFileByHash | null> {
async resolveBookFileByHash(hash: string, accessibleLibraryIds: number[] | null, userId?: number): Promise<ResolvedBookFileByHash | null> {
if (accessibleLibraryIds !== null && accessibleLibraryIds.length === 0) return null;
const libraryFilter = accessibleLibraryIds ? inArray(schema.books.libraryId, accessibleLibraryIds) : undefined;
@@ -61,12 +75,25 @@ export class KoreaderRepository {
if (byFileHashHistory) return byFileHashHistory;
if (userId !== undefined) {
const [byManualLink] = await this.db
.select({ id: schema.bookFiles.id, bookId: schema.bookFiles.bookId, libraryId: schema.books.libraryId })
.from(schema.koreaderBookHashLinks)
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.koreaderBookHashLinks.bookFileId))
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
.where(and(eq(schema.koreaderBookHashLinks.userId, userId), eq(schema.koreaderBookHashLinks.hash, hash), libraryFilter))
.limit(1);
if (byManualLink) return byManualLink;
}
return null;
}
async resolveBookFilesByHashes(
hashes: string[],
accessibleLibraryIds: number[] | null,
userId?: number,
): Promise<Map<string, { bookFileId: number; bookId: number; libraryId: number }>> {
const result = new Map<string, { bookFileId: number; bookId: number; libraryId: number }>();
if (hashes.length === 0) return result;
@@ -112,9 +139,219 @@ export class KoreaderRepository {
}
}
const stillMissing = missing.filter((hash) => !result.has(hash));
if (stillMissing.length === 0 || userId === undefined) return result;
const manualLinks = await this.db
.select({
hash: schema.koreaderBookHashLinks.hash,
bookFileId: schema.bookFiles.id,
bookId: schema.bookFiles.bookId,
libraryId: schema.books.libraryId,
})
.from(schema.koreaderBookHashLinks)
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.koreaderBookHashLinks.bookFileId))
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
.where(and(eq(schema.koreaderBookHashLinks.userId, userId), inArray(schema.koreaderBookHashLinks.hash, stillMissing), libraryFilter));
for (const row of manualLinks) {
if (!result.has(row.hash)) {
result.set(row.hash, { bookFileId: row.bookFileId, bookId: row.bookId, libraryId: row.libraryId });
}
}
return result;
}
async upsertUnmatchedBooks(userId: number, candidates: KoreaderUnmatchedCandidate[]): Promise<void> {
if (candidates.length === 0) return;
const now = new Date();
const incomingSourceRank = sql<number>`case excluded.source when 'current_file' then 2 when 'file' then 1 else 0 end`;
const storedSourceRank = sql<number>`case ${schema.koreaderUnmatchedBooks.source} when 'current_file' then 2 when 'file' then 1 else 0 end`;
await this.db
.insert(schema.koreaderUnmatchedBooks)
.values(
candidates.map((candidate) => ({
userId,
hash: candidate.hash,
title: candidate.title?.trim() || null,
authors: candidate.authors?.trim() || null,
lastOpen: candidate.lastOpen ?? null,
source: candidate.source ?? 'statistics',
metadataAmbiguous: candidate.metadataAmbiguous ?? false,
lastSeenAt: now,
})),
)
.onConflictDoUpdate({
target: [schema.koreaderUnmatchedBooks.userId, schema.koreaderUnmatchedBooks.hash],
set: {
title: sql`
case
when ${incomingSourceRank} >= ${storedSourceRank} then coalesce(excluded.title, ${schema.koreaderUnmatchedBooks.title})
else coalesce(${schema.koreaderUnmatchedBooks.title}, excluded.title)
end
`,
authors: sql`
case
when ${incomingSourceRank} >= ${storedSourceRank} then coalesce(excluded.authors, ${schema.koreaderUnmatchedBooks.authors})
else coalesce(${schema.koreaderUnmatchedBooks.authors}, excluded.authors)
end
`,
lastOpen: sql`
case
when excluded.last_open is null then ${schema.koreaderUnmatchedBooks.lastOpen}
when ${schema.koreaderUnmatchedBooks.lastOpen} is null then excluded.last_open
else greatest(excluded.last_open, ${schema.koreaderUnmatchedBooks.lastOpen})
end
`,
source: sql`
case
when ${incomingSourceRank} >= ${storedSourceRank} then excluded.source
else ${schema.koreaderUnmatchedBooks.source}
end
`,
metadataAmbiguous: sql`
case
when ${incomingSourceRank} >= ${storedSourceRank} then excluded.metadata_ambiguous
else ${schema.koreaderUnmatchedBooks.metadataAmbiguous}
end
`,
lastSeenAt: now,
},
});
}
async clearUnmatchedBooks(userId: number, hashes: string[]): Promise<void> {
if (hashes.length === 0) return;
await this.db
.delete(schema.koreaderUnmatchedBooks)
.where(and(eq(schema.koreaderUnmatchedBooks.userId, userId), inArray(schema.koreaderUnmatchedBooks.hash, hashes)));
}
async listUnmatchedBooks(userId: number, limit: number) {
return this.db
.select()
.from(schema.koreaderUnmatchedBooks)
.where(
and(
eq(schema.koreaderUnmatchedBooks.userId, userId),
inArray(schema.koreaderUnmatchedBooks.source, ['current_file', 'file']),
eq(schema.koreaderUnmatchedBooks.metadataAmbiguous, false),
),
)
.orderBy(desc(schema.koreaderUnmatchedBooks.lastSeenAt))
.limit(limit);
}
async getUnmatchedBook(userId: number, hash: string) {
const [row] = await this.db
.select()
.from(schema.koreaderUnmatchedBooks)
.where(and(eq(schema.koreaderUnmatchedBooks.userId, userId), eq(schema.koreaderUnmatchedBooks.hash, hash)))
.limit(1);
return row ?? null;
}
async listBookHashLinks(userId: number, limit: number, accessibleLibraryIds: number[] | null) {
if (accessibleLibraryIds !== null && accessibleLibraryIds.length === 0) return [];
const libraryFilter = accessibleLibraryIds ? inArray(schema.books.libraryId, accessibleLibraryIds) : undefined;
const rows = await this.db
.select({
hash: schema.koreaderBookHashLinks.hash,
bookFileId: schema.bookFiles.id,
bookId: schema.bookFiles.bookId,
bookTitle: schema.bookMetadata.title,
koreaderTitle: schema.koreaderBookHashLinks.koreaderTitle,
koreaderAuthors: schema.koreaderBookHashLinks.koreaderAuthors,
koreaderLastOpen: schema.koreaderBookHashLinks.koreaderLastOpen,
createdAt: schema.koreaderBookHashLinks.createdAt,
updatedAt: schema.koreaderBookHashLinks.updatedAt,
})
.from(schema.koreaderBookHashLinks)
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.koreaderBookHashLinks.bookFileId))
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
.where(and(eq(schema.koreaderBookHashLinks.userId, userId), libraryFilter))
.orderBy(desc(schema.koreaderBookHashLinks.updatedAt))
.limit(limit);
const bookIds = [...new Set(rows.map((row) => row.bookId))];
const authorRows =
bookIds.length > 0
? 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, bookIds))
.orderBy(schema.bookAuthors.displayOrder)
: [];
const authorsByBook = new Map<number, string[]>();
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, bookAuthors: authorsByBook.get(row.bookId) ?? [] }));
}
async upsertBookHashLink(userId: number, hash: string, bookFileId: number, metadata: KoreaderHashLinkMetadata = {}) {
const now = new Date();
await this.db
.insert(schema.koreaderBookHashLinks)
.values({
userId,
hash,
bookFileId,
koreaderTitle: metadata.title?.trim() || null,
koreaderAuthors: metadata.authors?.trim() || null,
koreaderLastOpen: metadata.lastOpen ?? null,
updatedAt: now,
})
.onConflictDoUpdate({
target: [schema.koreaderBookHashLinks.userId, schema.koreaderBookHashLinks.hash],
set: {
bookFileId,
koreaderTitle: sql`coalesce(excluded.koreader_title, ${schema.koreaderBookHashLinks.koreaderTitle})`,
koreaderAuthors: sql`coalesce(excluded.koreader_authors, ${schema.koreaderBookHashLinks.koreaderAuthors})`,
koreaderLastOpen: sql`
case
when excluded.koreader_last_open is null then ${schema.koreaderBookHashLinks.koreaderLastOpen}
when ${schema.koreaderBookHashLinks.koreaderLastOpen} is null then excluded.koreader_last_open
else greatest(excluded.koreader_last_open, ${schema.koreaderBookHashLinks.koreaderLastOpen})
end
`,
updatedAt: now,
},
});
}
async getBookHashLink(userId: number, hash: string): Promise<{ bookFileId: number } | null> {
const [row] = await this.db
.select({ bookFileId: schema.koreaderBookHashLinks.bookFileId })
.from(schema.koreaderBookHashLinks)
.where(and(eq(schema.koreaderBookHashLinks.userId, userId), eq(schema.koreaderBookHashLinks.hash, hash)))
.limit(1);
return row ?? null;
}
async deleteBookHashLink(userId: number, hash: string) {
const [row] = await this.db
.delete(schema.koreaderBookHashLinks)
.where(and(eq(schema.koreaderBookHashLinks.userId, userId), eq(schema.koreaderBookHashLinks.hash, hash)))
.returning({
hash: schema.koreaderBookHashLinks.hash,
bookFileId: schema.koreaderBookHashLinks.bookFileId,
koreaderTitle: schema.koreaderBookHashLinks.koreaderTitle,
koreaderAuthors: schema.koreaderBookHashLinks.koreaderAuthors,
koreaderLastOpen: schema.koreaderBookHashLinks.koreaderLastOpen,
});
return row ?? null;
}
async getAccessibleLibraryIds(userId: number): Promise<number[] | null> {
const user = await this.db.query.users.findFirst({
where: eq(schema.users.id, userId),
@@ -162,6 +162,7 @@ describe('KoreaderService', () => {
failedPositions: 0,
pageStatEvents: 0,
annotations: 0,
unmatchedBooks: 0,
}),
};
@@ -348,7 +349,7 @@ describe('KoreaderService', () => {
timestamp: 1700000000,
});
expect(mockRepo.resolveBookFileByHash).toHaveBeenCalledWith('abcdef1234567890fedcba', [1, 2]);
expect(mockRepo.resolveBookFileByHash).toHaveBeenCalledWith('abcdef1234567890fedcba', [1, 2], 12);
expect(mockChapterService.parseChapterIndexFromProgress).toHaveBeenCalledWith('/body/DocFragment[7]');
expect(mockChapterExtractor.extractAndStoreChapters).toHaveBeenCalledWith(44);
expect(mockRepo.upsertDeviceProgress).toHaveBeenCalledWith({
@@ -407,7 +408,7 @@ describe('KoreaderService', () => {
}),
).rejects.toThrow(NotFoundException);
expect(mockRepo.resolveBookFileByHash).toHaveBeenCalledWith('no-access-document', []);
expect(mockRepo.resolveBookFileByHash).toHaveBeenCalledWith('no-access-document', [], 12);
});
it('uses the default device and generated device id when the payload leaves them empty', async () => {
@@ -587,7 +588,15 @@ describe('KoreaderService', () => {
latestPluginVersion: null,
pluginUpdateAvailable: false,
sweeps: [],
pluginTotals: { matchedBooks: 0, trashedAnnotations: 0, pendingDeletes: 0, failedPositions: 0, pageStatEvents: 0, annotations: 0 },
pluginTotals: {
matchedBooks: 0,
trashedAnnotations: 0,
pendingDeletes: 0,
failedPositions: 0,
pageStatEvents: 0,
annotations: 0,
unmatchedBooks: 0,
},
});
expect(getCredentialsSpy).toHaveBeenCalledWith(7);
@@ -116,7 +116,7 @@ export class KoreaderService {
this.logger.debug(`[${SYNC_EVENT}] [start] userId=${userId} document=${data.document.slice(0, 16)} device=${device} - save progress started`);
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(userId);
const bookFile = await this.repo.resolveBookFileByHash(data.document, accessibleLibraryIds);
const bookFile = await this.repo.resolveBookFileByHash(data.document, accessibleLibraryIds, userId);
if (!bookFile) {
this.logger.debug(
@@ -188,7 +188,7 @@ export class KoreaderService {
async getProgress(userId: number, documentHash: string) {
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(userId);
const bookFile = await this.repo.resolveBookFileByHash(documentHash, accessibleLibraryIds);
const bookFile = await this.repo.resolveBookFileByHash(documentHash, accessibleLibraryIds, userId);
if (!bookFile) return null;