Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/routers/systems/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def get_system(
"description": b.description,
"page_count": b.page_count,
"file_size": b.file_size,
"mime_type": b.mime_type,
"authors": b.authors or [],
"publisher": b.publisher,
"publisher_url": b.publisher_url,
Expand Down
10 changes: 10 additions & 0 deletions backend/tests/test_systems.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ def test_system_books_include_index_failed(self, client, admin_headers, system):
if books:
assert all("index_failed" in b for b in books)

def test_system_books_include_mime_type(self, client, admin_headers, system):
# The per-book actions menu gates its re-scan / re-OCR items on
# mime_type being a PDF, so this field must be present in the payload.
from backend.tests.conftest import make_book
book = make_book(system_id=system.id)
resp = client.get(f"/api/systems/{system.id}", headers=admin_headers)
books = {b["id"]: b for b in resp.json()["books"]}
assert "mime_type" in books[book.id]
assert books[book.id]["mime_type"] == "application/pdf"

def test_system_books_include_is_missing(self, client, admin_headers, system):
from backend.tests.conftest import make_book
make_book(system_id=system.id)
Expand Down
25 changes: 17 additions & 8 deletions frontend/src/components/system/BookActionsMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ const MENU_WIDTH = 220
* standalone always-visible control on the row.
*
* The re-index item adapts to the book:
* - image-only / OCR PDF → "Re-OCR…" which reveals an inline DPI field, then
* POSTs /books/:id/reindex (optionally ?ocr_dpi=N).
* - text-layer PDF (or anything else) → "Re-scan & re-index" which POSTs
* /books/:id/rescan to re-read the file and rebuild its index.
* Re-index items are shown only when `onEdit` is passed (gm/admin) and the book
* is a PDF.
* - successfully OCR / image-only PDF → "Re-OCR…" which reveals an inline DPI
* field, then POSTs /books/:id/reindex (optionally ?ocr_dpi=N).
* - text-layer PDF, or any index-failed PDF → "Re-scan & re-index" which POSTs
* /books/:id/rescan to re-read the file and rebuild its index. For a failed
* book this recovers it through the full scan → index → OCR flow.
* Re-index items are shown only when `onEdit` is passed (gm/admin), the book is
* a PDF, and it has actually been processed (indexed or index-failed) — a
* never-scanned / still-pending book has nothing to re-do yet.
*
* The menu is portalled to document.body at fixed coordinates so it isn't
* clipped by the book row's `overflow: hidden`.
Expand All @@ -33,8 +35,15 @@ export default function BookActionsMenu({ book, onEdit, editing }) {
const menuRef = useRef(null)

const isPdf = book.mime_type === 'application/pdf'
const isOcrBook = book.index_error === 'ocr' || book.index_error === 'image-only'
const canReindex = Boolean(onEdit) && isPdf
// A book that finished indexing via OCR / as image-only. A hard index failure
// resets index_error to the error message, so index_failed takes precedence:
// failed books always go through the full re-scan → index → OCR flow below.
const isOcrBook =
!book.index_failed && (book.index_error === 'ocr' || book.index_error === 'image-only')
// Re-index items only make sense once a book has actually been processed —
// successfully indexed, or index-failed (which the re-scan can recover from).
// A never-scanned / still-pending book has nothing to re-do yet.
const canReindex = Boolean(onEdit) && isPdf && (book.indexed || book.index_failed)

const place = useCallback(() => {
const el = triggerRef.current
Expand Down
28 changes: 27 additions & 1 deletion frontend/src/components/system/BookActionsMenu.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ vi.mock('../../api', () => ({
}))

function makeBook(overrides = {}) {
return { id: 'b1', mime_type: 'application/pdf', index_error: '', ...overrides }
return {
id: 'b1',
mime_type: 'application/pdf',
index_error: '',
indexed: true,
index_failed: false,
...overrides,
}
}

function renderMenu(book = {}, props = {}) {
Expand Down Expand Up @@ -75,6 +82,25 @@ describe('BookActionsMenu', () => {
await waitFor(() => expect(screen.getByText('bookActions.rescanError')).toBeInTheDocument())
})

it('shows re-scan (not re-OCR) for an index-failed OCR book and posts to /rescan', async () => {
// A failed OCR book keeps an error message in index_error, but index_failed
// takes precedence: it must recover through the full re-scan flow.
renderMenu({ id: 'b8', indexed: false, index_failed: true, index_error: 'ocr open failed: x' })
fireEvent.click(screen.getByLabelText('bookActions.menu'))
expect(screen.queryByRole('menuitem', { name: 'bookActions.reocr' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: 'bookActions.rescan' }))
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/books/b8/rescan'))
})

it('hides re-index items for a never-indexed / still-pending book', () => {
renderMenu({ indexed: false, index_failed: false })
fireEvent.click(screen.getByLabelText('bookActions.menu'))
expect(screen.queryByRole('menuitem', { name: 'bookActions.rescan' })).not.toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: 'bookActions.reocr' })).not.toBeInTheDocument()
// download stays available regardless
expect(screen.getByRole('menuitem', { name: 'bookActions.download' })).toBeInTheDocument()
})

// --- image-only / OCR PDF: re-OCR with DPI ---

it('shows a re-OCR item (not re-scan) for OCR books', () => {
Expand Down
Loading