mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user