feat(scanner): align bookdock duplicate detection with upload behavior

Replace metadata-based duplicate checks (ISBN, title/author) during BookDock finalization with destination path lookup. This allows books with shared or incorrect metadata to be imported whenever their resolved library target paths differ, matching the standard upload workflow.

Closes #736
This commit is contained in:
neon
2026-07-22 20:06:49 +00:00
parent fb93f6ec20
commit 58c5b422d3
14 changed files with 349 additions and 622 deletions
@@ -275,38 +275,6 @@ function goToBook(bookId: number) {
handleClose()
}
async function handleReimportDuplicate(fileId: number) {
if (!result.value || reimportingIds.has(fileId)) return
reimportingIds.add(fileId)
try {
const payload: Record<string, unknown> = {
fileIds: [fileId],
overrides: [{ fileId, skipDuplicateCheck: true }],
}
if (defaultLibraryId.value !== null) payload.defaultLibraryId = defaultLibraryId.value
if (defaultFolderId.value !== null) payload.defaultFolderId = defaultFolderId.value
const res = await api('/api/v1/book-dock/finalize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) return
const newResult = await res.json()
const fileResult = (newResult.results as typeof result.value.results)[0]
if (!fileResult) return
const entry = result.value.results.find((r) => r.fileId === fileId)
if (entry) {
Object.assign(entry, fileResult)
if (fileResult.success) {
result.value.succeeded++
result.value.failed--
}
}
} finally {
reimportingIds.delete(fileId)
}
}
async function discardDuplicateSelection(payload: FinalizePayload): Promise<BookDockDiscardDuplicatesResult | null> {
const res = await api('/api/v1/book-dock/finalize/discard-duplicates', {
method: 'POST',
@@ -578,15 +546,6 @@ async function handleDiscardResultDuplicates() {
>
{{ t('bookDock.finalizeDialog.viewExisting') }} <ExternalLink class="size-3" />
</button>
<button
v-if="!r.success && r.isDuplicate"
class="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0 transition-colors"
:disabled="reimportingIds.has(r.fileId)"
@click="handleReimportDuplicate(r.fileId)"
>
<Loader2 v-if="reimportingIds.has(r.fileId)" class="size-3 animate-spin" />
{{ t('bookDock.finalizeDialog.importAnyway') }}
</button>
<button
v-if="!r.success && !r.isDuplicate && r.message && !isFileExistsError(r.message)"
class="text-xs text-red-500 flex items-center gap-1 shrink-0 hover:text-red-600 transition-colors"
@@ -1,10 +1,11 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { BookDockFinalizeResult } from '@bookorbit/types'
import BookDockFinalizeDialog from '../BookDockFinalizeDialog.vue'
const apiMock = vi.fn<(...args: unknown[]) => Promise<Response>>()
const finalizeResult = ref(null)
const finalizeResult = ref<BookDockFinalizeResult | null>(null)
const finalizeLoading = ref(false)
const finalizeError = ref<string | null>(null)
const finalizeMock = vi.fn<(...args: unknown[]) => Promise<void>>()
@@ -97,7 +98,7 @@ describe('BookDockFinalizeDialog', () => {
fileName: 'duplicate.epub',
status: 'duplicate',
existingBookId: 42,
message: 'Duplicate: this book already exists in the library',
message: 'A file with this name already exists at the target location',
},
{ fileId: 11, fileName: 'ready.epub', newName: 'ready.epub', status: 'ready' },
],
@@ -148,4 +149,32 @@ describe('BookDockFinalizeDialog', () => {
})
expect(wrapper.text()).not.toContain('1 already in library')
})
it('offers navigation and rename for an indexed destination collision without an import-anyway action', async () => {
finalizeResult.value = {
total: 1,
succeeded: 0,
failed: 1,
results: [
{
fileId: 10,
fileName: 'duplicate.epub',
newName: 'Author/Title/duplicate.epub',
success: false,
isDuplicate: true,
existingBookId: 42,
message: 'A file with this name already exists at the target location',
},
],
}
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.text()).toContain('View existing')
expect(wrapper.text()).toContain('Already exists - save as')
expect(wrapper.text()).toContain('Import')
expect(wrapper.text()).not.toContain('Import anyway')
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
})
})
-1
View File
@@ -4016,7 +4016,6 @@
"failedWithDuplicates": "{failed} fehlgeschlagen ({count} Duplikate) | {failed} fehlgeschlagen ({count} Duplikat) | {failed} fehlgeschlagen ({count} Duplikate)",
"view": "Anzeigen",
"viewExisting": "Vorhandenes anzeigen",
"importAnyway": "Trotzdem importieren",
"hide": "Ausblenden",
"details": "Details",
"alreadyExistsSaveAs": "Existiert bereits - speichern als:",
-1
View File
@@ -3974,7 +3974,6 @@
"failedWithDuplicates": "{failed} failed ({count} duplicates) | {failed} failed ({count} duplicate) | {failed} failed ({count} duplicates)",
"view": "View",
"viewExisting": "View existing",
"importAnyway": "Import anyway",
"hide": "Hide",
"details": "Details",
"alreadyExistsSaveAs": "Already exists - save as:",
-1
View File
@@ -4016,7 +4016,6 @@
"failedWithDuplicates": "{failed} mislukt ({count} duplicaten) | {failed} mislukt ({count} duplicaat) | {failed} mislukt ({count} duplicaten)",
"view": "Bekijken",
"viewExisting": "Bestaande bekijken",
"importAnyway": "Toch importeren",
"hide": "Verbergen",
"details": "Details",
"alreadyExistsSaveAs": "Bestaat al - opslaan als:",
-1
View File
@@ -4016,7 +4016,6 @@
"failedWithDuplicates": "{failed} falhou ({count} duplicatas) | {failed} falhou ({count} duplicata) | {failed} falharam ({count} duplicatas)",
"view": "Ver",
"viewExisting": "Ver existente",
"importAnyway": "Importar de qualquer forma",
"hide": "Esconder",
"details": "Detalhes",
"alreadyExistsSaveAs": "Já existe - salvar como:",
-1
View File
@@ -4016,7 +4016,6 @@
"failedWithDuplicates": "{failed} neuspešnih ({count} podvojenih) | {failed} neuspešna ({count} podvojena) | {failed} neuspešnih ({count} podvojenih)",
"view": "Ogled",
"viewExisting": "Ogled obstoječega",
"importAnyway": "Vseeno uvozi",
"hide": "Skrij",
"details": "Podrobnosti",
"alreadyExistsSaveAs": "Že obstaja - shrani kot:",