From d754f857171160f464a4a11b530890ae19f6eb01 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:45:20 -0400 Subject: [PATCH 1/3] update gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8933a9f..a593a36 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ medialore.db-shm medialore.db-wal tsconfig.tsbuildinfo .session_secret -.vscode/ \ No newline at end of file +.vscode/ +*.traineddata \ No newline at end of file From 96cfb8aae7b7184efa1b0785e637c7501635c695 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:37:20 -0400 Subject: [PATCH 2/3] UI polish: live job polling, panel layout, pending button states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Poll /api/ai-tagging/fields every 2s after any 202 (queued) response in ImageLightbox and DoomScrollView so extraction, translation, and description results appear automatically without a page refresh - DoomScrollView extract button now turns accent-coloured while a job is queued instead of flashing red; red is reserved for genuine errors - Kebab menu "Translate" option is now gated on entry.hasExtractedText (populated via a batch DB query in the browse API) so it only appears when there is text to translate - Tag panel redesigned: toolbar collapses to just the filename when open; panel header holds hide (›), AI Tagger (✨), and Close (✕) buttons; sections ordered Description → Text Extraction → Tags; description state and generate handler moved from TagSelector into ImageLightbox - VideoPlayerModal receives the same toolbar/panel restructure - TagSelector gains hideDescription prop so the parent can own description Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/browse/route.ts | 16 + src/components/DoomScrollView.tsx | 62 +++- src/components/mixed/ImageLightbox.tsx | 419 ++++++++++++++++------ src/components/mixed/MixedView.tsx | 2 +- src/components/mixed/VideoPlayerModal.tsx | 209 +++++++---- src/components/tags/TagSelector.tsx | 63 ++-- src/types/index.ts | 1 + 7 files changed, 552 insertions(+), 220 deletions(-) diff --git a/src/app/api/browse/route.ts b/src/app/api/browse/route.ts index f39d863..2bd89e5 100644 --- a/src/app/api/browse/route.ts +++ b/src/app/api/browse/route.ts @@ -1,4 +1,5 @@ import fs from 'fs' +import path from 'path' import { NextRequest, NextResponse } from 'next/server' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { scanDirectory, scanDirectoryRecursive } from '@/lib/files' @@ -31,6 +32,21 @@ export async function GET(request: NextRequest) { const listing = recursive ? scanDirectoryRecursive(root, libraryId, subpath) : scanDirectory(root, libraryId, subpath) + + // Annotate image entries with whether they have extracted text + const db = getDb() + const rows = db + .prepare('SELECT item_key FROM media_items WHERE library_id = ? AND extracted_text IS NOT NULL') + .all(libraryId) as { item_key: string }[] + const withText = new Set(rows.map((r) => r.item_key)) + + listing.entries = listing.entries.map((e) => { + if (e.type !== 'file' || e.mediaType !== 'image') return e + const relPath = subpath ? path.join(subpath, e.name) : e.name + const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}` + return { ...e, hasExtractedText: withText.has(itemKey) } + }) + return NextResponse.json(listing) } diff --git a/src/components/DoomScrollView.tsx b/src/components/DoomScrollView.tsx index e1dd38d..daa5f96 100644 --- a/src/components/DoomScrollView.tsx +++ b/src/components/DoomScrollView.tsx @@ -48,8 +48,10 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, const [showOriginal, setShowOriginal] = useState(false) const [extracting, setExtracting] = useState(false) const [extractError, setExtractError] = useState(null) + const [extractPending, setExtractPending] = useState(false) const videoRef = useRef(null) + const extractPollRef = useRef | null>(null) const cooldownRef = useRef(false) const touchStartY = useRef(null) @@ -126,14 +128,19 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, return () => clearTimeout(id) }, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext]) - // Fetch extracted text for current item + // Fetch extracted text for current item; clear any in-flight poll on item change useEffect(() => { + if (extractPollRef.current) { + clearInterval(extractPollRef.current) + extractPollRef.current = null + } setExtractedText(null) setTranslatedText(null) setShowTextOverlay(false) setShowOriginal(false) setExtracting(false) setExtractError(null) + setExtractPending(false) if (!current?.itemKey) return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`) .then((r) => r.json()) @@ -144,6 +151,13 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, .catch(() => {}) }, [current?.itemKey]) + // Clean up poll on unmount + useEffect(() => { + return () => { + if (extractPollRef.current) clearInterval(extractPollRef.current) + } + }, []) + useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); return } @@ -184,23 +198,44 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, const handleExtractText = async () => { if (!current?.itemKey) return + const itemKey = current.itemKey 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: current.itemKey }), + body: JSON.stringify({ itemKey }), }) + if (res.status === 202) { + // Job queued — poll until it completes (up to 5 min) + setExtractPending(true) + const deadline = Date.now() + 5 * 60 * 1000 + extractPollRef.current = setInterval(async () => { + if (Date.now() > deadline) { + if (extractPollRef.current) clearInterval(extractPollRef.current) + setExtractPending(false) + return + } + try { + const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`) + const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json() + if (data.extractedText) { + if (extractPollRef.current) clearInterval(extractPollRef.current) + setExtractPending(false) + setExtractedText(data.extractedText) + setTranslatedText(data.extractedTextTranslated) + setShowTextOverlay(true) + } + } catch { /* ignore */ } + }, 2000) + return + } if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error((data as { error?: string }).error ?? 'Extraction failed') } - if (res.status === 202) { - setExtractError('Queued — check AI Integrations for progress') - setTimeout(() => setExtractError(null), 4000) - return - } const result = await res.json() setExtractedText(result.extractedText || null) setTranslatedText(result.translatedText || null) @@ -371,15 +406,20 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, ) : current?.itemKey && current?.mediaType === 'image' ? ( )} - {itemKey && ( - + {/* These buttons only show in the toolbar when the tag panel is closed */} + {!showTags && ( + <> + {itemKey && ( + + )} + {onAiTag && ( + + )} + + )} - {onAiTag && ( - - )} - {showTags ? (
{/* Image */} -
+
{/* eslint-disable-next-line @next/next/no-img-element */} )}
+ {/* Tag panel */}
e.stopPropagation()} > -

