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