Merge pull request 'responsiveness' (#26) from responsiveness into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 54s
All checks were successful
Build and Push Docker Image / build (push) Successful in 54s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/26
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ medialore.db-wal
|
||||
tsconfig.tsbuildinfo
|
||||
.session_secret
|
||||
.vscode/
|
||||
*.traineddata
|
||||
11
src/app/api/ai-settings/ocr/route.ts
Normal file
11
src/app/api/ai-settings/ocr/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [extractPending, setExtractPending] = useState(false)
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const cooldownRef = useRef(false)
|
||||
const touchStartY = useRef<number | null>(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)
|
||||
@@ -301,7 +336,7 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
{/* Text overlay */}
|
||||
{showTextOverlay && displayText && (
|
||||
<div
|
||||
className="absolute bottom-16 left-4 right-4 z-20 rounded-xl p-4"
|
||||
className="absolute bottom-4 left-4 right-4 z-20 rounded-xl p-4 max-w-fit"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -371,15 +406,20 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
) : current?.itemKey && current?.mediaType === 'image' ? (
|
||||
<button
|
||||
onClick={handleExtractText}
|
||||
disabled={extracting}
|
||||
disabled={extracting || extractPending}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
|
||||
style={{
|
||||
backgroundColor: extractError ? 'rgba(127,29,29,0.8)' : 'rgba(0,0,0,0.5)',
|
||||
backgroundColor: extractPending
|
||||
? 'var(--accent)'
|
||||
: extractError
|
||||
? 'rgba(127,29,29,0.8)'
|
||||
: 'rgba(0,0,0,0.5)',
|
||||
color: extractError ? '#fca5a5' : '#fff',
|
||||
}}
|
||||
aria-label="Extract text"
|
||||
aria-label={extractPending ? 'Extracting text…' : 'Extract text'}
|
||||
title={extractPending ? 'Queued — extracting text…' : extractError ?? 'Extract text'}
|
||||
>
|
||||
{extracting ? (
|
||||
{extracting || extractPending ? (
|
||||
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '0.75rem' }}>⟳</span>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import TagSelector from '@/components/tags/TagSelector'
|
||||
|
||||
interface Props {
|
||||
@@ -25,16 +25,32 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
const [extractedText, setExtractedText] = useState<string | null>(null)
|
||||
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
||||
const [extracting, setExtracting] = useState(false)
|
||||
const [extractPending, setExtractPending] = useState(false)
|
||||
const [extractError, setExtractError] = useState<string | null>(null)
|
||||
const [retranslating, setRetranslating] = useState(false)
|
||||
const [translatePending, setTranslatePending] = useState(false)
|
||||
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
||||
const [savingText, setSavingText] = useState(false)
|
||||
const [sourceLanguage, setSourceLanguage] = useState('')
|
||||
|
||||
// Description state (moved from TagSelector)
|
||||
const [aiDescription, setAiDescription] = useState<string | null>(null)
|
||||
const [generatingDesc, setGeneratingDesc] = useState(false)
|
||||
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)
|
||||
|
||||
// Polling ref
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Determine if this is an image file (for text extraction controls)
|
||||
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
|
||||
|
||||
@@ -42,18 +58,68 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
const displayText = (translatedText && !showOriginal) ? translatedText : extractedText
|
||||
|
||||
// Fetch existing AI fields on mount / item change
|
||||
useEffect(() => {
|
||||
if (!itemKey) return
|
||||
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||||
const fetchAiFields = useCallback(() => {
|
||||
if (!itemKey) return Promise.resolve()
|
||||
return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
||||
.then((data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null }) => {
|
||||
setExtractedText(data.extractedText)
|
||||
setEditedExtractedText(data.extractedText ?? '')
|
||||
setTranslatedText(data.extractedTextTranslated)
|
||||
setAiDescription(data.aiDescription)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [itemKey])
|
||||
|
||||
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)
|
||||
}
|
||||
}, [fetchAiFields])
|
||||
|
||||
// Start polling fields every 2s until data changes or 5-min timeout
|
||||
const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null, snapshotDesc: string | null) => {
|
||||
if (!itemKey) return
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
const deadline = Date.now() + 5 * 60 * 1000
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (Date.now() > deadline) {
|
||||
clearInterval(pollRef.current!)
|
||||
pollRef.current = null
|
||||
setExtractPending(false)
|
||||
setTranslatePending(false)
|
||||
setDescPending(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||||
const data: { extractedText: string | null; extractedTextTranslated: string | null; aiDescription: string | null } = await r.json()
|
||||
const textChanged = data.extractedText !== snapshotText
|
||||
const translationChanged = data.extractedTextTranslated !== snapshotTranslated
|
||||
const descChanged = data.aiDescription !== snapshotDesc
|
||||
if (textChanged || translationChanged || descChanged) {
|
||||
clearInterval(pollRef.current!)
|
||||
pollRef.current = null
|
||||
setExtractedText(data.extractedText)
|
||||
setEditedExtractedText(data.extractedText ?? '')
|
||||
setTranslatedText(data.extractedTextTranslated)
|
||||
setAiDescription(data.aiDescription)
|
||||
setExtractPending(false)
|
||||
setTranslatePending(false)
|
||||
setDescPending(false)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 2000)
|
||||
}, [itemKey])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
@@ -72,6 +138,38 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
if (e.target === overlayRef.current) onClose()
|
||||
}
|
||||
|
||||
const handleGenerateDescription = async () => {
|
||||
if (!itemKey) return
|
||||
setGeneratingDesc(true)
|
||||
setDescError(null)
|
||||
setDescPending(false)
|
||||
try {
|
||||
const res = await fetch('/api/ai-tagging/describe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey }),
|
||||
})
|
||||
if (res.status === 202) {
|
||||
setDescPending(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 generate description')
|
||||
}
|
||||
const { description } = await res.json()
|
||||
setAiDescription(description)
|
||||
} catch (err) {
|
||||
setDescError(err instanceof Error ? err.message : 'Failed to generate description')
|
||||
setTimeout(() => setDescError(null), 4000)
|
||||
} finally {
|
||||
setGeneratingDesc(false)
|
||||
}
|
||||
}
|
||||
|
||||
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
@@ -79,13 +177,13 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
{/* Toolbar — collapses to just filename + text overlay when tag panel is open */}
|
||||
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
|
||||
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
|
||||
{name}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Text overlay button — only shown when extracted text exists */}
|
||||
{/* Text overlay button — always shown when text exists */}
|
||||
{extractedText && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowTextOverlay((v) => !v) }}
|
||||
@@ -110,82 +208,79 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{itemKey && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||
style={{
|
||||
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
|
||||
color: showTags ? '#fff' : 'var(--text-primary)',
|
||||
fontSize: '1.5rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||
}}
|
||||
aria-label={showTags ? 'Hide tags' : 'Show tags'}
|
||||
title="Tags"
|
||||
>
|
||||
🏷
|
||||
</button>
|
||||
{/* These buttons only show in the toolbar when the tag panel is closed */}
|
||||
{!showTags && (
|
||||
<>
|
||||
{itemKey && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowTags(true) }}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
|
||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||
aria-label="Show tags"
|
||||
title="Tags"
|
||||
>
|
||||
🏷
|
||||
</button>
|
||||
)}
|
||||
{onAiTag && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
setAiTagging(true)
|
||||
setAiTagError(null)
|
||||
try {
|
||||
await onAiTag()
|
||||
setTagRefreshKey((k) => k + 1)
|
||||
onTagsChanged?.()
|
||||
} catch (err) {
|
||||
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
||||
setTimeout(() => setAiTagError(null), 4000)
|
||||
} finally {
|
||||
setAiTagging(false)
|
||||
}
|
||||
}}
|
||||
disabled={aiTagging}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
|
||||
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
|
||||
fontSize: '1.5rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||
}}
|
||||
aria-label="AI Tag this image"
|
||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||
>
|
||||
{aiTagging ? (
|
||||
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '1.2rem' }}>⟳</span>
|
||||
) : '✨'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
|
||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onAiTag && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
setAiTagging(true)
|
||||
setAiTagError(null)
|
||||
try {
|
||||
await onAiTag()
|
||||
setTagRefreshKey((k) => k + 1)
|
||||
onTagsChanged?.()
|
||||
} catch (err) {
|
||||
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
||||
setTimeout(() => setAiTagError(null), 4000)
|
||||
} finally {
|
||||
setAiTagging(false)
|
||||
}
|
||||
}}
|
||||
disabled={aiTagging}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
|
||||
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
|
||||
fontSize: '1.5rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||
}}
|
||||
aria-label="AI Tag this image"
|
||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||
>
|
||||
{aiTagging ? (
|
||||
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '1.2rem' }}>⟳</span>
|
||||
) : '✨'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)', fontSize: '1.5rem' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTags ? (
|
||||
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-fit max-w-fit">
|
||||
{/* Image */}
|
||||
<div className="w-full flex-1 min-w-0 min-h-0 h-full flex items-center justify-center overflow-hidden relative">
|
||||
<div className="w-full flex-1 min-w-0 min-h-0 h-full flex items-center justify-center overflow-hidden relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
@@ -237,73 +332,201 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag panel */}
|
||||
<div
|
||||
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
|
||||
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4 flex flex-col gap-4"
|
||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
Tags
|
||||
</p>
|
||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
||||
|
||||
{/* Text extraction section — only for images */}
|
||||
{isImage && (
|
||||
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
Text Extraction
|
||||
</p>
|
||||
|
||||
{/* Panel header — hide panel + AI tagger + close lightbox */}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowTags(false)}
|
||||
className={smallBtn}
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||
aria-label="Hide panel"
|
||||
title="Hide panel"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{onAiTag && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
setAiTagging(true)
|
||||
setAiTagError(null)
|
||||
try {
|
||||
await onAiTag()
|
||||
setTagRefreshKey((k) => k + 1)
|
||||
onTagsChanged?.()
|
||||
} catch (err) {
|
||||
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
||||
setTimeout(() => setAiTagError(null), 4000)
|
||||
} finally {
|
||||
setAiTagging(false)
|
||||
}
|
||||
}}
|
||||
disabled={aiTagging}
|
||||
className={`${smallBtn} disabled:opacity-50`}
|
||||
style={{
|
||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
|
||||
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
}}
|
||||
aria-label="AI Tag this image"
|
||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||
>
|
||||
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
setExtracting(true)
|
||||
setExtractError(null)
|
||||
try {
|
||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey }),
|
||||
})
|
||||
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 || '')
|
||||
setTranslatedText(result.translatedText || null)
|
||||
} catch (err) {
|
||||
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
|
||||
setTimeout(() => setExtractError(null), 4000)
|
||||
} finally {
|
||||
setExtracting(false)
|
||||
}
|
||||
onClick={onClose}
|
||||
className={smallBtn}
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description section */}
|
||||
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
Description
|
||||
</p>
|
||||
{aiDescription && (
|
||||
<p className="text-xs italic mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{aiDescription}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleGenerateDescription}
|
||||
disabled={generatingDesc || descPending}
|
||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
|
||||
color: descPending ? '#fff' : 'var(--text-secondary)',
|
||||
}}
|
||||
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)' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!extracting) {
|
||||
if (!generatingDesc && !descPending) {
|
||||
;(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 (!descPending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}
|
||||
}}
|
||||
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
||||
>
|
||||
{extracting ? '⟳ Extracting…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
|
||||
{generatingDesc ? '⟳ Generating…' : descPending ? '⟳ Queued…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
|
||||
</button>
|
||||
{descError && (
|
||||
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text extraction section — only for images */}
|
||||
{isImage && (
|
||||
<div className="flex flex-col gap-2" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||
Text Extraction
|
||||
</p>
|
||||
|
||||
<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)
|
||||
}
|
||||
}}
|
||||
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)'
|
||||
}
|
||||
}}
|
||||
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 mb-2" style={{ color: '#f87171' }}>{extractError}</p>
|
||||
<p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>
|
||||
)}
|
||||
|
||||
{extractedText && (
|
||||
@@ -380,47 +603,64 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
<button
|
||||
onClick={async () => {
|
||||
setRetranslating(true)
|
||||
setTranslatePending(false)
|
||||
try {
|
||||
const res = await fetch('/api/ai-tagging/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
||||
})
|
||||
if (res.status === 202) {
|
||||
setTranslatePending(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 translate')
|
||||
}
|
||||
if (res.status !== 202) {
|
||||
const result = await res.json()
|
||||
setTranslatedText(result.translatedText || null)
|
||||
}
|
||||
const result = await res.json()
|
||||
setTranslatedText(result.translatedText || null)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setRetranslating(false)
|
||||
}
|
||||
}}
|
||||
disabled={retranslating}
|
||||
disabled={retranslating || translatePending}
|
||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
style={{
|
||||
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
|
||||
color: translatePending ? '#fff' : 'var(--text-secondary)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!retranslating) {
|
||||
if (!retranslating && !translatePending) {
|
||||
;(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 (!translatePending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{retranslating ? '⟳ Translating…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
||||
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags section */}
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
Tags
|
||||
</p>
|
||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} hideDescription />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -83,6 +83,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
setDoomScrollLoading(false)
|
||||
}, [currentPath])
|
||||
|
||||
const [ocrMode, setOcrMode] = useState<string | null>(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<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry) => Promise<void>; onDescribe?: (e: FileEntry) => Promise<void>; onTranslate?: (e: FileEntry) => Promise<void> }) {
|
||||
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<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry, ocrLanguages?: string) => Promise<void>; onDescribe?: (e: FileEntry) => Promise<void>; onTranslate?: (e: FileEntry) => Promise<void>; ocrMode?: string | null; defaultOcrLanguages?: string }) {
|
||||
type ImgState = 'loading' | 'loaded' | 'error'
|
||||
const [imgState, setImgState] = useState<ImgState>(
|
||||
entry.thumbnailUrl ? 'loading' : 'error'
|
||||
@@ -615,6 +630,8 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
const [describeError, setDescribeError] = useState<string | null>(null)
|
||||
const [translating, setTranslating] = useState(false)
|
||||
const [translateError, setTranslateError] = useState<string | null>(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
|
||||
</button>
|
||||
)}
|
||||
{onExtractText && entry.mediaType === 'image' && (
|
||||
{onExtractText && entry.mediaType === 'image' && !showOcrPrompt && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
setTextExtracting(true)
|
||||
setTextExtractError(null)
|
||||
onExtractText(entry)
|
||||
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
|
||||
.finally(() => setTextExtracting(false))
|
||||
if (ocrMode && ocrMode !== 'llm') {
|
||||
setOcrLanguageInput('')
|
||||
setShowOcrPrompt(true)
|
||||
} else {
|
||||
setMenuOpen(false)
|
||||
setTextExtracting(true)
|
||||
setTextExtractError(null)
|
||||
onExtractText(entry)
|
||||
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
|
||||
.finally(() => setTextExtracting(false))
|
||||
}
|
||||
}}
|
||||
disabled={textExtracting}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
||||
@@ -824,6 +846,57 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
🔍 Extract Text
|
||||
</button>
|
||||
)}
|
||||
{onExtractText && entry.mediaType === 'image' && showOcrPrompt && (
|
||||
<div className="px-4 py-2 flex flex-col gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>OCR language</p>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={ocrLanguageInput}
|
||||
onChange={(e) => 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."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
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))
|
||||
}}
|
||||
className="text-xs px-2 py-1 rounded-lg"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
Extract
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowOcrPrompt(false)}
|
||||
className="text-xs px-2 py-1"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{onExtractText && entry.type === 'directory' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -844,7 +917,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
🔍 Extract Text for Folder
|
||||
</button>
|
||||
)}
|
||||
{onTranslate && entry.mediaType === 'image' && (
|
||||
{onTranslate && entry.mediaType === 'image' && entry.hasExtractedText && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -45,6 +45,8 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
if (e.target === overlayRef.current) onClose()
|
||||
}
|
||||
|
||||
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
@@ -52,80 +54,75 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.9)', height: '100vh', maxHeight: '100vh' }}
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
{/* Toolbar — collapses to just filename when tag panel is open */}
|
||||
<div className={`flex items-center justify-between w-full flex-shrink-0 ${showTags ? '' : 'max-w-4xl'}`}>
|
||||
<span className="text-sm truncate max-w-[80%]" style={{ color: 'var(--text-secondary)' }}>
|
||||
{name}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{itemKey && (
|
||||
{!showTags && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{itemKey && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowTags(true) }}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||
aria-label="Show tags"
|
||||
title="Tags"
|
||||
>
|
||||
🏷
|
||||
</button>
|
||||
)}
|
||||
{onAiTag && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
setAiTagging(true)
|
||||
setAiTagError(null)
|
||||
try {
|
||||
await onAiTag()
|
||||
setTagRefreshKey((k) => k + 1)
|
||||
onTagsChanged?.()
|
||||
} catch (err) {
|
||||
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
||||
setTimeout(() => setAiTagError(null), 4000)
|
||||
} finally {
|
||||
setAiTagging(false)
|
||||
}
|
||||
}}
|
||||
disabled={aiTagging}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
|
||||
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||
}}
|
||||
aria-label="AI Tag this video"
|
||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||
>
|
||||
{aiTagging ? (
|
||||
<span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span>
|
||||
) : '✨'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||
style={{
|
||||
backgroundColor: showTags ? 'var(--accent)' : 'var(--surface)',
|
||||
color: showTags ? '#fff' : 'var(--text-primary)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!showTags) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||
}}
|
||||
aria-label={showTags ? 'Hide tags' : 'Show tags'}
|
||||
title="Tags"
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
|
||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'}
|
||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'}
|
||||
aria-label="Close"
|
||||
>
|
||||
🏷
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
{onAiTag && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
setAiTagging(true)
|
||||
setAiTagError(null)
|
||||
try {
|
||||
await onAiTag()
|
||||
setTagRefreshKey((k) => k + 1)
|
||||
onTagsChanged?.()
|
||||
} catch (err) {
|
||||
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
||||
setTimeout(() => setAiTagError(null), 4000)
|
||||
} finally {
|
||||
setAiTagging(false)
|
||||
}
|
||||
}}
|
||||
disabled={aiTagging}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
|
||||
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||
}}
|
||||
aria-label="AI Tag this video"
|
||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||
>
|
||||
{aiTagging ? (
|
||||
<span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span>
|
||||
) : '✨'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
|
||||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showTags ? (
|
||||
@@ -164,16 +161,84 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag panel */}
|
||||
<div
|
||||
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4"
|
||||
className="w-80 h-full max-h-full flex-shrink-0 rounded-xl overflow-y-auto p-4 flex flex-col gap-4"
|
||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
Tags
|
||||
</p>
|
||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
||||
{/* Panel header — hide panel + AI tagger + close */}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowTags(false)}
|
||||
className={smallBtn}
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||
aria-label="Hide panel"
|
||||
title="Hide panel"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{onAiTag && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
setAiTagging(true)
|
||||
setAiTagError(null)
|
||||
try {
|
||||
await onAiTag()
|
||||
setTagRefreshKey((k) => k + 1)
|
||||
onTagsChanged?.()
|
||||
} catch (err) {
|
||||
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
||||
setTimeout(() => setAiTagError(null), 4000)
|
||||
} finally {
|
||||
setAiTagging(false)
|
||||
}
|
||||
}}
|
||||
disabled={aiTagging}
|
||||
className={`${smallBtn} disabled:opacity-50`}
|
||||
style={{
|
||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
|
||||
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
}}
|
||||
aria-label="AI Tag this video"
|
||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||
>
|
||||
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={smallBtn}
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags section */}
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
Tags
|
||||
</p>
|
||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* AI description */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{aiDescription && (
|
||||
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
|
||||
{aiDescription}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleGenerateDescription}
|
||||
disabled={generatingDesc}
|
||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!generatingDesc) {
|
||||
;(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)'
|
||||
}}
|
||||
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
||||
>
|
||||
{generatingDesc ? '⟳ Generating…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
|
||||
</button>
|
||||
{descError && (
|
||||
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
|
||||
{!hideDescription && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{aiDescription && (
|
||||
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
|
||||
{aiDescription}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleGenerateDescription}
|
||||
disabled={generatingDesc}
|
||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!generatingDesc) {
|
||||
;(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)'
|
||||
}}
|
||||
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
||||
>
|
||||
{generatingDesc ? '⟳ Generating…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
|
||||
</button>
|
||||
{descError && (
|
||||
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Assigned tags grouped by category */}
|
||||
{assigned.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface FileEntry {
|
||||
mediaType: MediaType | null
|
||||
url: string | null
|
||||
thumbnailUrl: string | null
|
||||
hasExtractedText?: boolean
|
||||
}
|
||||
|
||||
export interface Movie {
|
||||
|
||||
Reference in New Issue
Block a user