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:
Garret Patti
2026-04-13 21:55:07 -04:00
parent 96cfb8aae7
commit db2e446ef4
8 changed files with 206 additions and 70 deletions

View File

@@ -34,6 +34,7 @@ interface AiJobRow {
started_at: number | null
completed_at: number | null
item_title: string | null
payload: string | null
}
function rowToJob(row: AiJobRow): AiJob {
@@ -75,6 +76,7 @@ export function enqueueJob(
jobType: AiJobType,
libraryId: string,
sourceLanguage?: string,
payload?: Record<string, string>,
): string {
const db = getDb()
@@ -96,9 +98,9 @@ export function enqueueJob(
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
db.prepare(
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title)
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?)`
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title)
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title, payload)
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?, ?)`
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title, payload ? JSON.stringify(payload) : null)
// Wake the processor
wakeProcessor()
@@ -251,6 +253,8 @@ async function processNextJob(): Promise<boolean> {
// Extract sourceLanguage for translate jobs (stored in error field at enqueue)
const sourceLanguage = row.job_type === 'translate' ? row.error : null
// Parse job payload (carries per-call overrides, e.g. ocrLanguages for extract jobs)
const jobPayload = row.payload ? (JSON.parse(row.payload) as Record<string, string>) : null
db.prepare(
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
@@ -265,7 +269,7 @@ async function processNextJob(): Promise<boolean> {
await generateItemDescription(row.item_key)
break
case 'extract':
await extractItemText(row.item_key)
await extractItemText(row.item_key, jobPayload?.ocrLanguages)
break
case 'translate':
await translateItemText(row.item_key, sourceLanguage || undefined)

View File

@@ -538,7 +538,7 @@ async function extractWithTesseract(
* Translation is not performed automatically — call translateItemText() separately.
* Returns { extractedText, translatedText } where translatedText is always null.
*/
export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> {
export async function extractItemText(itemKey: string, ocrLanguagesOverride?: string): Promise<{ extractedText: string; translatedText: string | null }> {
const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
@@ -567,7 +567,8 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
throw Object.assign(new Error('Text extraction is only available for images'), { code: 'NO_IMAGE' })
}
const { ocrMode, ocrLanguages, ocrConfidenceThreshold } = config
const { ocrMode, ocrLanguages: configOcrLanguages, ocrConfidenceThreshold } = config
const ocrLanguages = ocrLanguagesOverride?.trim() || configOcrLanguages
// ── Tesseract path ────────────────────────────────────────────────────────
if (ocrMode === 'tesseract' || ocrMode === 'hybrid') {

View File

@@ -338,4 +338,12 @@ function migrateAiJobs(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status);
CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at);
`)
// Add payload column if not present
const aiJobsRow = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='ai_jobs'")
.get() as { sql: string } | undefined
if (aiJobsRow && !aiJobsRow.sql.includes('payload')) {
db.exec('ALTER TABLE ai_jobs ADD COLUMN payload TEXT')
}
}