From a0dad230871b99e378deafae8eea477e68740c8e Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:32:02 +0000 Subject: [PATCH] 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 --- .../__tests__/useKoreaderSync.spec.ts | 192 +- .../koreader/composables/useKoreaderSync.ts | 99 +- .../features/settings/KoreaderSettings.vue | 484 +- .../__tests__/KoreaderSettings.spec.ts | 354 +- .../bookorbit.koplugin/bookorbit_api.lua | 19 +- .../bookorbit_book_sync.lua | 52 +- .../bookorbit_catalog_download.lua | 2 +- .../bookorbit_stats_reader.lua | 67 +- .../bookorbit.koplugin/bookorbit_sweep.lua | 54 +- packages/types/src/koreader.ts | 41 + .../src/common/test-utils/sql-chunk-text.ts | 17 + .../0034_koreader-manual-hash-links.sql | 35 + .../src/db/migrations/meta/0034_snapshot.json | 12594 ++++++++++++++++ server/src/db/migrations/meta/_journal.json | 7 + server/src/db/schema/koreader.ts | 57 + server/src/modules/koreader/dto/index.ts | 1 + .../modules/koreader/dto/koreader-link.dto.ts | 13 + .../koreader/dto/koreader-plugin.dto.ts | 37 + .../koreader-annotation-exchange.service.ts | 4 +- .../koreader-hash-link.service.test.ts | 351 + .../koreader/koreader-hash-link.service.ts | 184 + .../koreader-plugin-annotation.service.ts | 2 +- .../koreader/koreader-plugin-source.test.ts | 53 + .../koreader-plugin.repository.test.ts | 95 + .../koreader/koreader-plugin.repository.ts | 21 +- .../koreader/koreader-plugin.service.test.ts | 85 +- .../koreader/koreader-plugin.service.ts | 79 +- .../koreader/koreader-stats.service.test.ts | 2 +- .../koreader/koreader-stats.service.ts | 2 +- .../koreader/koreader.controller.test.ts | 161 + .../modules/koreader/koreader.controller.ts | 42 +- .../modules/koreader/koreader.module.test.ts | 41 + .../src/modules/koreader/koreader.module.ts | 2 + .../koreader/koreader.repository.test.ts | 206 + .../modules/koreader/koreader.repository.ts | 239 +- .../modules/koreader/koreader.service.test.ts | 15 +- .../src/modules/koreader/koreader.service.ts | 4 +- 37 files changed, 15654 insertions(+), 59 deletions(-) create mode 100644 server/src/common/test-utils/sql-chunk-text.ts create mode 100644 server/src/db/migrations/0034_koreader-manual-hash-links.sql create mode 100644 server/src/db/migrations/meta/0034_snapshot.json create mode 100644 server/src/modules/koreader/dto/koreader-link.dto.ts create mode 100644 server/src/modules/koreader/koreader-hash-link.service.test.ts create mode 100644 server/src/modules/koreader/koreader-hash-link.service.ts create mode 100644 server/src/modules/koreader/koreader-plugin.repository.test.ts create mode 100644 server/src/modules/koreader/koreader.controller.test.ts create mode 100644 server/src/modules/koreader/koreader.module.test.ts diff --git a/client/src/features/koreader/composables/__tests__/useKoreaderSync.spec.ts b/client/src/features/koreader/composables/__tests__/useKoreaderSync.spec.ts index 6496f8984..a5427033c 100644 --- a/client/src/features/koreader/composables/__tests__/useKoreaderSync.spec.ts +++ b/client/src/features/koreader/composables/__tests__/useKoreaderSync.spec.ts @@ -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>()) @@ -32,7 +39,43 @@ function makeSyncStatus(overrides: Partial = {}): 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 { + 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 { + 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') + }) }) diff --git a/client/src/features/koreader/composables/useKoreaderSync.ts b/client/src/features/koreader/composables/useKoreaderSync.ts index fc649b8db..19f7cc196 100644 --- a/client/src/features/koreader/composables/useKoreaderSync.ts +++ b/client/src/features/koreader/composables/useKoreaderSync.ts @@ -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(null) const syncStatus = ref(null) +const unmatchedBooks = ref([]) +const manualHashLinks = ref([]) const loading = ref(false) +const unmatchedLoading = ref(false) +const manualLinksLoading = ref(false) export function useKoreaderSync() { async function fetchSyncStatus(): Promise { @@ -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 { @@ -78,17 +95,97 @@ export function useKoreaderSync() { triggerBlobDownload(blob, 'bookorbit.koplugin.zip') } + async function fetchUnmatchedBooks(): Promise { + 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 { + 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 { + 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 { + 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 { + 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, } } diff --git a/client/src/features/settings/KoreaderSettings.vue b/client/src/features/settings/KoreaderSettings.vue index 7d9d418b9..5baabbd98 100644 --- a/client/src/features/settings/KoreaderSettings.vue +++ b/client/src/features/settings/KoreaderSettings.vue @@ -1,40 +1,56 @@