Add SmartSpellChecker + refactor vocab-worksheet page.tsx
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 45s
CI / test-go-edu-search (push) Successful in 43s
CI / test-python-klausur (push) Failing after 2m51s
CI / test-python-agent-core (push) Successful in 36s
CI / test-nodejs-website (push) Successful in 37s
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 45s
CI / test-go-edu-search (push) Successful in 43s
CI / test-python-klausur (push) Failing after 2m51s
CI / test-python-agent-core (push) Successful in 36s
CI / test-nodejs-website (push) Successful in 37s
SmartSpellChecker (klausur-service): - Language-aware OCR post-correction without LLMs - Dual-dictionary heuristic for EN/DE language detection - Context-based a/I disambiguation via bigram lookup - Multi-digit substitution (sch00l→school) - Cross-language guard (don't false-correct DE words in EN column) - Umlaut correction (Schuler→Schüler, uber→über) - Integrated into spell_review_entries_sync() pipeline - 31 tests, 9ms/100 corrections Vocab-worksheet refactoring (studio-v2): - Split 2337-line page.tsx into 14 files - Custom hook useVocabWorksheet.ts (all state + logic) - 9 components in components/ directory - types.ts, constants.ts for shared definitions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
843
studio-v2/app/vocab-worksheet/useVocabWorksheet.ts
Normal file
843
studio-v2/app/vocab-worksheet/useVocabWorksheet.ts
Normal file
@@ -0,0 +1,843 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useActivity } from '@/lib/ActivityContext'
|
||||
import type { UploadedFile } from '@/components/QRCodeUpload'
|
||||
|
||||
import type {
|
||||
VocabularyEntry, ExtraColumn, Session, StoredDocument, OcrPrompts,
|
||||
TabId, WorksheetType, WorksheetFormat, IpaMode, SyllableMode,
|
||||
VocabWorksheetHook,
|
||||
} from './types'
|
||||
import {
|
||||
getApiBase, DOCUMENTS_KEY, OCR_PROMPTS_KEY, SESSION_ID_KEY,
|
||||
defaultOcrPrompts, formatFileSize,
|
||||
} from './constants'
|
||||
|
||||
export function useVocabWorksheet(): VocabWorksheetHook {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const { startActivity, completeActivity } = useActivity()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<TabId>('upload')
|
||||
|
||||
// Session state
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [sessionName, setSessionName] = useState('')
|
||||
const [isCreatingSession, setIsCreatingSession] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [extractionStatus, setExtractionStatus] = useState<string>('')
|
||||
|
||||
// Existing sessions list
|
||||
const [existingSessions, setExistingSessions] = useState<Session[]>([])
|
||||
const [isLoadingSessions, setIsLoadingSessions] = useState(true)
|
||||
|
||||
// Documents from storage
|
||||
const [storedDocuments, setStoredDocuments] = useState<StoredDocument[]>([])
|
||||
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null)
|
||||
|
||||
// Direct file upload
|
||||
const [directFile, setDirectFile] = useState<File | null>(null)
|
||||
const [directFilePreview, setDirectFilePreview] = useState<string | null>(null)
|
||||
const [showFullPreview, setShowFullPreview] = useState(false)
|
||||
const directFileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// PDF page selection state
|
||||
const [pdfPageCount, setPdfPageCount] = useState<number>(0)
|
||||
const [selectedPages, setSelectedPages] = useState<number[]>([])
|
||||
const [pagesThumbnails, setPagesThumbnails] = useState<string[]>([])
|
||||
const [isLoadingThumbnails, setIsLoadingThumbnails] = useState(false)
|
||||
const [excludedPages, setExcludedPages] = useState<number[]>([])
|
||||
|
||||
// Dynamic extra columns per source page
|
||||
const [pageExtraColumns, setPageExtraColumns] = useState<Record<number, ExtraColumn[]>>({})
|
||||
|
||||
// Upload state
|
||||
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
|
||||
const [isExtracting, setIsExtracting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Vocabulary state
|
||||
const [vocabulary, setVocabulary] = useState<VocabularyEntry[]>([])
|
||||
|
||||
// Worksheet state
|
||||
const [selectedTypes, setSelectedTypes] = useState<WorksheetType[]>(['en_to_de'])
|
||||
const [worksheetTitle, setWorksheetTitle] = useState('')
|
||||
const [includeSolutions, setIncludeSolutions] = useState(true)
|
||||
const [lineHeight, setLineHeight] = useState('normal')
|
||||
const [selectedFormat, setSelectedFormat] = useState<WorksheetFormat>('standard')
|
||||
const [ipaMode, setIpaMode] = useState<IpaMode>('none')
|
||||
const [syllableMode, setSyllableMode] = useState<SyllableMode>('none')
|
||||
|
||||
// Export state
|
||||
const [worksheetId, setWorksheetId] = useState<string | null>(null)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
// Processing results
|
||||
const [processingErrors, setProcessingErrors] = useState<string[]>([])
|
||||
const [successfulPages, setSuccessfulPages] = useState<number[]>([])
|
||||
const [failedPages, setFailedPages] = useState<number[]>([])
|
||||
const [currentlyProcessingPage, setCurrentlyProcessingPage] = useState<number | null>(null)
|
||||
const [processingQueue, setProcessingQueue] = useState<number[]>([])
|
||||
|
||||
// OCR Prompts/Settings
|
||||
const [ocrPrompts, setOcrPrompts] = useState<OcrPrompts>(defaultOcrPrompts)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
// QR Code Upload
|
||||
const [showQRModal, setShowQRModal] = useState(false)
|
||||
const [uploadSessionId, setUploadSessionId] = useState('')
|
||||
const [mobileUploadedFiles, setMobileUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [selectedMobileFile, setSelectedMobileFile] = useState<UploadedFile | null>(null)
|
||||
|
||||
// OCR Comparison
|
||||
const [showOcrComparison, setShowOcrComparison] = useState(false)
|
||||
const [ocrComparePageIndex, setOcrComparePageIndex] = useState<number | null>(null)
|
||||
const [ocrCompareResult, setOcrCompareResult] = useState<any>(null)
|
||||
const [isComparingOcr, setIsComparingOcr] = useState(false)
|
||||
const [ocrCompareError, setOcrCompareError] = useState<string | null>(null)
|
||||
|
||||
// --- Effects ---
|
||||
|
||||
// SSR Safety
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
||||
if (!storedSessionId) {
|
||||
storedSessionId = `vocab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
||||
}
|
||||
setUploadSessionId(storedSessionId)
|
||||
}, [])
|
||||
|
||||
// Load OCR prompts from localStorage
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
const stored = localStorage.getItem(OCR_PROMPTS_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
setOcrPrompts({ ...defaultOcrPrompts, ...JSON.parse(stored) })
|
||||
} catch (e) {
|
||||
console.error('Failed to parse OCR prompts:', e)
|
||||
}
|
||||
}
|
||||
}, [mounted])
|
||||
|
||||
// Load documents from localStorage
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
const stored = localStorage.getItem(DOCUMENTS_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
const docs = JSON.parse(stored)
|
||||
const imagesDocs = docs.filter((d: StoredDocument) =>
|
||||
d.type?.startsWith('image/') || d.type === 'application/pdf'
|
||||
)
|
||||
setStoredDocuments(imagesDocs)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored documents:', e)
|
||||
}
|
||||
}
|
||||
}, [mounted])
|
||||
|
||||
// Load existing sessions from API
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
const loadSessions = async () => {
|
||||
const API_BASE = getApiBase()
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions`)
|
||||
if (res.ok) {
|
||||
const sessions = await res.json()
|
||||
setExistingSessions(sessions)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load sessions:', e)
|
||||
} finally {
|
||||
setIsLoadingSessions(false)
|
||||
}
|
||||
}
|
||||
loadSessions()
|
||||
}, [mounted])
|
||||
|
||||
// --- Glassmorphism styles ---
|
||||
|
||||
const glassCard = isDark
|
||||
? 'backdrop-blur-xl bg-white/10 border border-white/20'
|
||||
: 'backdrop-blur-xl bg-white/70 border border-black/10'
|
||||
|
||||
const glassInput = isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400'
|
||||
: 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400 focus:border-purple-500'
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
const saveOcrPrompts = (prompts: OcrPrompts) => {
|
||||
setOcrPrompts(prompts)
|
||||
localStorage.setItem(OCR_PROMPTS_KEY, JSON.stringify(prompts))
|
||||
}
|
||||
|
||||
const handleDirectFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setDirectFile(file)
|
||||
setSelectedDocumentId(null)
|
||||
setSelectedMobileFile(null)
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
setDirectFilePreview(ev.target?.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
} else if (file.type === 'application/pdf') {
|
||||
setDirectFilePreview(URL.createObjectURL(file))
|
||||
} else {
|
||||
setDirectFilePreview(null)
|
||||
}
|
||||
}
|
||||
|
||||
const startSession = async () => {
|
||||
if (!sessionName.trim()) {
|
||||
setError('Bitte geben Sie einen Namen fuer die Session ein.')
|
||||
return
|
||||
}
|
||||
if (!selectedDocumentId && !directFile && !selectedMobileFile) {
|
||||
setError('Bitte waehlen Sie ein Dokument aus oder laden Sie eine Datei hoch.')
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setIsCreatingSession(true)
|
||||
setExtractionStatus('Session wird erstellt...')
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
try {
|
||||
const sessionRes = await fetch(`${API_BASE}/api/v1/vocab/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: sessionName,
|
||||
ocr_prompts: ocrPrompts
|
||||
}),
|
||||
})
|
||||
|
||||
if (!sessionRes.ok) {
|
||||
throw new Error('Session konnte nicht erstellt werden')
|
||||
}
|
||||
|
||||
const sessionData = await sessionRes.json()
|
||||
setSession(sessionData)
|
||||
setWorksheetTitle(sessionName)
|
||||
|
||||
startActivity('vocab_extraction', { description: sessionName })
|
||||
|
||||
let file: File
|
||||
let isPdf = false
|
||||
|
||||
if (directFile) {
|
||||
file = directFile
|
||||
isPdf = directFile.type === 'application/pdf'
|
||||
} else if (selectedMobileFile) {
|
||||
isPdf = selectedMobileFile.type === 'application/pdf'
|
||||
const base64Data = selectedMobileFile.dataUrl.split(',')[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: selectedMobileFile.type })
|
||||
file = new File([blob], selectedMobileFile.name, { type: selectedMobileFile.type })
|
||||
} else {
|
||||
const selectedDoc = storedDocuments.find(d => d.id === selectedDocumentId)
|
||||
if (!selectedDoc || !selectedDoc.url) {
|
||||
throw new Error('Das ausgewaehlte Dokument ist nicht verfuegbar.')
|
||||
}
|
||||
|
||||
isPdf = selectedDoc.type === 'application/pdf'
|
||||
|
||||
const base64Data = selectedDoc.url.split(',')[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: selectedDoc.type })
|
||||
file = new File([blob], selectedDoc.name, { type: selectedDoc.type })
|
||||
}
|
||||
|
||||
if (isPdf) {
|
||||
setExtractionStatus('PDF wird hochgeladen...')
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const pdfInfoRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/upload-pdf-info`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!pdfInfoRes.ok) {
|
||||
throw new Error('PDF konnte nicht verarbeitet werden')
|
||||
}
|
||||
|
||||
const pdfInfo = await pdfInfoRes.json()
|
||||
setPdfPageCount(pdfInfo.page_count)
|
||||
setSelectedPages(Array.from({ length: pdfInfo.page_count }, (_, i) => i))
|
||||
|
||||
setActiveTab('pages')
|
||||
setExtractionStatus(`${pdfInfo.page_count} Seiten erkannt. Vorschau wird geladen...`)
|
||||
setIsLoadingThumbnails(true)
|
||||
|
||||
const thumbnails: string[] = []
|
||||
for (let i = 0; i < pdfInfo.page_count; i++) {
|
||||
try {
|
||||
const thumbRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/pdf-thumbnail/${i}?hires=true`)
|
||||
if (thumbRes.ok) {
|
||||
const blob = await thumbRes.blob()
|
||||
thumbnails.push(URL.createObjectURL(blob))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to load thumbnail for page ${i}`)
|
||||
}
|
||||
}
|
||||
|
||||
setPagesThumbnails(thumbnails)
|
||||
setIsLoadingThumbnails(false)
|
||||
setExtractionStatus(`${pdfInfo.page_count} Seiten bereit. Waehlen Sie die zu verarbeitenden Seiten.`)
|
||||
|
||||
} else {
|
||||
setExtractionStatus('KI analysiert das Bild... (kann 30-60 Sekunden dauern)')
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const uploadRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error('Bild konnte nicht verarbeitet werden')
|
||||
}
|
||||
|
||||
const uploadData = await uploadRes.json()
|
||||
setSession(prev => prev ? { ...prev, status: 'extracted', vocabulary_count: uploadData.vocabulary_count } : null)
|
||||
|
||||
const vocabRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/vocabulary`)
|
||||
if (vocabRes.ok) {
|
||||
const vocabData = await vocabRes.json()
|
||||
setVocabulary(vocabData.vocabulary || [])
|
||||
setExtractionStatus(`${vocabData.vocabulary?.length || 0} Vokabeln gefunden!`)
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
setActiveTab('vocabulary')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Session start failed:', error)
|
||||
setError(error instanceof Error ? error.message : 'Ein Fehler ist aufgetreten')
|
||||
setExtractionStatus('')
|
||||
setSession(null)
|
||||
} finally {
|
||||
setIsCreatingSession(false)
|
||||
}
|
||||
}
|
||||
|
||||
const processSinglePage = async (pageIndex: number, ipa: IpaMode, syllable: SyllableMode): Promise<{ success: boolean; vocabulary: VocabularyEntry[]; error?: string }> => {
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session!.id}/process-single-page/${pageIndex}?ipa_mode=${ipa}&syllable_mode=${syllable}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ocr_prompts: ocrPrompts }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json().catch(() => ({}))
|
||||
const detail = errBody.detail || `HTTP ${res.status}`
|
||||
return { success: false, vocabulary: [], error: `Seite ${pageIndex + 1}: ${detail}` }
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!data.success) {
|
||||
return { success: false, vocabulary: [], error: data.error || `Seite ${pageIndex + 1}: Unbekannter Fehler` }
|
||||
}
|
||||
|
||||
return { success: true, vocabulary: data.vocabulary || [] }
|
||||
} catch (e) {
|
||||
return { success: false, vocabulary: [], error: `Seite ${pageIndex + 1}: ${e instanceof Error ? e.message : 'Netzwerkfehler'}` }
|
||||
}
|
||||
}
|
||||
|
||||
const processSelectedPages = async () => {
|
||||
if (!session || selectedPages.length === 0) return
|
||||
|
||||
const pagesToProcess = [...selectedPages].sort((a, b) => a - b)
|
||||
|
||||
setIsExtracting(true)
|
||||
setProcessingErrors([])
|
||||
setSuccessfulPages([])
|
||||
setFailedPages([])
|
||||
setProcessingQueue(pagesToProcess)
|
||||
setVocabulary([])
|
||||
|
||||
setActiveTab('vocabulary')
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
const errors: string[] = []
|
||||
const successful: number[] = []
|
||||
const failed: number[] = []
|
||||
|
||||
for (let i = 0; i < pagesToProcess.length; i++) {
|
||||
const pageIndex = pagesToProcess[i]
|
||||
setCurrentlyProcessingPage(pageIndex + 1)
|
||||
setExtractionStatus(`Verarbeite Seite ${pageIndex + 1} von ${pagesToProcess.length}... (kann 30-60 Sekunden dauern)`)
|
||||
|
||||
const result = await processSinglePage(pageIndex, ipaMode, syllableMode)
|
||||
|
||||
if (result.success) {
|
||||
successful.push(pageIndex + 1)
|
||||
setSuccessfulPages([...successful])
|
||||
setVocabulary(prev => [...prev, ...result.vocabulary])
|
||||
setExtractionStatus(`Seite ${pageIndex + 1} fertig: ${result.vocabulary.length} Vokabeln gefunden`)
|
||||
} else {
|
||||
failed.push(pageIndex + 1)
|
||||
setFailedPages([...failed])
|
||||
if (result.error) {
|
||||
errors.push(result.error)
|
||||
setProcessingErrors([...errors])
|
||||
}
|
||||
setExtractionStatus(`Seite ${pageIndex + 1} fehlgeschlagen`)
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
|
||||
setCurrentlyProcessingPage(null)
|
||||
setProcessingQueue([])
|
||||
setIsExtracting(false)
|
||||
|
||||
if (successful.length === pagesToProcess.length) {
|
||||
setExtractionStatus(`Fertig! Alle ${successful.length} Seiten verarbeitet.`)
|
||||
} else if (successful.length > 0) {
|
||||
setExtractionStatus(`${successful.length} von ${pagesToProcess.length} Seiten verarbeitet. ${failed.length} fehlgeschlagen.`)
|
||||
} else {
|
||||
setExtractionStatus(`Alle Seiten fehlgeschlagen.`)
|
||||
}
|
||||
|
||||
// Reload thumbnails for processed pages (server may have rotated them)
|
||||
if (successful.length > 0 && session) {
|
||||
const updatedThumbs = [...pagesThumbnails]
|
||||
for (const pageNum of successful) {
|
||||
const idx = pageNum - 1
|
||||
try {
|
||||
const thumbRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/pdf-thumbnail/${idx}?hires=true&t=${Date.now()}`)
|
||||
if (thumbRes.ok) {
|
||||
const blob = await thumbRes.blob()
|
||||
if (updatedThumbs[idx]) URL.revokeObjectURL(updatedThumbs[idx])
|
||||
updatedThumbs[idx] = URL.createObjectURL(blob)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to refresh thumbnail for page ${pageNum}`)
|
||||
}
|
||||
}
|
||||
setPagesThumbnails(updatedThumbs)
|
||||
}
|
||||
|
||||
setSession(prev => prev ? { ...prev, status: 'extracted' } : null)
|
||||
}
|
||||
|
||||
const togglePageSelection = (pageIndex: number) => {
|
||||
setSelectedPages(prev =>
|
||||
prev.includes(pageIndex)
|
||||
? prev.filter(p => p !== pageIndex)
|
||||
: [...prev, pageIndex].sort((a, b) => a - b)
|
||||
)
|
||||
}
|
||||
|
||||
const selectAllPages = () => setSelectedPages(
|
||||
Array.from({ length: pdfPageCount }, (_, i) => i).filter(p => !excludedPages.includes(p))
|
||||
)
|
||||
const selectNoPages = () => setSelectedPages([])
|
||||
|
||||
const excludePage = (pageIndex: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setExcludedPages(prev => [...prev, pageIndex])
|
||||
setSelectedPages(prev => prev.filter(p => p !== pageIndex))
|
||||
}
|
||||
|
||||
const restoreExcludedPages = () => {
|
||||
setExcludedPages([])
|
||||
}
|
||||
|
||||
const runOcrComparison = async (pageIndex: number) => {
|
||||
if (!session) return
|
||||
|
||||
setOcrComparePageIndex(pageIndex)
|
||||
setShowOcrComparison(true)
|
||||
setIsComparingOcr(true)
|
||||
setOcrCompareError(null)
|
||||
setOcrCompareResult(null)
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/compare-ocr/${pageIndex}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setOcrCompareResult(data)
|
||||
} catch (e) {
|
||||
setOcrCompareError(e instanceof Error ? e.message : 'Vergleich fehlgeschlagen')
|
||||
} finally {
|
||||
setIsComparingOcr(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateVocabularyEntry = (id: string, field: string, value: string) => {
|
||||
setVocabulary(prev => prev.map(v => {
|
||||
if (v.id !== id) return v
|
||||
if (field === 'english' || field === 'german' || field === 'example_sentence' || field === 'word_type') {
|
||||
return { ...v, [field]: value }
|
||||
}
|
||||
return { ...v, extras: { ...(v.extras || {}), [field]: value } }
|
||||
}))
|
||||
}
|
||||
|
||||
const addExtraColumn = (sourcePage: number) => {
|
||||
const label = prompt('Spaltenname:')
|
||||
if (!label || !label.trim()) return
|
||||
const key = `extra_${Date.now()}`
|
||||
setPageExtraColumns(prev => ({
|
||||
...prev,
|
||||
[sourcePage]: [...(prev[sourcePage] || []), { key, label: label.trim() }],
|
||||
}))
|
||||
}
|
||||
|
||||
const removeExtraColumn = (sourcePage: number, key: string) => {
|
||||
setPageExtraColumns(prev => ({
|
||||
...prev,
|
||||
[sourcePage]: (prev[sourcePage] || []).filter(c => c.key !== key),
|
||||
}))
|
||||
setVocabulary(prev => prev.map(v => {
|
||||
if (!v.extras || !(key in v.extras)) return v
|
||||
const { [key]: _, ...rest } = v.extras
|
||||
return { ...v, extras: rest }
|
||||
}))
|
||||
}
|
||||
|
||||
const getExtraColumnsForPage = (sourcePage: number): ExtraColumn[] => {
|
||||
const global = pageExtraColumns[0] || []
|
||||
const pageSpecific = pageExtraColumns[sourcePage] || []
|
||||
return [...global, ...pageSpecific]
|
||||
}
|
||||
|
||||
const getAllExtraColumns = (): ExtraColumn[] => {
|
||||
const seen = new Set<string>()
|
||||
const result: ExtraColumn[] = []
|
||||
for (const cols of Object.values(pageExtraColumns)) {
|
||||
for (const col of cols) {
|
||||
if (!seen.has(col.key)) {
|
||||
seen.add(col.key)
|
||||
result.push(col)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const deleteVocabularyEntry = (id: string) => {
|
||||
setVocabulary(prev => prev.filter(v => v.id !== id))
|
||||
}
|
||||
|
||||
const toggleVocabularySelection = (id: string) => {
|
||||
setVocabulary(prev => prev.map(v =>
|
||||
v.id === id ? { ...v, selected: !v.selected } : v
|
||||
))
|
||||
}
|
||||
|
||||
const toggleAllSelection = () => {
|
||||
const allSelected = vocabulary.every(v => v.selected)
|
||||
setVocabulary(prev => prev.map(v => ({ ...v, selected: !allSelected })))
|
||||
}
|
||||
|
||||
const addVocabularyEntry = (atIndex?: number) => {
|
||||
const newEntry: VocabularyEntry = {
|
||||
id: `new-${Date.now()}`,
|
||||
english: '',
|
||||
german: '',
|
||||
example_sentence: '',
|
||||
selected: true
|
||||
}
|
||||
setVocabulary(prev => {
|
||||
if (atIndex === undefined) {
|
||||
return [...prev, newEntry]
|
||||
}
|
||||
const newList = [...prev]
|
||||
newList.splice(atIndex, 0, newEntry)
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const saveVocabulary = async () => {
|
||||
if (!session) return
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/vocabulary`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vocabulary }),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to save vocabulary:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const generateWorksheet = async () => {
|
||||
if (!session) return
|
||||
if (selectedFormat === 'standard' && selectedTypes.length === 0) return
|
||||
|
||||
setIsGenerating(true)
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
try {
|
||||
await saveVocabulary()
|
||||
|
||||
let res: Response
|
||||
|
||||
if (selectedFormat === 'nru') {
|
||||
res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/generate-nru`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: worksheetTitle || session.name,
|
||||
include_solutions: includeSolutions,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worksheet_types: selectedTypes,
|
||||
title: worksheetTitle || session.name,
|
||||
include_solutions: includeSolutions,
|
||||
line_height: lineHeight,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setWorksheetId(data.worksheet_id || data.id)
|
||||
setActiveTab('export')
|
||||
completeActivity({ vocabCount: vocabulary.length })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate worksheet:', error)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadPDF = (type: 'worksheet' | 'solution') => {
|
||||
if (!worksheetId) return
|
||||
const API_BASE = getApiBase()
|
||||
const endpoint = type === 'worksheet' ? 'pdf' : 'solution'
|
||||
window.open(`${API_BASE}/api/v1/vocab/worksheets/${worksheetId}/${endpoint}`, '_blank')
|
||||
}
|
||||
|
||||
const toggleWorksheetType = (type: WorksheetType) => {
|
||||
setSelectedTypes(prev =>
|
||||
prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]
|
||||
)
|
||||
}
|
||||
|
||||
const resumeSession = async (existingSession: Session) => {
|
||||
setError(null)
|
||||
setExtractionStatus('Session wird geladen...')
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
try {
|
||||
const sessionRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${existingSession.id}`)
|
||||
if (!sessionRes.ok) throw new Error('Session nicht gefunden')
|
||||
const sessionData = await sessionRes.json()
|
||||
setSession(sessionData)
|
||||
setWorksheetTitle(sessionData.name)
|
||||
|
||||
if (sessionData.status === 'extracted' || sessionData.status === 'completed') {
|
||||
const vocabRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${existingSession.id}/vocabulary`)
|
||||
if (vocabRes.ok) {
|
||||
const vocabData = await vocabRes.json()
|
||||
setVocabulary(vocabData.vocabulary || [])
|
||||
}
|
||||
setActiveTab('vocabulary')
|
||||
setExtractionStatus('')
|
||||
} else if (sessionData.status === 'pending') {
|
||||
setActiveTab('upload')
|
||||
setExtractionStatus('Diese Session hat noch keine Vokabeln. Bitte laden Sie ein Dokument hoch.')
|
||||
} else {
|
||||
setActiveTab('vocabulary')
|
||||
setExtractionStatus('')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to resume session:', error)
|
||||
setError(error instanceof Error ? error.message : 'Fehler beim Laden der Session')
|
||||
setExtractionStatus('')
|
||||
}
|
||||
}
|
||||
|
||||
const resetSession = async () => {
|
||||
setSession(null)
|
||||
setSessionName('')
|
||||
setVocabulary([])
|
||||
setUploadedImage(null)
|
||||
setWorksheetId(null)
|
||||
setSelectedDocumentId(null)
|
||||
setDirectFile(null)
|
||||
setDirectFilePreview(null)
|
||||
setShowFullPreview(false)
|
||||
setPdfPageCount(0)
|
||||
setSelectedPages([])
|
||||
setPagesThumbnails([])
|
||||
setExcludedPages([])
|
||||
setActiveTab('upload')
|
||||
setError(null)
|
||||
setExtractionStatus('')
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions`)
|
||||
if (res.ok) {
|
||||
const sessions = await res.json()
|
||||
setExistingSessions(sessions)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to reload sessions:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSession = async (sessionId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!confirm('Session wirklich loeschen? Diese Aktion kann nicht rueckgaengig gemacht werden.')) {
|
||||
return
|
||||
}
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (res.ok) {
|
||||
setExistingSessions(prev => prev.filter(s => s.id !== sessionId))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete session:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Reprocess all successful pages with new IPA/syllable modes
|
||||
const reprocessPages = (ipa: IpaMode, syllable: SyllableMode) => {
|
||||
if (!session || successfulPages.length === 0) return
|
||||
|
||||
setIsExtracting(true)
|
||||
setExtractionStatus('Verarbeite mit neuen Einstellungen...')
|
||||
const pagesToReprocess = successfulPages.map(p => p - 1)
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
;(async () => {
|
||||
const allVocab: VocabularyEntry[] = []
|
||||
for (const pageIndex of pagesToReprocess) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/process-single-page/${pageIndex}?ipa_mode=${ipa}&syllable_mode=${syllable}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ocr_prompts: ocrPrompts }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.vocabulary) allVocab.push(...data.vocabulary)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setVocabulary(allVocab)
|
||||
setIsExtracting(false)
|
||||
setExtractionStatus(`${allVocab.length} Vokabeln mit neuen Einstellungen`)
|
||||
})()
|
||||
}
|
||||
|
||||
return {
|
||||
// Mounted
|
||||
mounted,
|
||||
// Theme
|
||||
isDark, glassCard, glassInput,
|
||||
// Tab
|
||||
activeTab, setActiveTab,
|
||||
// Session
|
||||
session, sessionName, setSessionName, isCreatingSession, error, setError, extractionStatus,
|
||||
// Existing sessions
|
||||
existingSessions, isLoadingSessions,
|
||||
// Documents
|
||||
storedDocuments, selectedDocumentId, setSelectedDocumentId,
|
||||
// Direct file
|
||||
directFile, setDirectFile, directFilePreview, showFullPreview, setShowFullPreview, directFileInputRef,
|
||||
// PDF pages
|
||||
pdfPageCount, selectedPages, pagesThumbnails, isLoadingThumbnails, excludedPages,
|
||||
// Extra columns
|
||||
pageExtraColumns,
|
||||
// Upload
|
||||
uploadedImage, isExtracting,
|
||||
// Vocabulary
|
||||
vocabulary,
|
||||
// Worksheet
|
||||
selectedTypes, worksheetTitle, setWorksheetTitle,
|
||||
includeSolutions, setIncludeSolutions,
|
||||
lineHeight, setLineHeight,
|
||||
selectedFormat, setSelectedFormat,
|
||||
ipaMode, setIpaMode, syllableMode, setSyllableMode,
|
||||
// Export
|
||||
worksheetId, isGenerating,
|
||||
// Processing
|
||||
processingErrors, successfulPages, failedPages, currentlyProcessingPage,
|
||||
// OCR settings
|
||||
ocrPrompts, showSettings, setShowSettings,
|
||||
// QR
|
||||
showQRModal, setShowQRModal, uploadSessionId,
|
||||
mobileUploadedFiles, selectedMobileFile, setSelectedMobileFile, setMobileUploadedFiles,
|
||||
// OCR Comparison
|
||||
showOcrComparison, setShowOcrComparison,
|
||||
ocrComparePageIndex, ocrCompareResult, isComparingOcr, ocrCompareError,
|
||||
// Handlers
|
||||
handleDirectFileSelect, startSession, processSelectedPages,
|
||||
togglePageSelection, selectAllPages, selectNoPages, excludePage, restoreExcludedPages,
|
||||
runOcrComparison,
|
||||
updateVocabularyEntry, addExtraColumn, removeExtraColumn,
|
||||
getExtraColumnsForPage, getAllExtraColumns,
|
||||
deleteVocabularyEntry, toggleVocabularySelection, toggleAllSelection, addVocabularyEntry,
|
||||
saveVocabulary, generateWorksheet, downloadPDF, toggleWorksheetType,
|
||||
resumeSession, resetSession, deleteSession,
|
||||
saveOcrPrompts, formatFileSize, reprocessPages,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user