- Tags -

- + {/* Panel header — hide panel + AI tagger + close lightbox */} +
+ +
+ {onAiTag && ( + + )} + +
+
+ + {/* Description section */} +
+

+ Description +

+ {aiDescription && ( +

+ {aiDescription} +

+ )} +
+ + {descError && ( + {descError} + )} +
+
{/* Text extraction section — only for images */} {isImage && ( -
-

+

+

Text Extraction

@@ -259,21 +443,22 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item 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 + } if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error((data as { error?: string }).error ?? 'Failed to extract text') } - if (res.status === 202) { - setExtractError('Queued — check AI Integrations for progress') - setTimeout(() => setExtractError(null), 4000) - return - } const result = await res.json() setExtractedText(result.extractedText || null) setEditedExtractedText(result.extractedText || '') @@ -285,25 +470,30 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item setExtracting(false) } }} - disabled={extracting} - className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 mb-2" - style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }} + 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) { + if (!extracting && !extractPending) { ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)' ;(e.currentTarget as HTMLElement).style.color = 'var(--background)' } }} onMouseLeave={(e) => { - ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)' - ;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)' + if (!extractPending) { + ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)' + ;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)' + } }} > - {extracting ? '⟳ Extracting…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'} + {extracting ? '⟳ Extracting…' : extractPending ? '⟳ Queued…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'} {extractError && ( -

{extractError}

+

{extractError}

)} {extractedText && ( @@ -380,47 +570,64 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
)}
)} + + {/* Tags section */} +
+

+ Tags +

+ +
) : ( diff --git a/src/components/mixed/MixedView.tsx b/src/components/mixed/MixedView.tsx index 07e93db..e51735d 100644 --- a/src/components/mixed/MixedView.tsx +++ b/src/components/mixed/MixedView.tsx @@ -844,7 +844,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac 🔍 Extract Text for Folder )} - {onTranslate && entry.mediaType === 'image' && ( + {onTranslate && entry.mediaType === 'image' && entry.hasExtractedText && ( + )} + {onAiTag && ( + + )} - )} - {onAiTag && ( - - )} - - + + )} {showTags ? ( @@ -164,16 +161,84 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i )} + {/* Tag panel */}
e.stopPropagation()} > -

- Tags -

- + {/* Panel header — hide panel + AI tagger + close */} +
+ +
+ {onAiTag && ( + + )} + +
+
+ + {/* Tags section */} +
+

+ Tags +

