+ {t('adoption.editor.alreadyImported', 'This book already has its files. Adopting adds these alongside them, and nothing is replaced or deleted.')}
+
+ )}
diff --git a/web/src/pages/import/AdoptionRow.tsx b/web/src/pages/import/AdoptionRow.tsx
index 96ea3dd5d..6c30a4b97 100644
--- a/web/src/pages/import/AdoptionRow.tsx
+++ b/web/src/pages/import/AdoptionRow.tsx
@@ -7,7 +7,8 @@ import MediaBadge from '../../components/MediaBadge'
import MoreMenu, { type MoreMenuItem } from '../../components/MoreMenu'
import { formatBytes } from '../../util/format'
import { adoptionHint, unitDisplayName } from './adoptionHint'
-import { matchStrength, shortHint } from './adoptionMatch'
+import { StatusPill } from './AdoptionEditor'
+import { alreadySettled, matchStrength, shortHint } from './adoptionMatch'
import type { Outcome } from './adoptionReducer'
import { rowCls, cellCls, actionCellCls } from './adoptionStyles'
@@ -55,7 +56,9 @@ const AdoptionRow = forwardRef(function AdoptionRow(
case 'adopted': {
const book = outcome.item.book
const base = t('adoption.outcome.adopted', { title: book?.title ?? name, author: book?.authorName ?? '', defaultValue: 'Adopted as {{title}} by {{author}}.' })
- return outcome.item.bookCreated ? `${base} ${t('adoption.outcome.created', 'Added to your library, unmonitored.')}` : base
+ if (outcome.item.bookCreated) return `${base} ${t('adoption.outcome.created', 'Added to your library, unmonitored.')}`
+ // The server says when the book already had a file of this format.
+ return outcome.item.message ? `${base} ${t('adoption.outcome.addedAlongside', 'Added alongside the file it already had.')}` : base
}
case 'ignoring':
case 'ignored': return t('adoption.outcome.ignored', 'Ignored. Later scans keep it out of this list.')
@@ -132,6 +135,7 @@ const AdoptionRow = forwardRef(function AdoptionRow(
{top.book.title}
)}
+ {alreadySettled(top.book.status) && }
) : inGroup ? (
// The group row above already says why; repeating it per book is noise.
diff --git a/web/src/pages/import/AdoptionView.test.tsx b/web/src/pages/import/AdoptionView.test.tsx
index ef80fa050..0240e9d56 100644
--- a/web/src/pages/import/AdoptionView.test.tsx
+++ b/web/src/pages/import/AdoptionView.test.tsx
@@ -168,6 +168,35 @@ describe('AdoptionView', () => {
expect(within(editor).getByRole('button', { name: 'Adopt as The Martian' })).toBeEnabled()
})
+ it('shows an imported suggestion as such and says the files were added alongside (#2879)', async () => {
+ const owned = { ...martian, status: 'imported' }
+ const copy = item({
+ id: 5, parsedTitle: 'The Martian', relPath: 'Andy Weir/The Martian (2)/The Martian.epub',
+ candidates: [{ book: owned, score: 1 }], topScore: 1,
+ })
+ serve(listResponse([copy]))
+ server.use(
+ http.get(apiUrl('/book'), () => HttpResponse.json({ items: [], total: 0 })),
+ http.post(apiUrl('/library/unmatched/5/adopt'), () =>
+ HttpResponse.json({ ...copy, state: 'adopted', book: owned, message: 'This book already had a file of this format.' })),
+ )
+ const table = await renderView()
+ const row = within(table).getByRole('row', { name: 'The Martian' })
+
+ expect(within(row).getByText('Possible match')).toBeInTheDocument()
+ expect(within(row).getByText('Imported')).toBeInTheDocument()
+ expect(within(row).queryByRole('button', { name: 'Confirm' })).toBeNull()
+
+ fireEvent.click(within(row).getByRole('button', { name: 'The Martian' }))
+ const editor = await screen.findByRole('region', { name: 'Which book is The Martian?' })
+ expect(within(editor).getByRole('radio', { name: /The Martian/ })).toBeChecked()
+ expect(within(editor).getByText('Imported')).toBeInTheDocument()
+ expect(within(editor).getByRole('note')).toHaveTextContent('This book already has its files.')
+
+ fireEvent.click(within(editor).getByRole('button', { name: 'Adopt as The Martian' }))
+ expect(await within(table).findByText(/Added alongside the file it already had\./)).toBeInTheDocument()
+ })
+
it('asks a metadata provider only when the search is submitted', async () => {
serve(listResponse([unsuggested]))
server.use(http.get(apiUrl('/search/book'), () => HttpResponse.json([])))
diff --git a/web/src/pages/import/adoptionHint.test.ts b/web/src/pages/import/adoptionHint.test.ts
index bc6d3e892..afce27e43 100644
--- a/web/src/pages/import/adoptionHint.test.ts
+++ b/web/src/pages/import/adoptionHint.test.ts
@@ -58,6 +58,14 @@ describe('adoptionHint', () => {
expect(STRONG_MATCH_SCORE).toBe(0.92)
})
+ it('never offers a one click confirm into a book that already has its files or was skipped (#2879)', () => {
+ for (const status of ['imported', 'skipped']) {
+ const settled = item({ parsedAuthor: 'Andy Weir', candidates: [{ book: { ...martian, status }, score: 1 }] })
+ expect(matchStrength(settled)).toBe('possible')
+ expect(adoptionHint(settled, t).sentence).not.toContain('Confirm')
+ }
+ })
+
it('matches authors by their words', () => {
expect(authorsMatch('Weir', 'Andy Weir')).toBe(true)
expect(authorsMatch('Álvaro Enrigue', 'Alvaro Enrigue')).toBe(true)
diff --git a/web/src/pages/import/adoptionMatch.ts b/web/src/pages/import/adoptionMatch.ts
index 2dab50174..59f2531b4 100644
--- a/web/src/pages/import/adoptionMatch.ts
+++ b/web/src/pages/import/adoptionMatch.ts
@@ -31,10 +31,22 @@ export function authorsMatch(parsedAuthor: string, bookAuthor: string): boolean
return parsed.every(w => book.has(w))
}
+// alreadySettled reports whether a suggested book is one the scan would not
+// have claimed by itself: it already has its files, or it was skipped.
+// Suggestions include such books (#2879), so an untracked copy of a book
+// already imported can be pointed at it, but adopting there adds a second copy
+// or gives files to a book the user skipped. Both deserve a look in the
+// editor, where the book's status is shown, so neither is a one click Confirm.
+export function alreadySettled(status: string): boolean {
+ return status === 'imported' || status === 'skipped'
+}
+
export function matchStrength(item: AdoptionItem, candidate: AdoptionCandidate | undefined = item.candidates[0]): MatchStrength | null {
if (!candidate) return null
const author = item.parsedAuthor || item.authorFolder
- return candidate.score >= STRONG_MATCH_SCORE && authorsMatch(author, candidate.book.authorName) ? 'strong' : 'possible'
+ return candidate.score >= STRONG_MATCH_SCORE && authorsMatch(author, candidate.book.authorName) && !alreadySettled(candidate.book.status)
+ ? 'strong'
+ : 'possible'
}
// shortHint is the row's one line: the fact, without the advice, which lives