mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(koreader): keep manual links to a different copy matched and suggest links
Report a linked copy whose bytes differ from the library file as positionIncompatible instead of unmatched, so the plugin keeps the match, skips highlights without a failure and still syncs progress. Treat a position-less place within a page as the same place and jump to the nearest page. Suggest library books with the same title and authors for books to review, with bulk linking. See docs/KOREADER_HASH_MATCHING.md. Closes #1872 Closes #1919
This commit is contained in:
@@ -76,6 +76,7 @@ function makeManualHashLink(overrides: Partial<KoreaderManualHashLink> = {}): Ko
|
||||
koreaderTitle: 'KOReader Title',
|
||||
koreaderAuthors: 'Device Author',
|
||||
koreaderLastOpen: 1700000001,
|
||||
positionCompatible: true,
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
...overrides,
|
||||
@@ -441,6 +442,72 @@ describe('useKoreaderSync', () => {
|
||||
expect(manualHashLinks.value).toEqual(manualLinks)
|
||||
})
|
||||
|
||||
it('fetchLinkSuggestions stores suggestions and treats any failure as none', async () => {
|
||||
const suggestions = [{ hash: 'a'.repeat(32), candidates: [], moreCandidates: false }]
|
||||
apiMock
|
||||
.mockResolvedValueOnce(makeResponse(suggestions))
|
||||
.mockResolvedValueOnce(makeResponse({ message: 'Not Found' }, { ok: false, status: 404 }))
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
|
||||
const { useKoreaderSync } = await import('../useKoreaderSync')
|
||||
const { linkSuggestions, fetchLinkSuggestions } = useKoreaderSync()
|
||||
|
||||
await fetchLinkSuggestions()
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/v1/koreader/unmatched-books/suggestions')
|
||||
expect(linkSuggestions.value).toEqual(suggestions)
|
||||
|
||||
await fetchLinkSuggestions()
|
||||
expect(linkSuggestions.value).toEqual([])
|
||||
|
||||
linkSuggestions.value = suggestions
|
||||
await expect(fetchLinkSuggestions()).resolves.toBeUndefined()
|
||||
expect(linkSuggestions.value).toEqual([])
|
||||
})
|
||||
|
||||
it('linkSuggestedBooks links each pair, keeps failures listed, and refreshes once', async () => {
|
||||
const [hashA, hashB, hashC] = ['a'.repeat(32), 'b'.repeat(32), 'c'.repeat(32)]
|
||||
apiMock.mockImplementation(async (input) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith(`${hashB}/link`)) return makeResponse({ message: 'conflict' }, { ok: false, status: 409 })
|
||||
if (url.endsWith(`${hashC}/link`)) throw new Error('offline')
|
||||
if (url.endsWith('/link')) return makeResponse({ hash: hashA, bookId: 1, bookFileId: 2 })
|
||||
if (url.endsWith('/sync-status')) return makeResponse(makeSyncStatus())
|
||||
return makeResponse([])
|
||||
})
|
||||
|
||||
const { useKoreaderSync } = await import('../useKoreaderSync')
|
||||
const { unmatchedBooks, linkSuggestions, linkSuggestedBooks } = useKoreaderSync()
|
||||
unmatchedBooks.value = [makeUnmatchedBook({ hash: hashA }), makeUnmatchedBook({ hash: hashB }), makeUnmatchedBook({ hash: hashC })]
|
||||
linkSuggestions.value = [hashA, hashB, hashC].map((hash) => ({ hash, candidates: [], moreCandidates: false }))
|
||||
|
||||
const outcome = await linkSuggestedBooks([
|
||||
{ hash: hashA, bookId: 1 },
|
||||
{ hash: hashB, bookId: 2 },
|
||||
{ hash: hashC, bookId: 3 },
|
||||
])
|
||||
|
||||
expect(outcome.linked).toBe(1)
|
||||
expect([...outcome.failed].sort()).toEqual([hashB, hashC])
|
||||
expect(unmatchedBooks.value.map((book) => book.hash)).toEqual([hashB, hashC])
|
||||
expect(linkSuggestions.value.map((suggestion) => suggestion.hash)).toEqual([hashB, hashC])
|
||||
const urls = apiMock.mock.calls.map(([input]) => String(input))
|
||||
expect(urls.filter((url) => url.endsWith('/link'))).toHaveLength(3)
|
||||
expect(urls.filter((url) => url.endsWith('/sync-status'))).toHaveLength(1)
|
||||
expect(urls.filter((url) => url.endsWith('/hash-links'))).toHaveLength(1)
|
||||
const [, request] = apiMock.mock.calls.find(([input]) => String(input).endsWith(`${hashA}/link`)) as [string, RequestInit]
|
||||
expect(JSON.parse(String(request.body))).toEqual({ bookId: 1 })
|
||||
})
|
||||
|
||||
it('linkSuggestedBooks skips the refresh when nothing linked', async () => {
|
||||
apiMock.mockResolvedValue(makeResponse({ message: 'gone' }, { ok: false, status: 404 }))
|
||||
|
||||
const { useKoreaderSync } = await import('../useKoreaderSync')
|
||||
const { linkSuggestedBooks } = useKoreaderSync()
|
||||
|
||||
await expect(linkSuggestedBooks([{ hash: 'a'.repeat(32), bookId: 1 }])).resolves.toEqual({ linked: 0, failed: ['a'.repeat(32)] })
|
||||
expect(apiMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('linkUnmatchedBook throws the server message on failure', async () => {
|
||||
apiMock.mockResolvedValueOnce(makeResponse({ message: 'KOReader hash already matches a different book' }, { ok: false, status: 409 }))
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
KoreaderManualHashLink,
|
||||
KoreaderSyncStatus,
|
||||
KoreaderUnmatchedBook,
|
||||
KoreaderUnmatchedBookSuggestion,
|
||||
LinkKoreaderUnmatchedBookPayload,
|
||||
LinkKoreaderUnmatchedBookResult,
|
||||
UnlinkKoreaderManualHashLinkResult,
|
||||
@@ -19,6 +20,7 @@ const credentials = ref<KoreaderCredentials | null>(null)
|
||||
const syncStatus = ref<KoreaderSyncStatus | null>(null)
|
||||
const unmatchedBooks = ref<KoreaderUnmatchedBook[]>([])
|
||||
const manualHashLinks = ref<KoreaderManualHashLink[]>([])
|
||||
const linkSuggestions = ref<KoreaderUnmatchedBookSuggestion[]>([])
|
||||
const loading = ref(false)
|
||||
const unmatchedLoading = ref(false)
|
||||
const manualLinksLoading = ref(false)
|
||||
@@ -33,6 +35,13 @@ export class KoreaderFileNamingRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const SUGGESTED_LINK_CONCURRENCY = 3
|
||||
|
||||
export interface SuggestedLinkOutcome {
|
||||
linked: number
|
||||
failed: string[]
|
||||
}
|
||||
|
||||
export function useKoreaderSync() {
|
||||
async function fetchSyncStatus(silent = false): Promise<void> {
|
||||
if (!silent) loading.value = true
|
||||
@@ -79,6 +88,7 @@ export function useKoreaderSync() {
|
||||
syncStatus.value = null
|
||||
unmatchedBooks.value = []
|
||||
manualHashLinks.value = []
|
||||
linkSuggestions.value = []
|
||||
}
|
||||
|
||||
async function testConnection(username: string, password: string): Promise<boolean> {
|
||||
@@ -121,6 +131,21 @@ export function useKoreaderSync() {
|
||||
}
|
||||
}
|
||||
|
||||
// Suggestions are optional help: a server without the route, or a failed request, just shows none.
|
||||
async function fetchLinkSuggestions(): Promise<void> {
|
||||
try {
|
||||
const res = await api('/api/v1/koreader/unmatched-books/suggestions')
|
||||
linkSuggestions.value = res.ok ? await res.json() : []
|
||||
} catch {
|
||||
linkSuggestions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function forgetUnmatched(hashes: ReadonlySet<string>): void {
|
||||
unmatchedBooks.value = unmatchedBooks.value.filter((book) => !hashes.has(book.hash))
|
||||
linkSuggestions.value = linkSuggestions.value.filter((suggestion) => !hashes.has(suggestion.hash))
|
||||
}
|
||||
|
||||
async function fetchManualHashLinks(): Promise<void> {
|
||||
manualLinksLoading.value = true
|
||||
try {
|
||||
@@ -146,11 +171,41 @@ export function useKoreaderSync() {
|
||||
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)
|
||||
forgetUnmatched(new Set([result.hash]))
|
||||
await Promise.all([fetchSyncStatus(true), fetchManualHashLinks()])
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Links each confirmed suggestion through the same route as a manual link, a few at a time, and
|
||||
* refreshes the dependent lists once at the end. Returns how many linked and which hashes failed.
|
||||
*/
|
||||
async function linkSuggestedBooks(links: readonly { hash: string; bookId: number }[]): Promise<SuggestedLinkOutcome> {
|
||||
const linked = new Set<string>()
|
||||
const failed: string[] = []
|
||||
let next = 0
|
||||
async function worker(): Promise<void> {
|
||||
while (next < links.length) {
|
||||
const link = links[next++]!
|
||||
try {
|
||||
const res = await api(`/api/v1/koreader/unmatched-books/${link.hash}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bookId: link.bookId }),
|
||||
})
|
||||
if (res.ok) linked.add(link.hash)
|
||||
else failed.push(link.hash)
|
||||
} catch {
|
||||
failed.push(link.hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(SUGGESTED_LINK_CONCURRENCY, links.length) }, worker))
|
||||
forgetUnmatched(linked)
|
||||
if (linked.size > 0) await Promise.all([fetchSyncStatus(true), fetchManualHashLinks()])
|
||||
return { linked: linked.size, failed }
|
||||
}
|
||||
|
||||
async function dismissUnmatchedBook(hash: string): Promise<DismissKoreaderUnmatchedBookResult> {
|
||||
const res = await api(`/api/v1/koreader/unmatched-books/${hash}`, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
@@ -158,7 +213,7 @@ export function useKoreaderSync() {
|
||||
throw new Error(body.message || 'Failed to dismiss KOReader unmatched book')
|
||||
}
|
||||
const result: DismissKoreaderUnmatchedBookResult = await res.json()
|
||||
unmatchedBooks.value = unmatchedBooks.value.filter((book) => book.hash !== result.hash)
|
||||
forgetUnmatched(new Set([result.hash]))
|
||||
await fetchSyncStatus(true)
|
||||
return result
|
||||
}
|
||||
@@ -171,6 +226,7 @@ export function useKoreaderSync() {
|
||||
}
|
||||
const result: DismissAllKoreaderUnmatchedBooksResult = await res.json()
|
||||
unmatchedBooks.value = []
|
||||
linkSuggestions.value = []
|
||||
await fetchSyncStatus(true)
|
||||
return result
|
||||
}
|
||||
@@ -198,7 +254,7 @@ export function useKoreaderSync() {
|
||||
}
|
||||
const result: UnlinkKoreaderManualHashLinkResult = await res.json()
|
||||
manualHashLinks.value = manualHashLinks.value.filter((link) => link.hash !== result.hash)
|
||||
await Promise.all([fetchSyncStatus(true), fetchUnmatchedBooks()])
|
||||
await Promise.all([fetchSyncStatus(true), fetchUnmatchedBooks(), fetchLinkSuggestions()])
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -265,6 +321,7 @@ export function useKoreaderSync() {
|
||||
syncStatus,
|
||||
unmatchedBooks,
|
||||
manualHashLinks,
|
||||
linkSuggestions,
|
||||
loading,
|
||||
unmatchedLoading,
|
||||
manualLinksLoading,
|
||||
@@ -272,6 +329,7 @@ export function useKoreaderSync() {
|
||||
fetchSyncStatus,
|
||||
fetchUnmatchedBooks,
|
||||
fetchManualHashLinks,
|
||||
fetchLinkSuggestions,
|
||||
createCredentials,
|
||||
updateCredentials,
|
||||
deleteCredentials,
|
||||
@@ -279,6 +337,7 @@ export function useKoreaderSync() {
|
||||
getSyncUrl,
|
||||
downloadPluginPackage,
|
||||
linkUnmatchedBook,
|
||||
linkSuggestedBooks,
|
||||
dismissUnmatchedBook,
|
||||
dismissAllUnmatchedBooks,
|
||||
relinkManualHashLink,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formatDate as formatLocaleDate } from '@/i18n/formatters'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
ArrowRight,
|
||||
ArchiveRestore,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
@@ -17,8 +18,10 @@ import {
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info,
|
||||
KeyRound,
|
||||
Library,
|
||||
Link2,
|
||||
@@ -34,10 +37,13 @@ import type {
|
||||
BookCard,
|
||||
KoreaderDeviceInfo,
|
||||
KoreaderDeviceSweepInfo,
|
||||
KoreaderLinkSuggestionCandidate,
|
||||
KoreaderManualHashLink,
|
||||
KoreaderUnmatchedBook,
|
||||
KoreaderUnmatchedBookSuggestion,
|
||||
UpdateKoreaderCredentialsPayload,
|
||||
} from '@bookorbit/types'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||
import SettingsTabs from './components/SettingsTabs.vue'
|
||||
import KoreaderCredentialsDialog from './KoreaderCredentialsDialog.vue'
|
||||
import KoreaderDeliverySettings from './KoreaderDeliverySettings.vue'
|
||||
@@ -108,18 +114,21 @@ const {
|
||||
syncStatus,
|
||||
unmatchedBooks,
|
||||
manualHashLinks,
|
||||
linkSuggestions,
|
||||
loading,
|
||||
unmatchedLoading,
|
||||
manualLinksLoading,
|
||||
fetchSyncStatus,
|
||||
fetchUnmatchedBooks,
|
||||
fetchManualHashLinks,
|
||||
fetchLinkSuggestions,
|
||||
createCredentials,
|
||||
updateCredentials,
|
||||
deleteCredentials,
|
||||
getSyncUrl,
|
||||
downloadPluginPackage,
|
||||
linkUnmatchedBook,
|
||||
linkSuggestedBooks,
|
||||
dismissUnmatchedBook,
|
||||
dismissAllUnmatchedBooks,
|
||||
relinkManualHashLink,
|
||||
@@ -144,7 +153,10 @@ 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)
|
||||
type LinkTarget = { id: number; title: string | null; detail: string }
|
||||
const pendingLinkTarget = ref<LinkTarget | null>(null)
|
||||
const bulkLinkOpen = ref(false)
|
||||
const bulkLinking = ref(false)
|
||||
const unlinkConfirmLink = ref<KoreaderManualHashLink | null>(null)
|
||||
const unlinkingHash = ref<string | null>(null)
|
||||
const removeDeviceConfirmTarget = ref<KoreaderDeviceInfo | null>(null)
|
||||
@@ -217,6 +229,13 @@ const pagedUnmatchedBooks = computed(() => {
|
||||
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 pagedReviewRows = computed(() =>
|
||||
pagedUnmatchedBooks.value.map((book) => {
|
||||
const suggestion = suggestionFor(book)
|
||||
const single = suggestion && isSingleSuggestion(suggestion) ? suggestion.candidates[0]! : null
|
||||
return { book, suggestion, single, matchesLabel: suggestion && !single ? possibleMatchesLabel(suggestion) : null }
|
||||
}),
|
||||
)
|
||||
const hasPluginActivity = computed(
|
||||
() =>
|
||||
sweeps.value.length > 0 ||
|
||||
@@ -245,6 +264,19 @@ const selectedLinkLastOpen = computed(() => selectedUnmatched.value?.lastOpen ??
|
||||
const linkDialogTitle = computed(() =>
|
||||
selectedManualLink.value ? t('settings.reader.koreader.hashLinks.changeLinkTitle') : t('settings.reader.koreader.hashLinks.linkBookTitle'),
|
||||
)
|
||||
const suggestionsByHash = computed(() => new Map(linkSuggestions.value.map((suggestion) => [suggestion.hash, suggestion])))
|
||||
const selectedSuggestion = computed(() => (selectedUnmatched.value ? (suggestionsByHash.value.get(selectedUnmatched.value.hash) ?? null) : null))
|
||||
const selectedSuggestionLabel = computed(() => {
|
||||
const suggestion = selectedSuggestion.value
|
||||
if (!suggestion) return ''
|
||||
return isSingleSuggestion(suggestion) ? t('settings.reader.koreader.hashLinks.suggestedMatch') : possibleMatchesLabel(suggestion)
|
||||
})
|
||||
const singleSuggestionLinks = computed(() =>
|
||||
unmatchedBooks.value.flatMap((book) => {
|
||||
const suggestion = suggestionsByHash.value.get(book.hash)
|
||||
return suggestion && isSingleSuggestion(suggestion) ? [{ book, candidate: suggestion.candidates[0]! }] : []
|
||||
}),
|
||||
)
|
||||
|
||||
watch(unmatchedTotalPages, (totalPages) => {
|
||||
if (unmatchedPage.value > totalPages) unmatchedPage.value = totalPages
|
||||
@@ -295,8 +327,18 @@ function unmatchedBookTitle(book: KoreaderUnmatchedBook): string {
|
||||
)
|
||||
}
|
||||
|
||||
// KOReader reports several authors on separate lines.
|
||||
function koreaderAuthorsLabel(authors: string | null | undefined): string {
|
||||
return (authors ?? '')
|
||||
.split('\n')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
function unmatchedBookSubtitle(book: KoreaderUnmatchedBook): string {
|
||||
if (book.authors?.trim()) return book.authors
|
||||
const authors = koreaderAuthorsLabel(book.authors)
|
||||
if (authors) return authors
|
||||
return book.title?.trim() ? t('settings.reader.koreader.hashLinks.unknownAuthor') : t('settings.reader.koreader.hashLinks.noTitleOrAuthor')
|
||||
}
|
||||
|
||||
@@ -310,7 +352,8 @@ function manualLinkTitle(link: KoreaderManualHashLink): string {
|
||||
}
|
||||
|
||||
function manualLinkSubtitle(link: KoreaderManualHashLink): string {
|
||||
if (link.koreaderAuthors?.trim()) return link.koreaderAuthors
|
||||
const authors = koreaderAuthorsLabel(link.koreaderAuthors)
|
||||
if (authors) return authors
|
||||
return link.koreaderTitle?.trim() ? t('settings.reader.koreader.hashLinks.unknownAuthor') : t('settings.reader.koreader.hashLinks.noTitleOrAuthor')
|
||||
}
|
||||
|
||||
@@ -339,7 +382,7 @@ function pluginUpdateClass(sweep: KoreaderDeviceSweepInfo): string {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await fetchSyncStatus()
|
||||
if (credentials.value) await Promise.all([fetchUnmatchedBooks(), fetchManualHashLinks()])
|
||||
if (credentials.value) await Promise.all([fetchUnmatchedBooks(), fetchManualHashLinks(), fetchLinkSuggestions()])
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t('settings.reader.koreader.loadFailed')
|
||||
}
|
||||
@@ -464,7 +507,7 @@ async function handleCopySyncUrl() {
|
||||
async function handleRefresh() {
|
||||
try {
|
||||
await fetchSyncStatus(true)
|
||||
if (credentials.value) await Promise.all([fetchUnmatchedBooks(), fetchManualHashLinks()])
|
||||
if (credentials.value) await Promise.all([fetchUnmatchedBooks(), fetchManualHashLinks(), fetchLinkSuggestions()])
|
||||
toast.success(t('settings.reader.koreader.statusRefreshed'))
|
||||
} catch {
|
||||
toast.error(t('settings.reader.koreader.refreshFailed'))
|
||||
@@ -473,7 +516,7 @@ async function handleRefresh() {
|
||||
|
||||
async function handleRefreshUnmatched() {
|
||||
try {
|
||||
await fetchUnmatchedBooks()
|
||||
await Promise.all([fetchUnmatchedBooks(), fetchLinkSuggestions()])
|
||||
unmatchedPage.value = 1
|
||||
toast.success(t('settings.reader.koreader.hashLinks.toast.reviewRefreshed'))
|
||||
} catch {
|
||||
@@ -573,7 +616,80 @@ function handleCloseLink() {
|
||||
}
|
||||
|
||||
function handleChooseLinkTarget(book: BookCard) {
|
||||
pendingLinkTarget.value = book
|
||||
pendingLinkTarget.value = { id: book.id, title: book.title ?? null, detail: bookSearchSubtitle(book) }
|
||||
}
|
||||
|
||||
function handleChooseSuggestedTarget(candidate: KoreaderLinkSuggestionCandidate) {
|
||||
pendingLinkTarget.value = { id: candidate.bookId, title: candidateTitle(candidate), detail: candidateDetail(candidate) }
|
||||
}
|
||||
|
||||
function suggestionFor(book: KoreaderUnmatchedBook): KoreaderUnmatchedBookSuggestion | null {
|
||||
return suggestionsByHash.value.get(book.hash) ?? null
|
||||
}
|
||||
|
||||
function isSingleSuggestion(suggestion: KoreaderUnmatchedBookSuggestion): boolean {
|
||||
return suggestion.candidates.length === 1 && !suggestion.moreCandidates
|
||||
}
|
||||
|
||||
function possibleMatchesLabel(suggestion: KoreaderUnmatchedBookSuggestion): string {
|
||||
const count = suggestion.candidates.length
|
||||
return suggestion.moreCandidates
|
||||
? t('settings.reader.koreader.hashLinks.morePossibleMatches', { count })
|
||||
: t('settings.reader.koreader.hashLinks.possibleMatches', { count })
|
||||
}
|
||||
|
||||
function candidateTitle(candidate: KoreaderLinkSuggestionCandidate): string {
|
||||
const title = candidate.title?.trim()
|
||||
if (!title) return t('settings.reader.koreader.hashLinks.untitledBook')
|
||||
const subtitle = candidate.subtitle?.trim()
|
||||
return subtitle ? `${title}: ${subtitle}` : title
|
||||
}
|
||||
|
||||
function candidateDetail(candidate: KoreaderLinkSuggestionCandidate): string {
|
||||
const authors = candidate.authors.length > 0 ? candidate.authors.join(', ') : t('settings.reader.koreader.hashLinks.unknownAuthor')
|
||||
return [authors, candidate.format, candidate.libraryName, candidate.publishedYear, candidate.language]
|
||||
.filter((part) => part !== null && part !== '')
|
||||
.join(' - ')
|
||||
}
|
||||
|
||||
function candidateHref(candidate: KoreaderLinkSuggestionCandidate): string {
|
||||
return `${import.meta.env.BASE_URL}book/${candidate.bookId}`
|
||||
}
|
||||
|
||||
function handleOpenSuggestion(book: KoreaderUnmatchedBook) {
|
||||
handleOpenLink(book)
|
||||
const suggestion = suggestionFor(book)
|
||||
if (suggestion && isSingleSuggestion(suggestion)) handleChooseSuggestedTarget(suggestion.candidates[0]!)
|
||||
}
|
||||
|
||||
function handleOpenBulkLink() {
|
||||
bulkLinkOpen.value = true
|
||||
}
|
||||
|
||||
function handleCancelBulkLink() {
|
||||
if (!bulkLinking.value) bulkLinkOpen.value = false
|
||||
}
|
||||
|
||||
async function handleConfirmBulkLink() {
|
||||
const links = singleSuggestionLinks.value.map(({ book, candidate }) => ({ hash: book.hash, bookId: candidate.bookId }))
|
||||
if (links.length === 0) return
|
||||
bulkLinking.value = true
|
||||
try {
|
||||
const outcome = await linkSuggestedBooks(links)
|
||||
if (outcome.failed.length === 0) {
|
||||
toast.success(t('settings.reader.koreader.hashLinks.toast.suggestedLinked', { count: outcome.linked }))
|
||||
} else if (outcome.linked > 0) {
|
||||
toast.error(t('settings.reader.koreader.hashLinks.toast.suggestedPartlyLinked', { linked: outcome.linked, failed: outcome.failed.length }))
|
||||
} else {
|
||||
toast.error(t('settings.reader.koreader.hashLinks.toast.suggestedLinkFailed'))
|
||||
}
|
||||
bulkLinkOpen.value = false
|
||||
unmatchedPage.value = 1
|
||||
} catch {
|
||||
toast.error(t('settings.reader.koreader.hashLinks.toast.suggestedLinkFailed'))
|
||||
} finally {
|
||||
bulkLinking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearLinkTarget() {
|
||||
@@ -1210,7 +1326,17 @@ async function handleDownloadPlugin() {
|
||||
<p class="settings-group-label mb-0">
|
||||
{{ t('settings.reader.koreader.hashLinks.reviewTitle') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
v-if="singleSuggestionLinks.length > 0"
|
||||
size="sm"
|
||||
:disabled="unmatchedLoading || bulkLinking"
|
||||
@click="handleOpenBulkLink"
|
||||
type="button"
|
||||
>
|
||||
<Link2 :size="12" />
|
||||
{{ t('settings.reader.koreader.hashLinks.linkSuggested', { count: singleSuggestionLinks.length }) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="unmatchedBooks.length > 0"
|
||||
variant="destructive-outline"
|
||||
@@ -1236,40 +1362,56 @@ async function handleDownloadPlugin() {
|
||||
{{ t('settings.reader.koreader.hashLinks.noReview') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-for="book in pagedUnmatchedBooks" :key="book.hash" class="px-4 py-4 bg-card md:px-5">
|
||||
<div v-for="row in pagedReviewRows" :key="row.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">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="settings-label truncate">
|
||||
{{ unmatchedBookTitle(book) }}
|
||||
{{ unmatchedBookTitle(row.book) }}
|
||||
</p>
|
||||
<p class="settings-hint truncate">
|
||||
{{ unmatchedBookSubtitle(book) }}
|
||||
{{ unmatchedBookSubtitle(row.book) }}
|
||||
</p>
|
||||
<div class="mt-2 grid gap-1 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<p class="min-w-0 truncate">
|
||||
{{ t('settings.reader.koreader.hashLinks.hash') }}
|
||||
<span class="font-mono text-foreground">{{ book.hash }}</span>
|
||||
<span class="font-mono text-foreground">{{ row.book.hash }}</span>
|
||||
</p>
|
||||
<p>
|
||||
{{ t('settings.reader.koreader.hashLinks.lastOpened') }}
|
||||
<span class="text-foreground">{{ formatEpochSeconds(book.lastOpen) }}</span>
|
||||
<span class="text-foreground">{{ formatEpochSeconds(row.book.lastOpen) }}</span>
|
||||
</p>
|
||||
<p>
|
||||
{{ t('settings.reader.koreader.hashLinks.firstSeen') }}
|
||||
<span class="text-foreground">{{ formatDateTime(book.firstSeenAt) }}</span>
|
||||
<span class="text-foreground">{{ formatDateTime(row.book.firstSeenAt) }}</span>
|
||||
</p>
|
||||
<p>
|
||||
{{ t('settings.reader.koreader.hashLinks.lastSeen') }}
|
||||
<span class="text-foreground">{{ formatDateTime(book.lastSeenAt) }}</span>
|
||||
<span class="text-foreground">{{ formatDateTime(row.book.lastSeenAt) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="row.suggestion" class="mt-3 min-w-0 rounded-md border border-border bg-muted/30 px-3 py-2">
|
||||
<p class="text-xs font-medium text-muted-foreground">
|
||||
{{ row.single ? t('settings.reader.koreader.hashLinks.suggestedMatch') : row.matchesLabel }}
|
||||
</p>
|
||||
<template v-if="row.single">
|
||||
<p class="mt-0.5 truncate text-sm font-medium text-foreground">{{ candidateTitle(row.single) }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ candidateDetail(row.single) }}</p>
|
||||
</template>
|
||||
<p v-else class="mt-0.5 text-xs text-muted-foreground">
|
||||
{{ t('settings.reader.koreader.hashLinks.possibleMatchesHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 self-start md:self-auto">
|
||||
<Button size="sm" @click="handleOpenLink(book)" type="button">
|
||||
<div class="flex flex-wrap gap-2 self-start md:shrink-0 md:flex-nowrap md:self-auto">
|
||||
<Button v-if="row.suggestion" size="sm" @click="handleOpenSuggestion(row.book)" type="button">
|
||||
<Link2 :size="12" />
|
||||
{{ row.single ? t('settings.reader.koreader.hashLinks.reviewMatch') : t('settings.reader.koreader.hashLinks.chooseMatch') }}
|
||||
</Button>
|
||||
<Button :variant="row.suggestion ? 'outline' : 'default'" size="sm" @click="handleOpenLink(row.book)" type="button">
|
||||
<Link2 :size="12" />
|
||||
{{ t('settings.reader.koreader.hashLinks.link') }}
|
||||
</Button>
|
||||
<Button variant="destructive-outline" size="sm" @click="handleOpenDismiss(book)">
|
||||
<Button variant="destructive-outline" size="sm" @click="handleOpenDismiss(row.book)">
|
||||
<Trash2 :size="12" />
|
||||
{{ t('settings.reader.koreader.hashLinks.dismiss') }}
|
||||
</Button>
|
||||
@@ -1355,6 +1497,10 @@ async function handleDownloadPlugin() {
|
||||
{{ t('settings.reader.koreader.hashLinks.lastOpened') }}
|
||||
<span class="text-foreground">{{ formatEpochSeconds(link.koreaderLastOpen) }}</span>
|
||||
</p>
|
||||
<p v-if="link.positionCompatible === false" class="flex gap-1.5">
|
||||
<Info :size="12" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('settings.reader.koreader.hashLinks.differentCopyHint') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@@ -1675,6 +1821,42 @@ async function handleDownloadPlugin() {
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="selectedSuggestion" class="mt-4">
|
||||
<p class="text-xs font-medium text-muted-foreground">{{ selectedSuggestionLabel }}</p>
|
||||
<p class="mt-0.5 mb-1.5 text-xs text-muted-foreground">{{ t('settings.reader.koreader.hashLinks.possibleMatchesHint') }}</p>
|
||||
<div class="max-h-48 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
<div v-for="candidate in selectedSuggestion.candidates" :key="candidate.bookId" class="flex items-center gap-1 pe-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-start justify-between gap-3 px-3 py-3 text-left hover:bg-muted/70 disabled:opacity-60"
|
||||
:disabled="linkingBookId !== null"
|
||||
@click="handleChooseSuggestedTarget(candidate)"
|
||||
>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-sm font-medium text-foreground">{{ candidateTitle(candidate) }}</span>
|
||||
<span class="block truncate text-xs text-muted-foreground">{{ candidateDetail(candidate) }}</span>
|
||||
</span>
|
||||
<span class="shrink-0 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground">
|
||||
{{
|
||||
pendingLinkTarget?.id === candidate.bookId
|
||||
? t('settings.reader.koreader.hashLinks.selected')
|
||||
: t('settings.reader.koreader.hashLinks.select')
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
<a
|
||||
:href="candidateHref(candidate)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="shrink-0 rounded-md p-2 text-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
:aria-label="t('settings.reader.koreader.hashLinks.openCandidate', { title: candidateTitle(candidate) })"
|
||||
:title="t('settings.reader.koreader.hashLinks.openCandidate', { title: candidateTitle(candidate) })"
|
||||
>
|
||||
<ExternalLink :size="14" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1.5">{{ t('settings.reader.koreader.hashLinks.bookOrbitBook') }}</label>
|
||||
<div class="relative">
|
||||
@@ -1760,7 +1942,7 @@ async function handleDownloadPlugin() {
|
||||
{{ pendingLinkTarget.title ?? t('settings.reader.koreader.hashLinks.untitledBook') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
{{ bookSearchSubtitle(pendingLinkTarget) }}
|
||||
{{ pendingLinkTarget.detail }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{
|
||||
@@ -1777,6 +1959,12 @@ async function handleDownloadPlugin() {
|
||||
{{ t('settings.reader.koreader.hashLinks.syncedStatsNote') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2 flex gap-2 rounded-md border border-border bg-background px-3 py-2 text-xs text-muted-foreground">
|
||||
<Info :size="14" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<p>
|
||||
{{ t('settings.reader.koreader.hashLinks.copyNote') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" :disabled="linkingBookId !== null" @click="handleClearLinkTarget">
|
||||
{{ t('settings.reader.koreader.hashLinks.chooseDifferent') }}
|
||||
@@ -1792,6 +1980,34 @@ async function handleDownloadPlugin() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="bulkLinkOpen"
|
||||
:title="t('settings.reader.koreader.hashLinks.bulkLinkTitle', { count: singleSuggestionLinks.length })"
|
||||
:description="t('settings.reader.koreader.hashLinks.bulkLinkDescription')"
|
||||
:confirm-label="t('settings.reader.koreader.hashLinks.bulkLinkConfirm', { count: singleSuggestionLinks.length })"
|
||||
:busy="bulkLinking"
|
||||
:destructive="false"
|
||||
:confirm-disabled="singleSuggestionLinks.length === 0"
|
||||
@confirm="handleConfirmBulkLink"
|
||||
@cancel="handleCancelBulkLink"
|
||||
>
|
||||
<ul
|
||||
class="mt-3 max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border"
|
||||
:aria-label="t('settings.reader.koreader.hashLinks.bulkLinkListLabel')"
|
||||
>
|
||||
<li v-for="entry in singleSuggestionLinks" :key="entry.book.hash" class="min-w-0 px-3 py-2">
|
||||
<p class="truncate text-sm font-medium text-foreground">{{ unmatchedBookTitle(entry.book) }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ unmatchedBookSubtitle(entry.book) }}</p>
|
||||
<p class="mt-1 flex min-w-0 items-center gap-1.5 text-sm text-foreground">
|
||||
<ArrowRight :size="12" class="shrink-0 rtl:rotate-180" aria-hidden="true" />
|
||||
<span class="truncate">{{ candidateTitle(entry.candidate) }}</span>
|
||||
</p>
|
||||
<p class="truncate ps-[18px] text-xs text-muted-foreground">{{ candidateDetail(entry.candidate) }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-xs text-muted-foreground">{{ t('settings.reader.koreader.hashLinks.copyNote') }}</p>
|
||||
</ConfirmDialog>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import KoreaderSettings from '../KoreaderSettings.vue'
|
||||
import type { BookCard, KoreaderCredentials, KoreaderManualHashLink, KoreaderSyncStatus, KoreaderUnmatchedBook } from '@bookorbit/types'
|
||||
import type {
|
||||
BookCard,
|
||||
KoreaderCredentials,
|
||||
KoreaderLinkSuggestionCandidate,
|
||||
KoreaderManualHashLink,
|
||||
KoreaderSyncStatus,
|
||||
KoreaderUnmatchedBook,
|
||||
KoreaderUnmatchedBookSuggestion,
|
||||
} from '@bookorbit/types'
|
||||
import { copyToClipboard } from '@/lib/clipboard'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
@@ -24,18 +32,21 @@ const koreaderMock = vi.hoisted(() => ({
|
||||
syncStatus: { __v_isRef: true, value: null as KoreaderSyncStatus | null },
|
||||
unmatchedBooks: { __v_isRef: true, value: [] as KoreaderUnmatchedBook[] },
|
||||
manualHashLinks: { __v_isRef: true, value: [] as KoreaderManualHashLink[] },
|
||||
linkSuggestions: { __v_isRef: true, value: [] as KoreaderUnmatchedBookSuggestion[] },
|
||||
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>>(),
|
||||
fetchLinkSuggestions: 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>>(),
|
||||
linkSuggestedBooks: vi.fn<(links: { hash: string; bookId: number }[]) => Promise<{ linked: number; failed: string[] }>>(),
|
||||
dismissUnmatchedBook: vi.fn<() => Promise<void>>(),
|
||||
dismissAllUnmatchedBooks: vi.fn<() => Promise<{ count: number }>>(),
|
||||
relinkManualHashLink: vi.fn<() => Promise<void>>(),
|
||||
@@ -164,12 +175,31 @@ function makeManualHashLink(overrides: Partial<KoreaderManualHashLink> = {}): Ko
|
||||
koreaderTitle: 'Linked KOReader Title',
|
||||
koreaderAuthors: 'Device Author',
|
||||
koreaderLastOpen: 1700000001,
|
||||
positionCompatible: true,
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
updatedAt: '2026-06-04T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeCandidate(overrides: Partial<KoreaderLinkSuggestionCandidate> = {}): KoreaderLinkSuggestionCandidate {
|
||||
return {
|
||||
bookId: 91,
|
||||
title: 'Suggested Title',
|
||||
subtitle: null,
|
||||
authors: ['Device Author'],
|
||||
format: 'epub',
|
||||
language: 'en',
|
||||
publishedYear: 2020,
|
||||
libraryName: 'Novels',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSuggestion(overrides: Partial<KoreaderUnmatchedBookSuggestion> = {}): KoreaderUnmatchedBookSuggestion {
|
||||
return { hash: 'a'.repeat(32), candidates: [makeCandidate()], moreCandidates: false, ...overrides }
|
||||
}
|
||||
|
||||
function makeBookCard(overrides: Partial<BookCard> = {}): BookCard {
|
||||
return {
|
||||
id: 55,
|
||||
@@ -224,12 +254,15 @@ describe('KoreaderSettings', () => {
|
||||
koreaderMock.syncStatus.value = null
|
||||
koreaderMock.unmatchedBooks.value = []
|
||||
koreaderMock.manualHashLinks.value = []
|
||||
koreaderMock.linkSuggestions.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.fetchLinkSuggestions.mockResolvedValue(undefined)
|
||||
koreaderMock.linkSuggestedBooks.mockResolvedValue({ linked: 0, failed: [] })
|
||||
koreaderMock.createCredentials.mockResolvedValue(undefined)
|
||||
koreaderMock.updateCredentials.mockResolvedValue(undefined)
|
||||
koreaderMock.deleteCredentials.mockResolvedValue(undefined)
|
||||
@@ -634,6 +667,230 @@ describe('KoreaderSettings', () => {
|
||||
expect(koreaderMock.linkUnmatchedBook).toHaveBeenCalledWith('a'.repeat(32), { bookId: 55 })
|
||||
})
|
||||
|
||||
it('says what a link syncs before it is confirmed', 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')
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('BookOrbit Title'))!
|
||||
.trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('This KOReader copy is a different file from the one in BookOrbit.')
|
||||
expect(wrapper.text()).toContain('Highlights and bookmarks will not.')
|
||||
})
|
||||
|
||||
it('loads link suggestions with the review list', async () => {
|
||||
const status = makeSyncStatus()
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
koreaderMock.syncStatus.value = status
|
||||
|
||||
mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(koreaderMock.fetchLinkSuggestions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows a single suggested match and opens it ready to confirm', async () => {
|
||||
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
koreaderMock.syncStatus.value = status
|
||||
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
|
||||
koreaderMock.linkSuggestions.value = [makeSuggestion({ candidates: [makeCandidate({ subtitle: 'A Novel' })] })]
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Suggested match')
|
||||
expect(wrapper.text()).toContain('Suggested Title: A Novel')
|
||||
expect(wrapper.text()).toContain('Device Author - epub - Novels - 2020 - en')
|
||||
|
||||
await buttonByText(wrapper, 'Review match')!.trigger('click')
|
||||
expect(wrapper.text()).toContain('Confirm KOReader link')
|
||||
expect(wrapper.text()).toContain('Book ID: 91')
|
||||
|
||||
await buttonByText(wrapper, 'Confirm link')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(koreaderMock.linkUnmatchedBook).toHaveBeenCalledWith('a'.repeat(32), { bookId: 91 })
|
||||
})
|
||||
|
||||
it('lists every possible match without choosing one', async () => {
|
||||
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
koreaderMock.syncStatus.value = status
|
||||
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
|
||||
koreaderMock.linkSuggestions.value = [
|
||||
makeSuggestion({
|
||||
candidates: [makeCandidate({ bookId: 91 }), makeCandidate({ bookId: 92, title: 'Suggested Title', subtitle: 'Second edition' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('2 possible matches')
|
||||
expect(wrapper.text()).not.toContain('Suggested match')
|
||||
expect(buttonByText(wrapper, 'Link suggested')).toBeUndefined()
|
||||
|
||||
await buttonByText(wrapper, 'Choose match')!.trigger('click')
|
||||
expect(wrapper.text()).toContain('Suggested Title: Second edition')
|
||||
expect(wrapper.text()).not.toContain('Confirm KOReader link')
|
||||
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('Second edition'))!
|
||||
.trigger('click')
|
||||
await buttonByText(wrapper, 'Confirm link')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(koreaderMock.linkUnmatchedBook).toHaveBeenCalledWith('a'.repeat(32), { bookId: 92 })
|
||||
})
|
||||
|
||||
it('opens each possible match in a new tab so editions can be checked', async () => {
|
||||
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
koreaderMock.syncStatus.value = status
|
||||
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
|
||||
koreaderMock.linkSuggestions.value = [makeSuggestion({ candidates: [makeCandidate({ bookId: 91 }), makeCandidate({ bookId: 92 })] })]
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
await buttonByText(wrapper, 'Choose match')!.trigger('click')
|
||||
|
||||
const links = wrapper.findAll('a[target="_blank"]')
|
||||
expect(links.map((link) => link.attributes('href'))).toEqual(['/book/91', '/book/92'])
|
||||
expect(links[0]!.attributes('rel')).toBe('noopener')
|
||||
expect(links[0]!.attributes('aria-label')).toBe('Open Suggested Title in a new tab')
|
||||
})
|
||||
|
||||
it('shows KOReader authors reported on separate lines as a list', async () => {
|
||||
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
koreaderMock.syncStatus.value = status
|
||||
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook({ authors: 'Ada Tester\nBo Sample' })]
|
||||
koreaderMock.manualHashLinks.value = [makeManualHashLink({ koreaderAuthors: 'Cy Third\n Di Fourth ' })]
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Ada Tester, Bo Sample')
|
||||
expect(wrapper.text()).toContain('Cy Third, Di Fourth')
|
||||
})
|
||||
|
||||
it('never offers a truncated match list as a single suggestion', async () => {
|
||||
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
koreaderMock.syncStatus.value = status
|
||||
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook()]
|
||||
koreaderMock.linkSuggestions.value = [makeSuggestion({ moreCandidates: true })]
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('More than 1 possible match')
|
||||
expect(buttonByText(wrapper, 'Link suggested')).toBeUndefined()
|
||||
await buttonByText(wrapper, 'Choose match')!.trigger('click')
|
||||
expect(wrapper.text()).not.toContain('Confirm KOReader link')
|
||||
})
|
||||
|
||||
it('ignores suggestions for books no longer under review', async () => {
|
||||
const status = makeSyncStatus()
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
koreaderMock.syncStatus.value = status
|
||||
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook({ hash: 'c'.repeat(32) })]
|
||||
koreaderMock.linkSuggestions.value = [makeSuggestion()]
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Suggested match')
|
||||
expect(buttonByText(wrapper, 'Link suggested')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('links every single suggestion after listing them for confirmation', async () => {
|
||||
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 3 } })
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
koreaderMock.syncStatus.value = status
|
||||
koreaderMock.unmatchedBooks.value = [
|
||||
makeUnmatchedBook({ hash: 'a'.repeat(32), title: 'First Device Book' }),
|
||||
makeUnmatchedBook({ hash: 'c'.repeat(32), title: 'Second Device Book' }),
|
||||
makeUnmatchedBook({ hash: 'd'.repeat(32), title: 'Ambiguous Device Book' }),
|
||||
]
|
||||
koreaderMock.linkSuggestions.value = [
|
||||
makeSuggestion({ hash: 'a'.repeat(32), candidates: [makeCandidate({ bookId: 91, title: 'First Library Book' })] }),
|
||||
makeSuggestion({ hash: 'c'.repeat(32), candidates: [makeCandidate({ bookId: 93, title: 'Second Library Book' })] }),
|
||||
makeSuggestion({ hash: 'd'.repeat(32), candidates: [makeCandidate({ bookId: 94 }), makeCandidate({ bookId: 95 })] }),
|
||||
]
|
||||
koreaderMock.linkSuggestedBooks.mockResolvedValue({ linked: 2, failed: [] })
|
||||
|
||||
const wrapper = mount(KoreaderSettings, { props: { embedded: true }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
await buttonByText(wrapper, 'Link suggested (2)')!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = document.body.querySelector('[role="dialog"]')!
|
||||
expect(dialog.textContent).toContain('Link 2 suggested books?')
|
||||
expect(dialog.textContent).toContain('First Device Book')
|
||||
expect(dialog.textContent).toContain('First Library Book')
|
||||
expect(dialog.textContent).toContain('Second Library Book')
|
||||
expect(dialog.textContent).not.toContain('Ambiguous Device Book')
|
||||
expect(dialog.textContent).toContain('This KOReader copy is a different file from the one in BookOrbit.')
|
||||
|
||||
const confirm = [...dialog.querySelectorAll('button')].find((button) => button.textContent?.includes('Link 2 books'))!
|
||||
confirm.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(koreaderMock.linkSuggestedBooks).toHaveBeenCalledWith([
|
||||
{ hash: 'a'.repeat(32), bookId: 91 },
|
||||
{ hash: 'c'.repeat(32), bookId: 93 },
|
||||
])
|
||||
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('Linked 2 suggested books')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reports suggested links that could not be made', async () => {
|
||||
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 2 } })
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
koreaderMock.syncStatus.value = status
|
||||
koreaderMock.unmatchedBooks.value = [makeUnmatchedBook({ hash: 'a'.repeat(32) }), makeUnmatchedBook({ hash: 'c'.repeat(32) })]
|
||||
koreaderMock.linkSuggestions.value = [makeSuggestion({ hash: 'a'.repeat(32) }), makeSuggestion({ hash: 'c'.repeat(32) })]
|
||||
koreaderMock.linkSuggestedBooks.mockResolvedValue({ linked: 1, failed: ['c'.repeat(32)] })
|
||||
|
||||
const wrapper = mount(KoreaderSettings, { props: { embedded: true }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
await buttonByText(wrapper, 'Link suggested (2)')!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = document.body.querySelector('[role="dialog"]')!
|
||||
;[...dialog.querySelectorAll('button')].find((button) => button.textContent?.includes('Link 2 books'))!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(toast.error)).toHaveBeenCalledWith('Linked 1 suggested book. 1 could not be linked and stays in the list.')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('marks manual links to a different copy with what they sync', async () => {
|
||||
const status = makeSyncStatus()
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
koreaderMock.syncStatus.value = status
|
||||
koreaderMock.manualHashLinks.value = [
|
||||
makeManualHashLink({ hash: 'b'.repeat(32), positionCompatible: false }),
|
||||
makeManualHashLink({ hash: 'c'.repeat(32), koreaderTitle: 'Exact Copy', positionCompatible: true }),
|
||||
]
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const hint = "Different file from the one in BookOrbit: progress syncs as a percentage, and highlights and bookmarks don't sync."
|
||||
expect(wrapper.text().split(hint)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('confirms before dismissing an unmatched KOReader book', async () => {
|
||||
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
|
||||
koreaderMock.credentials.value = status.credentials
|
||||
|
||||
@@ -3577,7 +3577,10 @@
|
||||
"reviewDismissed": "Book dismissed from review",
|
||||
"reviewDismissFailed": "Failed to dismiss book from review",
|
||||
"reviewAllDismissed": "{count, plural, =0 {Dismissed no books from review} one {Dismissed # book from review} other {Dismissed # books from review}}",
|
||||
"reviewDismissAllFailed": "Failed to dismiss all books from review"
|
||||
"reviewDismissAllFailed": "Failed to dismiss all books from review",
|
||||
"suggestedLinked": "{count, plural, one {Linked # suggested book} other {Linked # suggested books}}",
|
||||
"suggestedPartlyLinked": "{linked, plural, one {Linked # suggested book.} other {Linked # suggested books.}} {failed, plural, one {# could not be linked and stays in the list.} other {# could not be linked and stay in the list.}}",
|
||||
"suggestedLinkFailed": "Could not link the suggested books"
|
||||
},
|
||||
"modal": {
|
||||
"linkTitle": "Link KOReader book",
|
||||
@@ -3638,7 +3641,21 @@
|
||||
"manualUnlinkConfirmBody": "Remove the manual link to {title} and return this book to the review list. Future syncs will use an automatic match if one exists. Already synced stats will stay on their current BookOrbit book.",
|
||||
"reviewDismissConfirmBody": "This removes it from your books to review. Automatic matching and existing sync data are kept. If a device reports the hash again without a match, it can reappear.",
|
||||
"reviewDismissAllConfirmTitle": "{count, plural, =0 {No books to review} one {Dismiss all # book to review?} other {Dismiss all # books to review?}}",
|
||||
"reviewDismissAllConfirmBody": "This clears your entire books to review list, including entries on other pages. Automatic matching and existing sync data are kept. If a device reports a hash again without a match, it can reappear."
|
||||
"reviewDismissAllConfirmBody": "This clears your entire books to review list, including entries on other pages. Automatic matching and existing sync data are kept. If a device reports a hash again without a match, it can reappear.",
|
||||
"copyNote": "This KOReader copy is a different file from the one in BookOrbit. Reading status, rating, reading time and progress (as a percentage) will sync. Highlights and bookmarks will not. To sync everything, download the book to KOReader from BookOrbit.",
|
||||
"differentCopyHint": "Different file from the one in BookOrbit: progress syncs as a percentage, and highlights and bookmarks don't sync.",
|
||||
"suggestedMatch": "Suggested match",
|
||||
"possibleMatches": "{count, plural, one {# possible match} other {# possible matches}}",
|
||||
"morePossibleMatches": "{count, plural, one {More than # possible match} other {More than # possible matches}}",
|
||||
"possibleMatchesHint": "Books in your library with the same title and authors. Check the edition before you link.",
|
||||
"reviewMatch": "Review match",
|
||||
"chooseMatch": "Choose match",
|
||||
"linkSuggested": "Link suggested ({count})",
|
||||
"bulkLinkTitle": "{count, plural, one {Link # suggested book?} other {Link # suggested books?}}",
|
||||
"bulkLinkDescription": "Each KOReader book below has the same title and authors as exactly one book in your library. Check the list before linking.",
|
||||
"bulkLinkConfirm": "{count, plural, one {Link # book} other {Link # books}}",
|
||||
"bulkLinkListLabel": "Suggested links",
|
||||
"openCandidate": "Open {title} in a new tab"
|
||||
},
|
||||
"remove": "Remove",
|
||||
"removing": "Removing...",
|
||||
|
||||
Reference in New Issue
Block a user