feat(reader): add KOReader device removal and unmatched book tracking

Introduces the koreader_unmatched_book_devices schema to track unmatched
books per device. Adds API endpoints and UI in settings for dismissing
unmatched books and completely removing a KOReader device.

Closes #504
This commit is contained in:
neon
2026-07-02 17:36:55 +00:00
parent 52bb16c8b8
commit fb35e232ed
19 changed files with 13763 additions and 87 deletions
@@ -138,7 +138,29 @@ describe('useKoreaderSync', () => {
expect(loading.value).toBe(false)
})
it('createCredentials makes POST with payload and refreshes status', async () => {
it('fetchSyncStatus does not toggle loading when called silently', async () => {
const status = makeSyncStatus()
let resolveResponse: ((value: Response) => void) | undefined
apiMock.mockReturnValueOnce(
new Promise<Response>((resolve) => {
resolveResponse = resolve
}),
)
const { useKoreaderSync } = await import('../useKoreaderSync')
const { loading, syncStatus, fetchSyncStatus } = useKoreaderSync()
const fetchPromise = fetchSyncStatus(true)
expect(loading.value).toBe(false)
resolveResponse?.(makeResponse(status))
await fetchPromise
expect(loading.value).toBe(false)
expect(syncStatus.value).toEqual(status)
})
it('createCredentials makes POST with payload and refreshes status without toggling the page loading flag', async () => {
const payload: CreateKoreaderCredentialsPayload = {
username: 'new-user',
password: 'secret',
@@ -147,9 +169,11 @@ describe('useKoreaderSync', () => {
apiMock.mockResolvedValueOnce(makeResponse({})).mockResolvedValueOnce(makeResponse(refreshedStatus))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { syncStatus, credentials, createCredentials } = useKoreaderSync()
const { syncStatus, credentials, loading, createCredentials } = useKoreaderSync()
await createCredentials(payload)
const createPromise = createCredentials(payload)
expect(loading.value).toBe(false)
await createPromise
const [url, request] = apiMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/v1/koreader/credentials')
@@ -159,6 +183,7 @@ describe('useKoreaderSync', () => {
expect(apiMock).toHaveBeenNthCalledWith(2, '/api/v1/koreader/sync-status')
expect(syncStatus.value).toEqual(refreshedStatus)
expect(credentials.value).toEqual(refreshedStatus.credentials)
expect(loading.value).toBe(false)
})
it('createCredentials throws error on non-ok response with message', async () => {
@@ -423,6 +448,76 @@ describe('useKoreaderSync', () => {
await expect(linkUnmatchedBook('a'.repeat(32), { bookId: 55 })).rejects.toThrow('KOReader hash already matches a different book')
})
it('dismissUnmatchedBook deletes the entry, removes it locally, and refreshes sync status without toggling the page loading flag', async () => {
const hash = 'a'.repeat(32)
const refreshedStatus = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 0 } })
apiMock.mockResolvedValueOnce(makeResponse({ hash })).mockResolvedValueOnce(makeResponse(refreshedStatus))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { unmatchedBooks, syncStatus, loading, dismissUnmatchedBook } = useKoreaderSync()
unmatchedBooks.value = [makeUnmatchedBook({ hash }), makeUnmatchedBook({ hash: 'b'.repeat(32) })]
const dismissPromise = dismissUnmatchedBook(hash)
expect(loading.value).toBe(false)
await expect(dismissPromise).resolves.toEqual({ hash })
expect(apiMock).toHaveBeenCalledWith(`/api/v1/koreader/unmatched-books/${hash}`, { method: 'DELETE' })
expect(unmatchedBooks.value).toEqual([makeUnmatchedBook({ hash: 'b'.repeat(32) })])
expect(syncStatus.value).toEqual(refreshedStatus)
expect(loading.value).toBe(false)
})
it('dismissUnmatchedBook throws the server message on failure', async () => {
apiMock.mockResolvedValueOnce(makeResponse({ message: 'KOReader unmatched book not found' }, { ok: false, status: 404 }))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { dismissUnmatchedBook } = useKoreaderSync()
await expect(dismissUnmatchedBook('a'.repeat(32))).rejects.toThrow('KOReader unmatched book not found')
})
it('dismissUnmatchedBook falls back to a generic message when the error body is not JSON', async () => {
apiMock.mockResolvedValueOnce(makeInvalidJsonResponse())
const { useKoreaderSync } = await import('../useKoreaderSync')
const { dismissUnmatchedBook } = useKoreaderSync()
await expect(dismissUnmatchedBook('a'.repeat(32))).rejects.toThrow('Failed to dismiss KOReader unmatched book')
})
it('dismissAllUnmatchedBooks deletes all entries, clears local state, and refreshes sync status', async () => {
const refreshedStatus = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 0 } })
apiMock.mockResolvedValueOnce(makeResponse({ count: 2 })).mockResolvedValueOnce(makeResponse(refreshedStatus))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { unmatchedBooks, syncStatus, dismissAllUnmatchedBooks } = useKoreaderSync()
unmatchedBooks.value = [makeUnmatchedBook({ hash: 'a'.repeat(32) }), makeUnmatchedBook({ hash: 'b'.repeat(32) })]
await expect(dismissAllUnmatchedBooks()).resolves.toEqual({ count: 2 })
expect(apiMock).toHaveBeenCalledWith('/api/v1/koreader/unmatched-books', { method: 'DELETE' })
expect(unmatchedBooks.value).toEqual([])
expect(syncStatus.value).toEqual(refreshedStatus)
})
it('dismissAllUnmatchedBooks throws the server message on failure', async () => {
apiMock.mockResolvedValueOnce(makeResponse({ message: 'Something went wrong' }, { ok: false, status: 500 }))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { dismissAllUnmatchedBooks } = useKoreaderSync()
await expect(dismissAllUnmatchedBooks()).rejects.toThrow('Something went wrong')
})
it('dismissAllUnmatchedBooks falls back to a generic message when the error body is not JSON', async () => {
apiMock.mockResolvedValueOnce(makeInvalidJsonResponse())
const { useKoreaderSync } = await import('../useKoreaderSync')
const { dismissAllUnmatchedBooks } = useKoreaderSync()
await expect(dismissAllUnmatchedBooks()).rejects.toThrow('Failed to dismiss all KOReader unmatched books')
})
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()
@@ -483,4 +578,38 @@ describe('useKoreaderSync', () => {
await expect(unlinkManualHashLink('b'.repeat(32))).rejects.toThrow('KOReader manual link not found')
})
it('removeDevice deletes the device and refreshes the sync status without toggling the page loading flag', async () => {
const refreshedStatus = makeSyncStatus({ devices: [] })
apiMock.mockResolvedValueOnce(makeResponse({ success: true })).mockResolvedValueOnce(makeResponse(refreshedStatus))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { syncStatus, loading, removeDevice } = useKoreaderSync()
const removePromise = removeDevice('device-1')
expect(loading.value).toBe(false)
await expect(removePromise).resolves.toBeUndefined()
expect(apiMock).toHaveBeenCalledWith('/api/v1/koreader/devices/device-1', { method: 'DELETE' })
expect(syncStatus.value).toEqual(refreshedStatus)
expect(loading.value).toBe(false)
})
it('removeDevice throws the server message on failure', async () => {
apiMock.mockResolvedValueOnce(makeResponse({ message: 'KOReader device not found' }, { ok: false, status: 404 }))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { removeDevice } = useKoreaderSync()
await expect(removeDevice('missing-device')).rejects.toThrow('KOReader device not found')
})
it('removeDevice falls back to a generic message when the error body is not JSON', async () => {
apiMock.mockResolvedValueOnce(makeInvalidJsonResponse())
const { useKoreaderSync } = await import('../useKoreaderSync')
const { removeDevice } = useKoreaderSync()
await expect(removeDevice('device-1')).rejects.toThrow('Failed to remove KOReader device')
})
})
@@ -2,6 +2,8 @@ import { ref } from 'vue'
import { api } from '@/lib/api'
import type {
CreateKoreaderCredentialsPayload,
DismissAllKoreaderUnmatchedBooksResult,
DismissKoreaderUnmatchedBookResult,
KoreaderCredentials,
KoreaderManualHashLink,
KoreaderSyncStatus,
@@ -22,15 +24,15 @@ const unmatchedLoading = ref(false)
const manualLinksLoading = ref(false)
export function useKoreaderSync() {
async function fetchSyncStatus(): Promise<void> {
loading.value = true
async function fetchSyncStatus(silent = false): Promise<void> {
if (!silent) loading.value = true
try {
const res = await api('/api/v1/koreader/sync-status')
if (!res.ok) throw new Error('Failed to fetch sync status')
syncStatus.value = await res.json()
credentials.value = syncStatus.value!.credentials
} finally {
loading.value = false
if (!silent) loading.value = false
}
}
@@ -44,7 +46,7 @@ export function useKoreaderSync() {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || 'Failed to create credentials')
}
await fetchSyncStatus()
await fetchSyncStatus(true)
}
async function updateCredentials(payload: UpdateKoreaderCredentialsPayload): Promise<void> {
@@ -57,7 +59,7 @@ export function useKoreaderSync() {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || 'Failed to update credentials')
}
await fetchSyncStatus()
await fetchSyncStatus(true)
}
async function deleteCredentials(): Promise<void> {
@@ -135,7 +137,31 @@ export function useKoreaderSync() {
}
const result: LinkKoreaderUnmatchedBookResult = await res.json()
unmatchedBooks.value = unmatchedBooks.value.filter((book) => book.hash !== result.hash)
await Promise.all([fetchSyncStatus(), fetchManualHashLinks()])
await Promise.all([fetchSyncStatus(true), fetchManualHashLinks()])
return result
}
async function dismissUnmatchedBook(hash: string): Promise<DismissKoreaderUnmatchedBookResult> {
const res = await api(`/api/v1/koreader/unmatched-books/${hash}`, { method: 'DELETE' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || 'Failed to dismiss KOReader unmatched book')
}
const result: DismissKoreaderUnmatchedBookResult = await res.json()
unmatchedBooks.value = unmatchedBooks.value.filter((book) => book.hash !== result.hash)
await fetchSyncStatus(true)
return result
}
async function dismissAllUnmatchedBooks(): Promise<DismissAllKoreaderUnmatchedBooksResult> {
const res = await api('/api/v1/koreader/unmatched-books', { method: 'DELETE' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || 'Failed to dismiss all KOReader unmatched books')
}
const result: DismissAllKoreaderUnmatchedBooksResult = await res.json()
unmatchedBooks.value = []
await fetchSyncStatus(true)
return result
}
@@ -150,7 +176,7 @@ export function useKoreaderSync() {
throw new Error(body.message || 'Failed to update KOReader manual link')
}
const result: LinkKoreaderUnmatchedBookResult = await res.json()
await Promise.all([fetchSyncStatus(), fetchManualHashLinks()])
await Promise.all([fetchSyncStatus(true), fetchManualHashLinks()])
return result
}
@@ -162,10 +188,19 @@ export function useKoreaderSync() {
}
const result: UnlinkKoreaderManualHashLinkResult = await res.json()
manualHashLinks.value = manualHashLinks.value.filter((link) => link.hash !== result.hash)
await Promise.all([fetchSyncStatus(), fetchUnmatchedBooks()])
await Promise.all([fetchSyncStatus(true), fetchUnmatchedBooks()])
return result
}
async function removeDevice(deviceId: string): Promise<void> {
const res = await api(`/api/v1/koreader/devices/${deviceId}`, { method: 'DELETE' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || 'Failed to remove KOReader device')
}
await fetchSyncStatus(true)
}
return {
credentials,
syncStatus,
@@ -184,8 +219,11 @@ export function useKoreaderSync() {
getSyncUrl,
downloadPluginPackage,
linkUnmatchedBook,
dismissUnmatchedBook,
dismissAllUnmatchedBooks,
relinkManualHashLink,
unlinkManualHashLink,
removeDevice,
}
}
+209 -10
View File
@@ -23,7 +23,7 @@ import {
X,
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import type { BookCard, KoreaderManualHashLink, KoreaderUnmatchedBook } from '@bookorbit/types'
import type { BookCard, KoreaderDeviceInfo, KoreaderManualHashLink, KoreaderUnmatchedBook } from '@bookorbit/types'
import SettingsPageHeader from './SettingsPageHeader.vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { copyToClipboard } from '@/lib/clipboard'
@@ -49,8 +49,11 @@ const {
getSyncUrl,
downloadPluginPackage,
linkUnmatchedBook,
dismissUnmatchedBook,
dismissAllUnmatchedBooks,
relinkManualHashLink,
unlinkManualHashLink,
removeDevice,
} = useKoreaderSync()
const error = ref<string | null>(null)
@@ -69,6 +72,12 @@ 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 removeDeviceConfirmTarget = ref<KoreaderDeviceInfo | null>(null)
const removingDeviceId = ref<string | null>(null)
const dismissConfirmBook = ref<KoreaderUnmatchedBook | null>(null)
const dismissingHash = ref<string | null>(null)
const dismissAllConfirmOpen = ref(false)
const dismissingAll = ref(false)
const unmatchedPage = ref(1)
const unmatchedPageSize = 6
const {
@@ -311,7 +320,7 @@ async function handleCopyUrl() {
async function handleRefresh() {
try {
await fetchSyncStatus()
await fetchSyncStatus(true)
if (credentials.value) await Promise.all([fetchUnmatchedBooks(), fetchManualHashLinks()])
toast.success('Sync status refreshed')
} catch {
@@ -354,6 +363,51 @@ function handleOpenLink(book: KoreaderUnmatchedBook) {
linkSearchQuery.value = book.title ?? ''
}
function handleOpenDismiss(book: KoreaderUnmatchedBook) {
dismissConfirmBook.value = book
}
function handleCloseDismiss() {
dismissConfirmBook.value = null
dismissingHash.value = null
}
async function handleDismissUnmatchedBook() {
if (!dismissConfirmBook.value) return
dismissingHash.value = dismissConfirmBook.value.hash
try {
await dismissUnmatchedBook(dismissConfirmBook.value.hash)
toast.success('Unmatched book dismissed')
handleCloseDismiss()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to dismiss unmatched book')
} finally {
dismissingHash.value = null
}
}
function handleOpenDismissAll() {
dismissAllConfirmOpen.value = true
}
function handleCloseDismissAll() {
dismissAllConfirmOpen.value = false
}
async function handleDismissAllUnmatchedBooks() {
dismissingAll.value = true
try {
const result = await dismissAllUnmatchedBooks()
toast.success(`Dismissed ${formatCount(result.count, 'unmatched book')}`)
dismissAllConfirmOpen.value = false
unmatchedPage.value = 1
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to dismiss all unmatched books')
} finally {
dismissingAll.value = false
}
}
function handleOpenRelink(link: KoreaderManualHashLink) {
selectedManualLink.value = link
selectedUnmatched.value = null
@@ -426,6 +480,29 @@ async function handleUnlinkManualLink() {
}
}
function handleOpenRemoveDevice(device: KoreaderDeviceInfo) {
removeDeviceConfirmTarget.value = device
}
function handleCloseRemoveDevice() {
removeDeviceConfirmTarget.value = null
removingDeviceId.value = null
}
async function handleRemoveDevice() {
if (!removeDeviceConfirmTarget.value) return
removingDeviceId.value = removeDeviceConfirmTarget.value.deviceId
try {
await removeDevice(removeDeviceConfirmTarget.value.deviceId)
toast.success('Device removed')
handleCloseRemoveDevice()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to remove device')
} finally {
removingDeviceId.value = null
}
}
function bookSearchSubtitle(book: BookCard): string {
const authors = book.authors.length > 0 ? book.authors.join(', ') : 'Unknown author'
const formats = book.files
@@ -647,6 +724,13 @@ async function handleDownloadPlugin() {
</p>
</div>
</div>
<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 shrink-0"
@click="handleOpenRemoveDevice(device)"
>
<Trash2 :size="12" />
Remove
</button>
</div>
</div>
</div>
@@ -736,10 +820,21 @@ async function handleDownloadPlugin() {
<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 class="flex items-center gap-2">
<button
v-if="unmatchedBooks.length > 0"
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"
:disabled="unmatchedLoading"
@click="handleOpenDismissAll"
>
<Trash2 :size="12" />
Dismiss all
</button>
<button class="settings-btn-outline" :disabled="unmatchedLoading" @click="handleRefreshUnmatched">
<RefreshCw :size="12" />
Refresh
</button>
</div>
</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>
@@ -767,10 +862,19 @@ async function handleDownloadPlugin() {
</p>
</div>
</div>
<button class="settings-btn-primary self-start md:self-auto" @click="handleOpenLink(book)">
<Link2 :size="12" />
Link
</button>
<div class="flex gap-2 self-start md:self-auto">
<button class="settings-btn-primary" @click="handleOpenLink(book)">
<Link2 :size="12" />
Link
</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="handleOpenDismiss(book)"
>
<Trash2 :size="12" />
Dismiss
</button>
</div>
</div>
</div>
<div
@@ -979,6 +1083,101 @@ async function handleDownloadPlugin() {
</div>
</div>
<div
v-if="removeDeviceConfirmTarget"
class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4"
@click.self="handleCloseRemoveDevice"
>
<button class="absolute inset-0 bg-black/45" @click="handleCloseRemoveDevice" />
<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">Remove {{ removeDeviceConfirmTarget.device }}?</p>
<p class="mt-1 text-sm text-muted-foreground">
This permanently deletes this device's synced progress, reading activity, and any unmatched-book entries no longer reported by another
device. If the device syncs again later, it will reappear as a new entry.
</p>
<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="handleCloseRemoveDevice"
>
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="removingDeviceId !== null"
@click="handleRemoveDevice"
>
{{ removingDeviceId === removeDeviceConfirmTarget.deviceId ? 'Removing...' : 'Remove' }}
</button>
</div>
</div>
</div>
<div
v-if="dismissConfirmBook"
class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4"
@click.self="handleCloseDismiss"
>
<button class="absolute inset-0 bg-black/45" @click="handleCloseDismiss" />
<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">Dismiss {{ unmatchedBookTitle(dismissConfirmBook) }}?</p>
<p class="mt-1 text-sm text-muted-foreground">
This removes it from your unmatched books list. If any device reports this hash again, it will reappear as a new entry.
</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">{{ dismissConfirmBook.hash }}</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="handleCloseDismiss"
>
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="dismissingHash !== null"
@click="handleDismissUnmatchedBook"
>
{{ dismissingHash === dismissConfirmBook.hash ? 'Dismissing...' : 'Dismiss' }}
</button>
</div>
</div>
</div>
<div
v-if="dismissAllConfirmOpen"
class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4"
@click.self="handleCloseDismissAll"
>
<button class="absolute inset-0 bg-black/45" @click="handleCloseDismissAll" />
<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">Dismiss all {{ formatCount(unmatchedBooks.length, 'unmatched book') }}?</p>
<div class="mt-2 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertTriangle :size="14" class="mt-0.5 shrink-0" />
<p>
This clears your entire unmatched books list, not just the current page. If any device reports one of these hashes again, it will reappear
as a new entry.
</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="handleCloseDismissAll"
>
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="dismissingAll"
@click="handleDismissAllUnmatchedBooks"
>
{{ dismissingAll ? 'Dismissing...' : 'Dismiss all' }}
</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"
@@ -21,8 +21,11 @@ const koreaderMock = vi.hoisted(() => ({
getSyncUrl: vi.fn<() => string>(),
downloadPluginPackage: vi.fn<() => Promise<void>>(),
linkUnmatchedBook: vi.fn<() => Promise<void>>(),
dismissUnmatchedBook: vi.fn<() => Promise<void>>(),
dismissAllUnmatchedBooks: vi.fn<() => Promise<{ count: number }>>(),
relinkManualHashLink: vi.fn<() => Promise<void>>(),
unlinkManualHashLink: vi.fn<() => Promise<void>>(),
removeDevice: vi.fn<() => Promise<void>>(),
}))
const searchMock = vi.hoisted(() => ({
@@ -197,8 +200,11 @@ describe('KoreaderSettings', () => {
koreaderMock.getSyncUrl.mockReturnValue('https://bookorbit.example')
koreaderMock.downloadPluginPackage.mockResolvedValue(undefined)
koreaderMock.linkUnmatchedBook.mockResolvedValue(undefined)
koreaderMock.dismissUnmatchedBook.mockResolvedValue(undefined)
koreaderMock.dismissAllUnmatchedBooks.mockResolvedValue({ count: 0 })
koreaderMock.relinkManualHashLink.mockResolvedValue(undefined)
koreaderMock.unlinkManualHashLink.mockResolvedValue(undefined)
koreaderMock.removeDevice.mockResolvedValue(undefined)
searchMock.results.value = []
searchMock.loading.value = false
searchMock.loadingMore.value = false
@@ -405,6 +411,139 @@ describe('KoreaderSettings', () => {
expect(koreaderMock.linkUnmatchedBook).toHaveBeenCalledWith('a'.repeat(32), { bookId: 55 })
})
it('confirms before dismissing an unmatched KOReader book', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
const wrapper = mountComponent()
await flushPromises()
await wrapper
.findAll('button')
.find((button) => button.text() === 'Dismiss')!
.trigger('click')
expect(wrapper.text()).toContain('Dismiss')
expect(wrapper.text()).toContain('a'.repeat(32))
const dismissButtons = wrapper.findAll('button').filter((button) => button.text() === 'Dismiss')
await dismissButtons[dismissButtons.length - 1]!.trigger('click')
await flushPromises()
expect(koreaderMock.dismissUnmatchedBook).toHaveBeenCalledWith('a'.repeat(32))
})
it('cancels dismissing an unmatched KOReader book without calling dismissUnmatchedBook', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
const wrapper = mountComponent()
await flushPromises()
await wrapper
.findAll('button')
.find((button) => button.text() === 'Dismiss')!
.trigger('click')
await buttonByText(wrapper, 'Cancel')!.trigger('click')
expect(koreaderMock.dismissUnmatchedBook).not.toHaveBeenCalled()
})
it('keeps the confirm dialog open and reports an error when dismissing an unmatched book fails', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
koreaderMock.dismissUnmatchedBook.mockRejectedValueOnce(new Error('Failed to dismiss KOReader unmatched book'))
const wrapper = mountComponent()
await flushPromises()
await wrapper
.findAll('button')
.find((button) => button.text() === 'Dismiss')!
.trigger('click')
const dismissButtons = wrapper.findAll('button').filter((button) => button.text() === 'Dismiss')
await dismissButtons[dismissButtons.length - 1]!.trigger('click')
await flushPromises()
expect(koreaderMock.dismissUnmatchedBook).toHaveBeenCalledWith('a'.repeat(32))
expect(wrapper.text()).toContain('a'.repeat(32))
})
it('does not show a Dismiss all button when there are no unmatched books', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = []
const wrapper = mountComponent()
await flushPromises()
expect(buttonByText(wrapper, 'Dismiss all')).toBeUndefined()
})
it('confirms before dismissing all unmatched KOReader books', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 2 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook({ hash: 'a'.repeat(32) }), makeUnmatchedBook({ hash: 'c'.repeat(32) })]
koreaderMock.dismissAllUnmatchedBooks.mockResolvedValue({ count: 2 })
const wrapper = mountComponent()
await flushPromises()
await buttonByText(wrapper, 'Dismiss all')!.trigger('click')
expect(wrapper.text()).toContain('Dismiss all 2 unmatched books?')
expect(wrapper.text()).toContain('clears your entire unmatched books list')
const dismissAllButtons = wrapper.findAll('button').filter((button) => button.text() === 'Dismiss all')
await dismissAllButtons[dismissAllButtons.length - 1]!.trigger('click')
await flushPromises()
expect(koreaderMock.dismissAllUnmatchedBooks).toHaveBeenCalledTimes(1)
})
it('cancels dismissing all unmatched KOReader books without calling dismissAllUnmatchedBooks', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
const wrapper = mountComponent()
await flushPromises()
await buttonByText(wrapper, 'Dismiss all')!.trigger('click')
await buttonByText(wrapper, 'Cancel')!.trigger('click')
expect(koreaderMock.dismissAllUnmatchedBooks).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('clears your entire unmatched books list')
})
it('keeps the confirm dialog open and reports an error when dismissing all 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.dismissAllUnmatchedBooks.mockRejectedValueOnce(new Error('Failed to dismiss all KOReader unmatched books'))
const wrapper = mountComponent()
await flushPromises()
await buttonByText(wrapper, 'Dismiss all')!.trigger('click')
const dismissAllButtons = wrapper.findAll('button').filter((button) => button.text() === 'Dismiss all')
await dismissAllButtons[dismissAllButtons.length - 1]!.trigger('click')
await flushPromises()
expect(koreaderMock.dismissAllUnmatchedBooks).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('clears your entire unmatched books list')
})
it('loads more BookOrbit search results from the link dialog', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
@@ -467,6 +606,62 @@ describe('KoreaderSettings', () => {
expect(koreaderMock.unlinkManualHashLink).toHaveBeenCalledWith('b'.repeat(32))
})
it('confirms before removing a device', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
const wrapper = mountComponent()
await flushPromises()
await buttonByText(wrapper, 'Remove')!.trigger('click')
expect(wrapper.text()).toContain('Remove Kobo Libra 2?')
expect(wrapper.text()).toContain("permanently deletes this device's synced progress, reading activity, and any unmatched-book entries")
const removeButtons = wrapper.findAll('button').filter((button) => button.text() === 'Remove')
await removeButtons[removeButtons.length - 1]!.trigger('click')
await flushPromises()
expect(koreaderMock.removeDevice).toHaveBeenCalledWith('device-1')
expect(wrapper.text()).not.toContain('Remove Kobo Libra 2?')
})
it('cancels removing a device without calling removeDevice', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
const wrapper = mountComponent()
await flushPromises()
await buttonByText(wrapper, 'Remove')!.trigger('click')
expect(wrapper.text()).toContain('Remove Kobo Libra 2?')
await buttonByText(wrapper, 'Cancel')!.trigger('click')
expect(koreaderMock.removeDevice).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('Remove Kobo Libra 2?')
})
it('keeps the confirm dialog open and reports an error when removing a device fails', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
koreaderMock.removeDevice.mockRejectedValueOnce(new Error('Failed to remove KOReader device'))
const wrapper = mountComponent()
await flushPromises()
await buttonByText(wrapper, 'Remove')!.trigger('click')
const removeButtons = wrapper.findAll('button').filter((button) => button.text() === 'Remove')
await removeButtons[removeButtons.length - 1]!.trigger('click')
await flushPromises()
expect(koreaderMock.removeDevice).toHaveBeenCalledWith('device-1')
expect(wrapper.text()).toContain('Remove Kobo Libra 2?')
})
it('renders hash-only unmatched rows with a distinguishable fallback label', async () => {
const hash = 'c1aa241d98bcdbc5c52b2eff31c4920e'
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
+8
View File
@@ -99,6 +99,14 @@ export interface LinkKoreaderUnmatchedBookResult {
bookFileId: number;
}
export interface DismissKoreaderUnmatchedBookResult {
hash: string;
}
export interface DismissAllKoreaderUnmatchedBooksResult {
count: number;
}
export interface UpdateKoreaderManualHashLinkPayload {
bookId: number;
}
@@ -0,0 +1,12 @@
CREATE TABLE "koreader_unmatched_book_devices" (
"user_id" integer NOT NULL,
"hash" varchar(32) NOT NULL,
"device_id" varchar(100) NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "koreader_unmatched_book_devices_user_id_hash_device_id_pk" PRIMARY KEY("user_id","hash","device_id"),
CONSTRAINT "koreader_unmatched_book_devices_hash_chk" CHECK ("koreader_unmatched_book_devices"."hash" ~ '^[0-9a-f]{32}$')
);
--> statement-breakpoint
ALTER TABLE "koreader_unmatched_book_devices" ADD CONSTRAINT "koreader_unmatched_book_devices_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_unmatched_book_devices" ADD CONSTRAINT "koreader_unmatched_book_devices_user_hash_fk" FOREIGN KEY ("user_id","hash") REFERENCES "public"."koreader_unmatched_books"("user_id","hash") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "koreader_unmatched_book_devices_user_device_idx" ON "koreader_unmatched_book_devices" USING btree ("user_id","device_id");
File diff suppressed because it is too large Load Diff
@@ -246,6 +246,13 @@
"when": 1782943263105,
"tag": "0034_koreader-manual-hash-links",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1783009954135,
"tag": "0035_add_koreader_unmatched_book_devices",
"breakpoints": true
}
]
}
+29
View File
@@ -3,6 +3,7 @@ import {
bigint,
boolean,
check,
foreignKey,
index,
integer,
pgTable,
@@ -159,6 +160,34 @@ export const koreaderUnmatchedBooks = pgTable(
export type KoreaderUnmatchedBook = typeof koreaderUnmatchedBooks.$inferSelect;
export type NewKoreaderUnmatchedBook = typeof koreaderUnmatchedBooks.$inferInsert;
// Tracks which devices have reported a given unmatched hash. Modeled as a proper many-to-many
// join (rather than a single "last device" column) so a hash shared across multiple devices
// isn't incorrectly dropped when only one of those devices is removed.
export const koreaderUnmatchedBookDevices = pgTable(
'koreader_unmatched_book_devices',
{
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
hash: varchar('hash', { length: 32 }).notNull(),
deviceId: varchar('device_id', { length: 100 }).notNull(),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
primaryKey({ columns: [t.userId, t.hash, t.deviceId] }),
index('koreader_unmatched_book_devices_user_device_idx').on(t.userId, t.deviceId),
foreignKey({
columns: [t.userId, t.hash],
foreignColumns: [koreaderUnmatchedBooks.userId, koreaderUnmatchedBooks.hash],
name: 'koreader_unmatched_book_devices_user_hash_fk',
}).onDelete('cascade'),
check('koreader_unmatched_book_devices_hash_chk', sql`${t.hash} ~ '^[0-9a-f]{32}$'`),
],
);
export type KoreaderUnmatchedBookDevice = typeof koreaderUnmatchedBookDevices.$inferSelect;
export type NewKoreaderUnmatchedBookDevice = typeof koreaderUnmatchedBookDevices.$inferInsert;
export const bookFileChapters = pgTable(
'book_file_chapters',
{
@@ -22,6 +22,8 @@ describe('KoreaderHashLinkService', () => {
deleteBookHashLink: ReturnType<typeof vi.fn>;
upsertUnmatchedBooks: ReturnType<typeof vi.fn>;
findBookFileIdByBookId: ReturnType<typeof vi.fn>;
dismissUnmatchedBook: ReturnType<typeof vi.fn>;
dismissAllUnmatchedBooks: ReturnType<typeof vi.fn>;
};
let mockBookService: {
verifyBookAccess: ReturnType<typeof vi.fn>;
@@ -63,6 +65,8 @@ describe('KoreaderHashLinkService', () => {
deleteBookHashLink: vi.fn().mockResolvedValue(null),
upsertUnmatchedBooks: vi.fn().mockResolvedValue(undefined),
findBookFileIdByBookId: vi.fn(),
dismissUnmatchedBook: vi.fn().mockResolvedValue(null),
dismissAllUnmatchedBooks: vi.fn().mockResolvedValue(0),
};
mockBookService = {
@@ -348,4 +352,50 @@ describe('KoreaderHashLinkService', () => {
expect(mockRepo.upsertUnmatchedBooks).not.toHaveBeenCalled();
});
});
describe('dismissUnmatchedBook', () => {
it('dismisses an unmatched book and returns its hash', async () => {
mockRepo.dismissUnmatchedBook.mockResolvedValue({ hash: HASH_A });
await expect(service.dismissUnmatchedBook(user, HASH_A)).resolves.toEqual({ hash: HASH_A });
expect(mockRepo.dismissUnmatchedBook).toHaveBeenCalledWith(7, HASH_A);
});
it('normalizes the hash before dismissing', async () => {
mockRepo.dismissUnmatchedBook.mockResolvedValue({ hash: HASH_A });
await service.dismissUnmatchedBook(user, HASH_A.toUpperCase());
expect(mockRepo.dismissUnmatchedBook).toHaveBeenCalledWith(7, HASH_A);
});
it('rejects an invalid hash before calling the repository', async () => {
await expect(service.dismissUnmatchedBook(user, 'not-a-hash')).rejects.toThrow(BadRequestException);
expect(mockRepo.dismissUnmatchedBook).not.toHaveBeenCalled();
});
it('rejects dismissing when no unmatched book exists for the user', async () => {
mockRepo.dismissUnmatchedBook.mockResolvedValue(null);
await expect(service.dismissUnmatchedBook(user, HASH_A)).rejects.toThrow(NotFoundException);
});
});
describe('dismissAllUnmatchedBooks', () => {
it('dismisses all unmatched books for the user and returns the count', async () => {
mockRepo.dismissAllUnmatchedBooks.mockResolvedValue(5);
await expect(service.dismissAllUnmatchedBooks(user)).resolves.toEqual({ count: 5 });
expect(mockRepo.dismissAllUnmatchedBooks).toHaveBeenCalledWith(7);
});
it('returns a zero count when there is nothing to dismiss', async () => {
mockRepo.dismissAllUnmatchedBooks.mockResolvedValue(0);
await expect(service.dismissAllUnmatchedBooks(user)).resolves.toEqual({ count: 0 });
});
});
});
@@ -1,6 +1,8 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type {
DismissAllKoreaderUnmatchedBooksResult,
DismissKoreaderUnmatchedBookResult,
KoreaderManualHashLink,
KoreaderUnmatchedBook,
LinkKoreaderUnmatchedBookResult,
@@ -16,6 +18,8 @@ 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';
const UNMATCHED_BOOK_DISMISS_EVENT = 'koreader.unmatched_book_dismiss';
const UNMATCHED_BOOK_DISMISS_ALL_EVENT = 'koreader.unmatched_book_dismiss_all';
/**
* Manages the KOReader "unmatched book" queue and the manual hash links used
@@ -172,6 +176,34 @@ export class KoreaderHashLinkService {
return { hash: normalizedHash };
}
async dismissUnmatchedBook(user: RequestUser, hash: string): Promise<DismissKoreaderUnmatchedBookResult> {
const startedAtMs = Date.now();
const normalizedHash = this.normalizeKoreaderHash(hash);
this.logger.log(
`[${UNMATCHED_BOOK_DISMISS_EVENT}] [start] userId=${user.id} hash=${normalizedHash.slice(0, 8)} - unmatched book dismiss started`,
);
const deleted = await this.repo.dismissUnmatchedBook(user.id, normalizedHash);
if (!deleted) throw new NotFoundException('KOReader unmatched book not found');
this.logger.log(
`[${UNMATCHED_BOOK_DISMISS_EVENT}] [end] userId=${user.id} hash=${normalizedHash.slice(0, 8)} durationMs=${Date.now() - startedAtMs} - unmatched book dismiss completed`,
);
return { hash: normalizedHash };
}
async dismissAllUnmatchedBooks(user: RequestUser): Promise<DismissAllKoreaderUnmatchedBooksResult> {
const startedAtMs = Date.now();
this.logger.log(`[${UNMATCHED_BOOK_DISMISS_ALL_EVENT}] [start] userId=${user.id} - unmatched book bulk dismiss started`);
const count = await this.repo.dismissAllUnmatchedBooks(user.id);
this.logger.log(
`[${UNMATCHED_BOOK_DISMISS_ALL_EVENT}] [end] userId=${user.id} durationMs=${Date.now() - startedAtMs} count=${count} - unmatched book bulk dismiss completed`,
);
return { count };
}
private normalizeKoreaderHash(hash: string): string {
const normalized = hash.trim().toLowerCase();
if (!MD5_HEX.test(normalized)) throw new BadRequestException('Invalid KOReader hash');
@@ -88,7 +88,7 @@ describe('KoreaderPluginService', () => {
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(koreaderRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(7, [{ hash: HASH_B, source: 'statistics' }], DEVICE_ID);
expect(result.matches).toEqual([{ hash: HASH_A, bookId: 20, bookFileId: 10 }]);
expect(result.libraryVersion).toMatch(/^[0-9a-f]{16}$/);
});
@@ -105,9 +105,11 @@ describe('KoreaderPluginService', () => {
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 },
]);
expect(koreaderRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(
7,
[{ hash: HASH_B, title: 'Unmatched title', authors: 'Author One', lastOpen: 200, source: 'statistics', metadataAmbiguous: false }],
DEVICE_ID,
);
});
it('keeps the strongest unmatched source and ambiguity flag for duplicate candidate metadata', async () => {
@@ -122,9 +124,11 @@ describe('KoreaderPluginService', () => {
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 },
]);
expect(koreaderRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(
7,
[{ hash: HASH_B, title: 'File title', authors: 'File author', lastOpen: 200, source: 'file', metadataAmbiguous: true }],
DEVICE_ID,
);
});
it('does not overwrite stronger unmatched metadata with a weaker later candidate', async () => {
@@ -139,9 +143,11 @@ describe('KoreaderPluginService', () => {
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 },
]);
expect(koreaderRepo.upsertUnmatchedBooks).toHaveBeenCalledWith(
7,
[{ hash: HASH_B, title: 'Open file title', authors: 'Open file author', lastOpen: 300, source: 'current_file', metadataAmbiguous: false }],
DEVICE_ID,
);
});
it('changes the library version token when the accessible library set changes', async () => {
@@ -69,7 +69,7 @@ export class KoreaderPluginService {
const unmatchedCandidates = this.buildUnmatchedCandidates(hashes, matchedHashes, dto);
await Promise.all([
this.koreaderRepo.clearUnmatchedBooks(user.id, matchedHashes),
this.koreaderRepo.upsertUnmatchedBooks(user.id, unmatchedCandidates),
this.koreaderRepo.upsertUnmatchedBooks(user.id, unmatchedCandidates, dto.deviceId),
]);
const matches = [...resolved.entries()].map(([hash, match]) => ({
@@ -17,12 +17,15 @@ function makeController() {
getCredentials: vi.fn().mockResolvedValue({ username: 'reader', syncEnabled: true, createdAt: '2026-01-01' }),
getSyncStatus: vi.fn().mockResolvedValue({ credentials: null }),
getDevices: vi.fn().mockResolvedValue([]),
removeDevice: vi.fn().mockResolvedValue(undefined),
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 }),
dismissUnmatchedBook: vi.fn().mockResolvedValue({ hash: 'a'.repeat(32) }),
dismissAllUnmatchedBooks: vi.fn().mockResolvedValue({ count: 3 }),
listManualHashLinks: vi.fn().mockResolvedValue([]),
relinkManualHashLink: vi.fn().mockResolvedValue({ hash: 'a'.repeat(32), bookId: 1, bookFileId: 2 }),
unlinkManualHashLink: vi.fn().mockResolvedValue({ hash: 'a'.repeat(32) }),
@@ -112,6 +115,26 @@ describe('KoreaderController', () => {
expect(koreaderService.getBookProgress).toHaveBeenCalledWith(7, 10);
});
it('forwards device removal requests to the service', async () => {
const { controller, koreaderService } = makeController();
const user = { id: 7 } as never;
await expect(controller.removeDevice(user, 'device-1')).resolves.toEqual({ success: true });
expect(koreaderService.removeDevice).toHaveBeenCalledWith(7, 'device-1');
});
it('grants Koreader Sync permission for the device removal route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, KoreaderController.prototype.removeDevice)).toBe(Permission.KoreaderSync);
});
it('grants Koreader Sync permission for the unmatched-book dismiss route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, KoreaderController.prototype.dismissUnmatchedBook)).toBe(Permission.KoreaderSync);
});
it('grants Koreader Sync permission for the unmatched-book bulk dismiss route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, KoreaderController.prototype.dismissAllUnmatchedBooks)).toBe(Permission.KoreaderSync);
});
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;
@@ -123,6 +146,12 @@ describe('KoreaderController', () => {
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.dismissUnmatchedBook(user, hash)).resolves.toEqual({ hash });
expect(hashLinkService.dismissUnmatchedBook).toHaveBeenCalledWith(user, hash);
await expect(controller.dismissAllUnmatchedBooks(user)).resolves.toEqual({ count: 3 });
expect(hashLinkService.dismissAllUnmatchedBooks).toHaveBeenCalledWith(user);
await expect(controller.listManualHashLinks(user)).resolves.toEqual([]);
expect(hashLinkService.listManualHashLinks).toHaveBeenCalledWith(user);
@@ -114,6 +114,13 @@ export class KoreaderController {
return this.koreaderService.getDevices(user.id);
}
@RequirePermission(Permission.KoreaderSync)
@Delete('devices/:deviceId')
async removeDevice(@CurrentUser() user: RequestUser, @Param('deviceId') deviceId: string) {
await this.koreaderService.removeDevice(user.id, deviceId);
return { success: true };
}
@RequirePermission(Permission.KoreaderSync)
@Get('unmatched-books')
listUnmatchedBooks(@CurrentUser() user: RequestUser) {
@@ -126,6 +133,18 @@ export class KoreaderController {
return this.hashLinkService.linkUnmatchedBook(user, hash, dto.bookId);
}
@RequirePermission(Permission.KoreaderSync)
@Delete('unmatched-books/:hash')
dismissUnmatchedBook(@CurrentUser() user: RequestUser, @Param('hash') hash: string) {
return this.hashLinkService.dismissUnmatchedBook(user, hash);
}
@RequirePermission(Permission.KoreaderSync)
@Delete('unmatched-books')
dismissAllUnmatchedBooks(@CurrentUser() user: RequestUser) {
return this.hashLinkService.dismissAllUnmatchedBooks(user);
}
@RequirePermission(Permission.KoreaderSync)
@Get('hash-links')
listManualHashLinks(@CurrentUser() user: RequestUser) {
@@ -25,6 +25,7 @@ function makeDb() {
update: vi.fn(),
delete: vi.fn(),
execute: vi.fn(),
transaction: vi.fn(),
query: {
users: { findFirst: vi.fn() },
koreaderUsers: { findFirst: vi.fn() },
@@ -157,15 +158,19 @@ describe('KoreaderRepository', () => {
});
describe('unmatched books and manual hash links', () => {
it('upserts unmatched candidates with trimmed nullable metadata', async () => {
it('upserts unmatched candidates with trimmed nullable metadata and no device association when deviceId is omitted', async () => {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
db.insert.mockReturnValue({ values });
const txInsert = vi.fn().mockReturnValue({ values });
const tx = { insert: txInsert };
db.transaction.mockImplementation(async (handler: (client: typeof tx) => Promise<void>) => handler(tx));
await repo.upsertUnmatchedBooks(7, [
{ hash: 'a'.repeat(32), title: ' Title ', authors: ' Author ', lastOpen: 100, source: 'file', metadataAmbiguous: true },
]);
expect(db.transaction).toHaveBeenCalledTimes(1);
expect(txInsert).toHaveBeenCalledTimes(1);
expect(values).toHaveBeenCalledWith([
expect.objectContaining({
userId: 7,
@@ -196,6 +201,28 @@ describe('KoreaderRepository', () => {
expect(sqlChunkText(conflictSet.title)).toContain('coalesce');
});
it('also upserts a device association when a deviceId is provided', async () => {
const booksOnConflict = vi.fn().mockResolvedValue(undefined);
const booksValues = vi.fn().mockReturnValue({ onConflictDoUpdate: booksOnConflict });
const devicesOnConflict = vi.fn().mockResolvedValue(undefined);
const devicesValues = vi.fn().mockReturnValue({ onConflictDoUpdate: devicesOnConflict });
const txInsert = vi.fn().mockReturnValueOnce({ values: booksValues }).mockReturnValueOnce({ values: devicesValues });
const tx = { insert: txInsert };
db.transaction.mockImplementation(async (handler: (client: typeof tx) => Promise<void>) => handler(tx));
await repo.upsertUnmatchedBooks(7, [{ hash: 'a'.repeat(32), source: 'statistics' }], 'device-1');
expect(txInsert).toHaveBeenCalledTimes(2);
expect(devicesValues).toHaveBeenCalledWith([{ userId: 7, hash: 'a'.repeat(32), deviceId: 'device-1', lastSeenAt: expect.any(Date) }]);
expect(devicesOnConflict).toHaveBeenCalledWith(expect.objectContaining({ target: expect.any(Array), set: { lastSeenAt: expect.any(Date) } }));
});
it('does not touch device associations when no candidates are given', async () => {
await repo.upsertUnmatchedBooks(7, [], 'device-1');
expect(db.transaction).not.toHaveBeenCalled();
});
it('clears unmatched books for a user and hash set', async () => {
const where = vi.fn().mockResolvedValue(undefined);
db.delete.mockReturnValue({ where });
@@ -212,6 +239,34 @@ describe('KoreaderRepository', () => {
expect(db.delete).not.toHaveBeenCalled();
});
it('dismisses and returns a user-scoped unmatched book', async () => {
const chain = makeQueryChain([{ hash: 'a'.repeat(32) }]);
db.delete.mockReturnValue(chain);
await expect(repo.dismissUnmatchedBook(7, 'a'.repeat(32))).resolves.toEqual({ hash: 'a'.repeat(32) });
expect(chain.returning).toHaveBeenCalledTimes(1);
});
it('returns null when dismissing an unmatched book that does not exist for the user', async () => {
db.delete.mockReturnValue(makeQueryChain([]));
await expect(repo.dismissUnmatchedBook(7, 'a'.repeat(32))).resolves.toBeNull();
});
it('dismisses all visible unmatched books for a user and returns the count', async () => {
const chain = makeQueryChain([{ hash: 'a'.repeat(32) }, { hash: 'b'.repeat(32) }, { hash: 'c'.repeat(32) }]);
db.delete.mockReturnValue(chain);
await expect(repo.dismissAllUnmatchedBooks(7)).resolves.toBe(3);
expect(chain.returning).toHaveBeenCalledTimes(1);
});
it('returns zero when there are no unmatched books to dismiss', async () => {
db.delete.mockReturnValue(makeQueryChain([]));
await expect(repo.dismissAllUnmatchedBooks(7)).resolves.toBe(0);
});
it('lists unmatched books newest first with the requested limit', async () => {
const rows = [{ hash: 'a'.repeat(32), lastSeenAt: new Date() }];
const chain = makeQueryChain(rows);
@@ -485,6 +540,55 @@ describe('KoreaderRepository', () => {
await expect(repo.getTotalSyncedBooks(42)).resolves.toBe(3);
await expect(repo.getTotalSyncedBooks(42)).resolves.toBe(0);
});
it('removeDevice deletes progress/sweep/page-stat/unmatched-device-link rows and cleans up orphaned unmatched books, summing everything', async () => {
const returning = vi
.fn()
.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]) // device progress
.mockResolvedValueOnce([{ deviceId: 'device-1' }]) // device sweeps
.mockResolvedValueOnce([{ id: 5 }]) // page stats
.mockResolvedValueOnce([{ hash: 'a'.repeat(32) }]) // unmatched-book device links
.mockResolvedValueOnce([{ hash: 'a'.repeat(32) }]); // orphaned unmatched books cleanup
const txDeleteBuilder = { where: vi.fn().mockReturnValue({ returning }) };
const txSelectBuilder = { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue('SUBQUERY') }) };
const tx = { delete: vi.fn().mockReturnValue(txDeleteBuilder), select: vi.fn().mockReturnValue(txSelectBuilder) };
db.transaction.mockImplementation(async (handler: (client: typeof tx) => Promise<number>) => handler(tx));
await expect(repo.removeDevice(42, 'device-1')).resolves.toBe(6);
expect(db.transaction).toHaveBeenCalledTimes(1);
expect(tx.delete).toHaveBeenCalledTimes(5);
expect(txDeleteBuilder.where).toHaveBeenCalledTimes(5);
expect(returning).toHaveBeenCalledTimes(5);
expect(tx.select).toHaveBeenCalledTimes(1);
});
it('removeDevice skips the orphaned unmatched-book cleanup when the device had no unmatched-book links', async () => {
const returning = vi
.fn()
.mockResolvedValueOnce([{ id: 1 }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]); // no unmatched-book device links removed
const txDeleteBuilder = { where: vi.fn().mockReturnValue({ returning }) };
const tx = { delete: vi.fn().mockReturnValue(txDeleteBuilder), select: vi.fn() };
db.transaction.mockImplementation(async (handler: (client: typeof tx) => Promise<number>) => handler(tx));
await expect(repo.removeDevice(42, 'device-1')).resolves.toBe(1);
expect(tx.delete).toHaveBeenCalledTimes(4);
expect(tx.select).not.toHaveBeenCalled();
});
it('removeDevice returns zero when nothing matched the given user and device', async () => {
const returning = vi.fn().mockResolvedValue([]);
const txDeleteBuilder = { where: vi.fn().mockReturnValue({ returning }) };
const tx = { delete: vi.fn().mockReturnValue(txDeleteBuilder), select: vi.fn() };
db.transaction.mockImplementation(async (handler: (client: typeof tx) => Promise<number>) => handler(tx));
await expect(repo.removeDevice(42, 'unknown-device')).resolves.toBe(0);
expect(tx.select).not.toHaveBeenCalled();
});
});
describe('reading progress records', () => {
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import { and, desc, eq, inArray, notExists, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -163,63 +163,80 @@ export class KoreaderRepository {
return result;
}
async upsertUnmatchedBooks(userId: number, candidates: KoreaderUnmatchedCandidate[]): Promise<void> {
async upsertUnmatchedBooks(userId: number, candidates: KoreaderUnmatchedCandidate[], deviceId?: string): 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,
},
});
await this.db.transaction(async (tx) => {
await tx
.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,
},
});
if (!deviceId) return;
await tx
.insert(schema.koreaderUnmatchedBookDevices)
.values(candidates.map((candidate) => ({ userId, hash: candidate.hash, deviceId, lastSeenAt: now })))
.onConflictDoUpdate({
target: [
schema.koreaderUnmatchedBookDevices.userId,
schema.koreaderUnmatchedBookDevices.hash,
schema.koreaderUnmatchedBookDevices.deviceId,
],
set: { lastSeenAt: now },
});
});
}
async clearUnmatchedBooks(userId: number, hashes: string[]): Promise<void> {
@@ -229,6 +246,33 @@ export class KoreaderRepository {
.where(and(eq(schema.koreaderUnmatchedBooks.userId, userId), inArray(schema.koreaderUnmatchedBooks.hash, hashes)));
}
async dismissUnmatchedBook(userId: number, hash: string) {
// The koreader_unmatched_book_devices FK is ON DELETE CASCADE, so removing this row also
// clears any device associations for it - no separate cleanup needed here.
const [row] = await this.db
.delete(schema.koreaderUnmatchedBooks)
.where(and(eq(schema.koreaderUnmatchedBooks.userId, userId), eq(schema.koreaderUnmatchedBooks.hash, hash)))
.returning({ hash: schema.koreaderUnmatchedBooks.hash });
return row ?? null;
}
async dismissAllUnmatchedBooks(userId: number): Promise<number> {
// Scoped to the same source/ambiguity filter as listUnmatchedBooks so this only clears what
// the "Unmatched Books" list actually shows the user - not internal statistics-only or
// ambiguous candidates that were never surfaced.
const rows = await this.db
.delete(schema.koreaderUnmatchedBooks)
.where(
and(
eq(schema.koreaderUnmatchedBooks.userId, userId),
inArray(schema.koreaderUnmatchedBooks.source, ['current_file', 'file']),
eq(schema.koreaderUnmatchedBooks.metadataAmbiguous, false),
),
)
.returning({ hash: schema.koreaderUnmatchedBooks.hash });
return rows.length;
}
async listUnmatchedBooks(userId: number, limit: number) {
return this.db
.select()
@@ -506,6 +550,60 @@ export class KoreaderRepository {
}));
}
async removeDevice(userId: number, deviceId: string): Promise<number> {
return this.db.transaction(async (tx) => {
const [deletedProgress, deletedSweep, deletedPageStats, deletedUnmatchedDeviceLinks] = await Promise.all([
tx
.delete(schema.koreaderDeviceProgress)
.where(and(eq(schema.koreaderDeviceProgress.userId, userId), eq(schema.koreaderDeviceProgress.deviceId, deviceId)))
.returning({ id: schema.koreaderDeviceProgress.id }),
tx
.delete(schema.koreaderDeviceSweeps)
.where(and(eq(schema.koreaderDeviceSweeps.userId, userId), eq(schema.koreaderDeviceSweeps.deviceId, deviceId)))
.returning({ deviceId: schema.koreaderDeviceSweeps.deviceId }),
tx
.delete(schema.koreaderPageStats)
.where(and(eq(schema.koreaderPageStats.userId, userId), eq(schema.koreaderPageStats.deviceId, deviceId)))
.returning({ id: schema.koreaderPageStats.id }),
tx
.delete(schema.koreaderUnmatchedBookDevices)
.where(and(eq(schema.koreaderUnmatchedBookDevices.userId, userId), eq(schema.koreaderUnmatchedBookDevices.deviceId, deviceId)))
.returning({ hash: schema.koreaderUnmatchedBookDevices.hash }),
]);
// Only drop unmatched-book rows this device orphaned - a hash still reported by another
// device must stay visible until that device is removed (or the hash is matched/linked).
const affectedHashes = [...new Set(deletedUnmatchedDeviceLinks.map((row) => row.hash))];
let deletedUnmatchedBooks: { hash: string }[] = [];
if (affectedHashes.length > 0) {
deletedUnmatchedBooks = await tx
.delete(schema.koreaderUnmatchedBooks)
.where(
and(
eq(schema.koreaderUnmatchedBooks.userId, userId),
inArray(schema.koreaderUnmatchedBooks.hash, affectedHashes),
notExists(
tx
.select({ one: sql`1` })
.from(schema.koreaderUnmatchedBookDevices)
.where(
and(
eq(schema.koreaderUnmatchedBookDevices.userId, userId),
eq(schema.koreaderUnmatchedBookDevices.hash, schema.koreaderUnmatchedBooks.hash),
),
),
),
),
)
.returning({ hash: schema.koreaderUnmatchedBooks.hash });
}
return (
deletedProgress.length + deletedSweep.length + deletedPageStats.length + deletedUnmatchedDeviceLinks.length + deletedUnmatchedBooks.length
);
});
}
async getTotalSyncedBooks(userId: number): Promise<number> {
const [result] = await this.db
.select({ count: sql<number>`count(distinct ${schema.books.id})` })
@@ -64,6 +64,7 @@ describe('KoreaderService', () => {
getBookProgressForDashboard: ReturnType<typeof vi.fn>;
getChapters: ReturnType<typeof vi.fn>;
getLastFileWriteTime: ReturnType<typeof vi.fn>;
removeDevice: ReturnType<typeof vi.fn>;
};
let mockChapterService: {
parseChapterIndexFromProgress: ReturnType<typeof vi.fn>;
@@ -126,6 +127,7 @@ describe('KoreaderService', () => {
getBookProgressForDashboard: vi.fn(),
getChapters: vi.fn(),
getLastFileWriteTime: vi.fn(),
removeDevice: vi.fn(),
};
mockChapterService = {
@@ -729,6 +731,23 @@ describe('KoreaderService', () => {
});
});
describe('removeDevice', () => {
it('delegates deletion to the repository when rows were removed', async () => {
mockRepo.removeDevice.mockResolvedValue(3);
await service.removeDevice(7, 'device-1');
expect(mockRepo.removeDevice).toHaveBeenCalledWith(7, 'device-1');
});
it('throws NotFoundException when no rows matched the given device', async () => {
mockRepo.removeDevice.mockResolvedValue(0);
await expect(service.removeDevice(7, 'missing-device')).rejects.toThrow(NotFoundException);
expect(mockRepo.removeDevice).toHaveBeenCalledWith(7, 'missing-device');
});
});
describe('getBookProgress', () => {
it('returns full sync info with chapters when KOReader is the canonical source', async () => {
const latestDeviceTime = new Date('2026-03-01T10:00:00.000Z');
@@ -16,6 +16,7 @@ import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED } fro
const BCRYPT_ROUNDS = 12;
const SYNC_EVENT = 'koreader.sync';
const CREDENTIALS_EVENT = 'koreader.credentials';
const DEVICE_REMOVE_EVENT = 'koreader.device_remove';
const DEFAULT_DEVICE = 'KOReader';
@Injectable()
@@ -273,6 +274,24 @@ export class KoreaderService {
}));
}
async removeDevice(userId: number, deviceId: string): Promise<void> {
const startedAt = Date.now();
this.logger.log(`[${DEVICE_REMOVE_EVENT}] [start] userId=${userId} deviceId=${deviceId} - remove device started`);
const deletedRows = await this.repo.removeDevice(userId, deviceId);
if (deletedRows === 0) {
this.logger.warn(
`[${DEVICE_REMOVE_EVENT}] [fail] userId=${userId} deviceId=${deviceId} durationMs=${Date.now() - startedAt} errorClass=NotFoundException error="device not found" - remove device failed`,
);
throw new NotFoundException('KOReader device not found');
}
this.logger.log(
`[${DEVICE_REMOVE_EVENT}] [end] userId=${userId} deviceId=${deviceId} durationMs=${Date.now() - startedAt} deletedRows=${deletedRows} - remove device completed`,
);
}
async getBookProgress(userId: number, bookId: number): Promise<KoreaderBookSyncInfo | null> {
const bookFileId = await this.repo.findBookFileIdByBookId(bookId);
if (!bookFileId) return null;