+ +
) : ( diff --git a/src/components/tags/TagSelector.tsx b/src/components/tags/TagSelector.tsx index 742cb16..7d3a756 100644 --- a/src/components/tags/TagSelector.tsx +++ b/src/components/tags/TagSelector.tsx @@ -8,6 +8,7 @@ interface Props { itemKey: string onTagsChanged?: () => void refreshKey?: number + hideDescription?: boolean } interface AllTags { @@ -15,7 +16,7 @@ interface AllTags { tags: Tag[] } -export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Props) { +export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription }: Props) { const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({ tags: [], categories: [], @@ -210,37 +211,39 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop return (
{/* AI description */} -
- {aiDescription && ( -

- {aiDescription} -

- )} -
- - {descError && ( - {descError} + {!hideDescription && ( +
+ {aiDescription && ( +

+ {aiDescription} +

)} +
+ + {descError && ( + {descError} + )} +
-
+ )} {/* Assigned tags grouped by category */} {assigned.tags.length > 0 && (
diff --git a/src/types/index.ts b/src/types/index.ts index 19d014c..fcf8b88 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -44,6 +44,7 @@ export interface FileEntry { mediaType: MediaType | null url: string | null thumbnailUrl: string | null + hasExtractedText?: boolean } export interface Movie { From db2e446ef499ca035320ffa2b418145aed0734f6 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:55:07 -0400 Subject: [PATCH 3/3] feat: per-extraction OCR language override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/api/ai-settings/ocr/route.ts | 11 ++ src/app/api/ai-tagging/extract-text/route.ts | 12 +- src/components/DoomScrollView.tsx | 2 +- src/components/mixed/ImageLightbox.tsx | 133 ++++++++++++------- src/components/mixed/MixedView.tsx | 93 +++++++++++-- src/lib/ai-jobs.ts | 12 +- src/lib/ai-tagger.ts | 5 +- src/lib/db.ts | 8 ++ 8 files changed, 206 insertions(+), 70 deletions(-) create mode 100644 src/app/api/ai-settings/ocr/route.ts diff --git a/src/app/api/ai-settings/ocr/route.ts b/src/app/api/ai-settings/ocr/route.ts new file mode 100644 index 0000000..2ccf3ce --- /dev/null +++ b/src/app/api/ai-settings/ocr/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth' +import { getAiConfig } from '@/lib/app-settings' + +export async function GET(request: NextRequest) { + const auth = await requireAuth(request) + if (auth instanceof NextResponse) return auth + + const { ocrMode, ocrLanguages } = getAiConfig() + return NextResponse.json({ ocrMode, ocrLanguages }) +} diff --git a/src/app/api/ai-tagging/extract-text/route.ts b/src/app/api/ai-tagging/extract-text/route.ts index 5b6ad22..b213555 100644 --- a/src/app/api/ai-tagging/extract-text/route.ts +++ b/src/app/api/ai-tagging/extract-text/route.ts @@ -3,14 +3,14 @@ import { requireLibraryAccess } from '@/lib/auth' import { enqueueJob } from '@/lib/ai-jobs' export async function POST(request: NextRequest) { - let body: { itemKey?: string } + let body: { itemKey?: string; ocrLanguages?: string } try { body = await request.json() } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - const { itemKey } = body + const { itemKey, ocrLanguages } = body if (!itemKey || typeof itemKey !== 'string') { return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) } @@ -19,6 +19,12 @@ export async function POST(request: NextRequest) { const auth = await requireLibraryAccess(request, libraryId) if (auth instanceof NextResponse) return auth - const jobId = enqueueJob(itemKey, 'extract', libraryId) + const jobId = enqueueJob( + itemKey, + 'extract', + libraryId, + undefined, + ocrLanguages ? { ocrLanguages } : undefined, + ) return NextResponse.json({ jobId }, { status: 202 }) } diff --git a/src/components/DoomScrollView.tsx b/src/components/DoomScrollView.tsx index daa5f96..d750160 100644 --- a/src/components/DoomScrollView.tsx +++ b/src/components/DoomScrollView.tsx @@ -336,7 +336,7 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, {/* Text overlay */} {showTextOverlay && displayText && (
e.stopPropagation()} > diff --git a/src/components/mixed/ImageLightbox.tsx b/src/components/mixed/ImageLightbox.tsx index baa3742..d059616 100644 --- a/src/components/mixed/ImageLightbox.tsx +++ b/src/components/mixed/ImageLightbox.tsx @@ -39,6 +39,11 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item const [descPending, setDescPending] = useState(false) const [descError, setDescError] = useState(null) + // OCR settings + const [ocrMode, setOcrMode] = useState(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

- + }} + 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'} + + {ocrMode && ocrMode !== 'llm' && ( + 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." + /> + )} +
{extractError && (

{extractError}

diff --git a/src/components/mixed/MixedView.tsx b/src/components/mixed/MixedView.tsx index e51735d..ebd856d 100644 --- a/src/components/mixed/MixedView.tsx +++ b/src/components/mixed/MixedView.tsx @@ -83,6 +83,9 @@ export default function MixedView({ libraryId, initialPath }: Props) { setDoomScrollLoading(false) }, [currentPath]) + const [ocrMode, setOcrMode] = useState(null) + const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng') + const fetchAssignments = useCallback(() => { fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`) .then((r) => r.json()) @@ -92,6 +95,16 @@ export default function MixedView({ libraryId, initialPath }: Props) { useEffect(() => { fetchAssignments() }, [fetchAssignments]) + useEffect(() => { + fetch('/api/ai-settings/ocr') + .then((r) => r.json()) + .then((d: { ocrMode: string; ocrLanguages: string }) => { + setOcrMode(d.ocrMode) + setDefaultOcrLanguages(d.ocrLanguages) + }) + .catch(() => {}) + }, []) + const filtersActive = search !== '' || selectedTagIds.size > 0 const fetchRecursive = useCallback(() => { @@ -387,6 +400,8 @@ export default function MixedView({ libraryId, initialPath }: Props) { entry={entry} onOpen={handleEntry} onTag={handleTagEntry} + ocrMode={ocrMode} + defaultOcrLanguages={defaultOcrLanguages} onAiTag={async (e) => { const itemKey = itemKeyFor(e) const res = await fetch('/api/ai-tagging', { @@ -401,7 +416,7 @@ export default function MixedView({ libraryId, initialPath }: Props) { fetchAssignments() setFilterRefreshKey((k) => k + 1) }} - onExtractText={async (e) => { + onExtractText={async (e, ocrLanguages) => { if (e.type === 'directory') { // Bulk extract for directory const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name) @@ -420,7 +435,7 @@ export default function MixedView({ libraryId, initialPath }: Props) { const res = await fetch('/api/ai-tagging/extract-text', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ itemKey }), + body: JSON.stringify({ itemKey, ...(ocrLanguages && { ocrLanguages }) }), }) if (!res.ok) { const data = await res.json().catch(() => ({})) @@ -594,7 +609,7 @@ export default function MixedView({ libraryId, initialPath }: Props) { ) } -function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe, onTranslate }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise; onAiTag?: (e: FileEntry) => Promise; onExtractText?: (e: FileEntry) => Promise; onDescribe?: (e: FileEntry) => Promise; onTranslate?: (e: FileEntry) => Promise }) { +function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe, onTranslate, ocrMode, defaultOcrLanguages }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise; onAiTag?: (e: FileEntry) => Promise; onExtractText?: (e: FileEntry, ocrLanguages?: string) => Promise; onDescribe?: (e: FileEntry) => Promise; onTranslate?: (e: FileEntry) => Promise; ocrMode?: string | null; defaultOcrLanguages?: string }) { type ImgState = 'loading' | 'loaded' | 'error' const [imgState, setImgState] = useState( entry.thumbnailUrl ? 'loading' : 'error' @@ -615,6 +630,8 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac const [describeError, setDescribeError] = useState(null) const [translating, setTranslating] = useState(false) const [translateError, setTranslateError] = useState(null) + const [showOcrPrompt, setShowOcrPrompt] = useState(false) + const [ocrLanguageInput, setOcrLanguageInput] = useState('') useEffect(() => { if (!menuOpen) return @@ -804,16 +821,21 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac 📝 Describe Folder )} - {onExtractText && entry.mediaType === 'image' && ( + {onExtractText && entry.mediaType === 'image' && !showOcrPrompt && ( )} + {onExtractText && entry.mediaType === 'image' && showOcrPrompt && ( +
e.stopPropagation()}> +

OCR language

+ setOcrLanguageInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { setShowOcrPrompt(false) } + if (e.key === 'Enter') { + setShowOcrPrompt(false) + setMenuOpen(false) + setTextExtracting(true) + setTextExtractError(null) + onExtractText(entry, ocrLanguageInput.trim() || undefined) + .catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed')) + .finally(() => setTextExtracting(false)) + } + }} + placeholder={defaultOcrLanguages ?? 'eng'} + className="text-xs px-2 py-1 rounded-lg outline-none w-full" + style={{ backgroundColor: 'var(--background)', border: '1px solid var(--border)', color: 'var(--text-primary)' }} + title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default." + /> +
+ + +
+
+ )} {onExtractText && entry.type === 'directory' && (