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:
neon
2026-10-26 15:06:04 +00:00
parent 834da8c954
commit 1c27e5dd05
36 changed files with 1634 additions and 63 deletions
@@ -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,
+236 -20
View File
@@ -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
+19 -2
View File
@@ -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...",