feat: per-extraction OCR language override
Allow users to specify a Tesseract language string (e.g. jpn+jpn_vert)
on a per-extraction basis, overriding the global OCR language setting.
- Add payload column to ai_jobs table (migration) to carry per-call data
- Thread ocrLanguages payload through enqueueJob → processNextJob → extractItemText
- New GET /api/ai-settings/ocr endpoint (requireAuth) returns { ocrMode, ocrLanguages }
- ImageLightbox fetches OCR settings and shows a language input next to the
Extract Text button when mode is hybrid or tesseract (hidden for llm-only)
- MixedView fetches OCR settings and passes them down to EntryTile; kebab
Extract Text on images shows an inline language prompt before dispatching the job
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,11 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
const [descPending, setDescPending] = useState(false)
|
||||
const [descError, setDescError] = useState<string | null>(null)
|
||||
|
||||
// OCR settings
|
||||
const [ocrMode, setOcrMode] = useState<string | null>(null)
|
||||
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
|
||||
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
|
||||
|
||||
// Text overlay state
|
||||
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
||||
const [showOriginal, setShowOriginal] = useState(false)
|
||||
@@ -68,6 +73,13 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
|
||||
useEffect(() => {
|
||||
fetchAiFields()
|
||||
fetch('/api/ai-settings/ocr')
|
||||
.then((r) => r.json())
|
||||
.then((d: { ocrMode: string; ocrLanguages: string }) => {
|
||||
setOcrMode(d.ocrMode)
|
||||
setDefaultOcrLanguages(d.ocrLanguages)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
@@ -439,58 +451,79 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
Text Extraction
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
setExtracting(true)
|
||||
setExtractError(null)
|
||||
setExtractPending(false)
|
||||
try {
|
||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey }),
|
||||
})
|
||||
if (res.status === 202) {
|
||||
setExtractPending(true)
|
||||
startPolling(extractedText, translatedText, aiDescription)
|
||||
return
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setExtracting(true)
|
||||
setExtractError(null)
|
||||
setExtractPending(false)
|
||||
try {
|
||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
itemKey,
|
||||
...(ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
|
||||
}),
|
||||
})
|
||||
if (res.status === 202) {
|
||||
setExtractPending(true)
|
||||
startPolling(extractedText, translatedText, aiDescription)
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
|
||||
}
|
||||
const result = await res.json()
|
||||
setExtractedText(result.extractedText || null)
|
||||
setEditedExtractedText(result.extractedText || '')
|
||||
setTranslatedText(result.translatedText || null)
|
||||
} catch (err) {
|
||||
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
|
||||
setTimeout(() => setExtractError(null), 4000)
|
||||
} finally {
|
||||
setExtracting(false)
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
|
||||
}}
|
||||
disabled={extracting || extractPending}
|
||||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
|
||||
color: extractPending ? '#fff' : 'var(--text-secondary)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!extracting && !extractPending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||
}
|
||||
const result = await res.json()
|
||||
setExtractedText(result.extractedText || null)
|
||||
setEditedExtractedText(result.extractedText || '')
|
||||
setTranslatedText(result.translatedText || null)
|
||||
} catch (err) {
|
||||
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
|
||||
setTimeout(() => setExtractError(null), 4000)
|
||||
} finally {
|
||||
setExtracting(false)
|
||||
}
|
||||
}}
|
||||
disabled={extracting || extractPending}
|
||||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start"
|
||||
style={{
|
||||
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
|
||||
color: extractPending ? '#fff' : 'var(--text-secondary)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!extracting && !extractPending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!extractPending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{extracting ? '⟳ Extracting…' : extractPending ? '⟳ Queued…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
|
||||
</button>
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!extractPending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{extracting ? '⟳ Extracting…' : extractPending ? '⟳ Queued…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
|
||||
</button>
|
||||
{ocrMode && ocrMode !== 'llm' && (
|
||||
<input
|
||||
type="text"
|
||||
value={ocrLanguageInput}
|
||||
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
||||
placeholder={defaultOcrLanguages}
|
||||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
width: 120,
|
||||
}}
|
||||
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{extractError && (
|
||||
<p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>
|
||||
|
||||
Reference in New Issue
Block a user