From 816bdd2c4453108fb26c1887b075930b03d0ee68 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Mon, 14 Jul 2025 19:45:35 +0000 Subject: [PATCH] feat(lang): create frontend components to support multiple ocr languages --- frontend/src/components/FileUpload.tsx | 27 +- frontend/src/components/LanguageSelector.tsx | 265 ++++++++++++++++++ .../OcrRetryDialog/OcrRetryDialog.tsx | 88 +++++- frontend/src/components/Upload/UploadZone.tsx | 39 +++ frontend/src/pages/SettingsPage.tsx | 58 +++- frontend/src/services/api.ts | 17 +- 6 files changed, 469 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/LanguageSelector.tsx diff --git a/frontend/src/components/FileUpload.tsx b/frontend/src/components/FileUpload.tsx index 02a80f0..5fa6341 100644 --- a/frontend/src/components/FileUpload.tsx +++ b/frontend/src/components/FileUpload.tsx @@ -3,6 +3,7 @@ import { useDropzone } from 'react-dropzone' import { DocumentArrowUpIcon } from '@heroicons/react/24/outline' import { Document, documentService } from '../services/api' import { useNotifications } from '../contexts/NotificationContext' +import LanguageSelector from './LanguageSelector' interface FileUploadProps { onUploadSuccess: (document: Document) => void @@ -11,6 +12,8 @@ interface FileUploadProps { function FileUpload({ onUploadSuccess }: FileUploadProps) { const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) + const [selectedLanguages, setSelectedLanguages] = useState(['eng']) + const [primaryLanguage, setPrimaryLanguage] = useState('eng') const { addBatchNotification } = useNotifications() const onDrop = useCallback(async (acceptedFiles: File[]) => { @@ -21,7 +24,7 @@ function FileUpload({ onUploadSuccess }: FileUploadProps) { setError(null) try { - const response = await documentService.upload(file) + const response = await documentService.upload(file, selectedLanguages.length > 0 ? selectedLanguages : undefined) onUploadSuccess(response.data) // Trigger success notification @@ -34,7 +37,16 @@ function FileUpload({ onUploadSuccess }: FileUploadProps) { } finally { setUploading(false) } - }, [onUploadSuccess, addBatchNotification]) + }, [onUploadSuccess, addBatchNotification, selectedLanguages]) + + const handleLanguagesChange = (languages: string[], primary?: string) => { + setSelectedLanguages(languages) + if (primary) { + setPrimaryLanguage(primary) + } else if (languages.length > 0) { + setPrimaryLanguage(languages[0]) + } + } const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, @@ -49,7 +61,16 @@ function FileUpload({ onUploadSuccess }: FileUploadProps) { }) return ( -
+
+ {/* Language Selector */} + + + {/* File Upload Area */}
void + maxLanguages?: number + disabled?: boolean + showPrimarySelector?: boolean + className?: string +} + +// Common languages with display names +const COMMON_LANGUAGES: LanguageInfo[] = [ + { code: 'eng', name: 'English', installed: true }, + { code: 'spa', name: 'Spanish', installed: true }, + { code: 'fra', name: 'French', installed: true }, + { code: 'deu', name: 'German', installed: true }, + { code: 'ita', name: 'Italian', installed: true }, + { code: 'por', name: 'Portuguese', installed: true }, + { code: 'rus', name: 'Russian', installed: true }, + { code: 'chi_sim', name: 'Chinese (Simplified)', installed: true }, + { code: 'chi_tra', name: 'Chinese (Traditional)', installed: true }, + { code: 'jpn', name: 'Japanese', installed: true }, + { code: 'kor', name: 'Korean', installed: true }, + { code: 'ara', name: 'Arabic', installed: true }, + { code: 'hin', name: 'Hindi', installed: true }, + { code: 'nld', name: 'Dutch', installed: true }, + { code: 'swe', name: 'Swedish', installed: true }, + { code: 'nor', name: 'Norwegian', installed: true }, + { code: 'dan', name: 'Danish', installed: true }, + { code: 'fin', name: 'Finnish', installed: true }, + { code: 'pol', name: 'Polish', installed: true }, + { code: 'ces', name: 'Czech', installed: true }, + { code: 'hun', name: 'Hungarian', installed: true }, + { code: 'tur', name: 'Turkish', installed: true }, + { code: 'tha', name: 'Thai', installed: true }, + { code: 'vie', name: 'Vietnamese', installed: true }, +] + +function LanguageSelector({ + selectedLanguages, + primaryLanguage, + onLanguagesChange, + maxLanguages = 4, + disabled = false, + showPrimarySelector = true, + className = '', +}: LanguageSelectorProps) { + const [availableLanguages, setAvailableLanguages] = useState(COMMON_LANGUAGES) + const [isOpen, setIsOpen] = useState(false) + + // Auto-set primary language to first selected if not specified + const effectivePrimary = primaryLanguage || selectedLanguages[0] || '' + + const handleLanguageToggle = (languageCode: string) => { + if (disabled) return + + let newLanguages: string[] + let newPrimary = effectivePrimary + + if (selectedLanguages.includes(languageCode)) { + // Remove language + newLanguages = selectedLanguages.filter(lang => lang !== languageCode) + // If removing the primary language, set new primary to first remaining language + if (languageCode === effectivePrimary && newLanguages.length > 0) { + newPrimary = newLanguages[0] + } else if (newLanguages.length === 0) { + newPrimary = '' + } + } else { + // Add language (check max limit) + if (selectedLanguages.length >= maxLanguages) { + return + } + newLanguages = [...selectedLanguages, languageCode] + // If this is the first language, make it primary + if (newLanguages.length === 1) { + newPrimary = languageCode + } + } + + onLanguagesChange(newLanguages, newPrimary) + } + + const handlePrimaryChange = (languageCode: string) => { + if (disabled || !selectedLanguages.includes(languageCode)) return + onLanguagesChange(selectedLanguages, languageCode) + } + + const removeLanguage = (languageCode: string) => { + handleLanguageToggle(languageCode) + } + + const getLanguageName = (code: string) => { + const language = availableLanguages.find(lang => lang.code === code) + return language?.name || code + } + + return ( +
+ {/* Selected Languages Display */} +
+ + + {selectedLanguages.length > 0 ? ( +
+ {selectedLanguages.map((langCode) => ( + + {getLanguageName(langCode)} + {langCode === effectivePrimary && ( + (Primary) + )} + {!disabled && ( + + )} + + ))} +
+ ) : ( +
+ No languages selected. Documents will use default OCR language. +
+ )} +
+ + {/* Language Selector Button */} + {!disabled && ( + + )} + + {/* Dropdown Panel */} + {isOpen && !disabled && ( +
+
+
+ Available Languages +
+ +
+ {availableLanguages + .filter(lang => lang.installed) + .map((language) => { + const isSelected = selectedLanguages.includes(language.code) + const isPrimary = language.code === effectivePrimary + const canSelect = !isSelected && selectedLanguages.length < maxLanguages + + return ( +
+
+ +
+ + {/* Primary selector */} + {isSelected && showPrimarySelector && selectedLanguages.length > 1 && ( + + )} +
+ ) + })} +
+ + {selectedLanguages.length >= maxLanguages && ( +
+ Maximum {maxLanguages} languages allowed for optimal performance. +
+ )} +
+ +
+ +
+
+ )} + + {/* Help Text */} + {selectedLanguages.length > 1 && ( +
+

+ Primary language is processed first for better accuracy. + Multiple languages help with mixed-language documents. +

+
+ )} +
+ ) +} + +export default LanguageSelector \ No newline at end of file diff --git a/frontend/src/components/OcrRetryDialog/OcrRetryDialog.tsx b/frontend/src/components/OcrRetryDialog/OcrRetryDialog.tsx index 4d6f934..a9b6c52 100644 --- a/frontend/src/components/OcrRetryDialog/OcrRetryDialog.tsx +++ b/frontend/src/components/OcrRetryDialog/OcrRetryDialog.tsx @@ -13,6 +13,7 @@ import { } from '@mui/material'; import { Refresh as RefreshIcon, Language as LanguageIcon } from '@mui/icons-material'; import OcrLanguageSelector from '../OcrLanguageSelector'; +import LanguageSelector from '../LanguageSelector'; import { ocrService } from '../../services/api'; interface OcrRetryDialogProps { @@ -38,8 +39,16 @@ const OcrRetryDialog: React.FC = ({ onRetryError, }) => { const [selectedLanguage, setSelectedLanguage] = useState(''); + const [selectedLanguages, setSelectedLanguages] = useState([]); + const [primaryLanguage, setPrimaryLanguage] = useState(''); + const [useMultiLanguage, setUseMultiLanguage] = useState(false); const [retrying, setRetrying] = useState(false); + const handleLanguagesChange = (languages: string[], primary?: string) => { + setSelectedLanguages(languages); + setPrimaryLanguage(primary || languages[0] || ''); + }; + // Simple language code to name mapping for display const getLanguageDisplayName = (langCode: string): string => { const languageNames: Record = { @@ -95,15 +104,32 @@ const OcrRetryDialog: React.FC = ({ try { setRetrying(true); + + // Use multi-language if enabled and languages are selected, otherwise use single language + const languagesToUse = useMultiLanguage && selectedLanguages.length > 0 + ? selectedLanguages + : undefined; + const singleLanguageToUse = !useMultiLanguage && selectedLanguage + ? selectedLanguage + : undefined; + const response = await ocrService.retryWithLanguage( document.id, - selectedLanguage || undefined + singleLanguageToUse, + languagesToUse ); if (response.data.success) { const waitTime = response.data.estimated_wait_minutes || 'Unknown'; - const languageInfo = selectedLanguage ? - ` with language "${getLanguageDisplayName(selectedLanguage)}"` : ''; + let languageInfo = ''; + + if (languagesToUse && languagesToUse.length > 0) { + const langNames = languagesToUse.map(lang => getLanguageDisplayName(lang)); + languageInfo = ` with languages: ${langNames.join(', ')} (Primary: ${getLanguageDisplayName(primaryLanguage)})`; + } else if (singleLanguageToUse) { + languageInfo = ` with language "${getLanguageDisplayName(singleLanguageToUse)}"`; + } + onRetrySuccess( `OCR retry queued for "${document.filename}"${languageInfo}. Estimated wait time: ${waitTime} minutes.` ); @@ -124,6 +150,9 @@ const OcrRetryDialog: React.FC = ({ const handleClose = () => { if (!retrying) { setSelectedLanguage(''); + setSelectedLanguages([]); + setPrimaryLanguage(''); + setUseMultiLanguage(false); onClose(); } }; @@ -170,16 +199,51 @@ const OcrRetryDialog: React.FC = ({ OCR Language Selection - Choose a different language if the previous OCR attempt used the wrong language for this document. + Choose a different language or language combination if the previous OCR attempt failed due to incorrect language settings. - + + {/* Toggle between single and multi-language */} + + + + + + {/* Single Language Selector */} + {!useMultiLanguage && ( + + )} + + {/* Multi-Language Selector */} + {useMultiLanguage && ( + + )} diff --git a/frontend/src/components/Upload/UploadZone.tsx b/frontend/src/components/Upload/UploadZone.tsx index 726d786..3309cba 100644 --- a/frontend/src/components/Upload/UploadZone.tsx +++ b/frontend/src/components/Upload/UploadZone.tsx @@ -32,6 +32,7 @@ import api from '../../services/api'; import { useNotifications } from '../../contexts/NotificationContext'; import LabelSelector from '../Labels/LabelSelector'; import { type LabelData } from '../Labels/Label'; +import LanguageSelector from '../LanguageSelector'; interface UploadedDocument { id: string; @@ -65,6 +66,8 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { const [selectedLabels, setSelectedLabels] = useState([]); const [availableLabels, setAvailableLabels] = useState([]); const [labelsLoading, setLabelsLoading] = useState(false); + const [selectedLanguages, setSelectedLanguages] = useState(['eng']); + const [primaryLanguage, setPrimaryLanguage] = useState('eng'); useEffect(() => { fetchLabels(); @@ -99,6 +102,15 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { } }; + const handleLanguagesChange = (languages: string[], primary?: string) => { + setSelectedLanguages(languages); + if (primary) { + setPrimaryLanguage(primary); + } else if (languages.length > 0) { + setPrimaryLanguage(languages[0]); + } + }; + const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: FileRejection[]) => { setError(''); @@ -151,6 +163,13 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { formData.append('label_ids', JSON.stringify(labelIds)); } + // Add selected languages to the form data + if (selectedLanguages.length > 0) { + selectedLanguages.forEach((lang, index) => { + formData.append(`ocr_languages[${index}]`, lang); + }); + } + try { setFiles(prev => prev.map(f => f.id === fileItem.id @@ -338,6 +357,26 @@ const UploadZone: React.FC = ({ onUploadComplete }) => { )} + {/* Language Selection */} + + + + 🌐 OCR Language Settings + + + Select languages for optimal OCR text recognition + + div': { width: '100%' } }}> + + + + + {/* Label Selection */} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 5e6b6d7..2a1cd76 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -43,6 +43,7 @@ import { Edit as EditIcon, Delete as DeleteIcon, Add as AddIcon, import { useAuth } from '../contexts/AuthContext'; import api, { queueService } from '../services/api'; import OcrLanguageSelector from '../components/OcrLanguageSelector'; +import LanguageSelector from '../components/LanguageSelector'; interface User { id: string; @@ -53,6 +54,9 @@ interface User { interface Settings { ocrLanguage: string; + preferredLanguages: string[]; + primaryLanguage: string; + autoDetectLanguageCombination: boolean; concurrentOcrJobs: number; ocrTimeoutSeconds: number; maxFileSizeMb: number; @@ -188,6 +192,9 @@ const SettingsPage: React.FC = () => { const [tabValue, setTabValue] = useState(0); const [settings, setSettings] = useState({ ocrLanguage: 'eng', + preferredLanguages: ['eng'], + primaryLanguage: 'eng', + autoDetectLanguageCombination: false, concurrentOcrJobs: 4, ocrTimeoutSeconds: 300, maxFileSizeMb: 50, @@ -268,6 +275,9 @@ const SettingsPage: React.FC = () => { const response = await api.get('/settings'); setSettings({ ocrLanguage: response.data.ocr_language || 'eng', + preferredLanguages: response.data.preferred_languages || ['eng'], + primaryLanguage: response.data.primary_language || 'eng', + autoDetectLanguageCombination: response.data.auto_detect_language_combination || false, concurrentOcrJobs: response.data.concurrent_ocr_jobs || 4, ocrTimeoutSeconds: response.data.ocr_timeout_seconds || 300, maxFileSizeMb: response.data.max_file_size_mb || 50, @@ -430,6 +440,20 @@ const SettingsPage: React.FC = () => { handleSettingsChange('searchResultsPerPage', event.target.value); }; + const handleLanguagesChange = (languages: string[], primary?: string) => { + // Update multiple fields at once + const updates = { + preferredLanguages: languages, + primaryLanguage: primary || languages[0] || 'eng', + ocrLanguage: primary || languages[0] || 'eng', // Backward compatibility + }; + + // Update all language-related settings + Object.entries(updates).forEach(([key, value]) => { + handleSettingsChange(key as keyof Settings, value); + }); + }; + const fetchOcrStatus = async (): Promise => { try { const response = await queueService.getOcrStatus(); @@ -521,14 +545,34 @@ const SettingsPage: React.FC = () => { - - handleSettingsChange('ocrLanguage', language)} - disabled={loading} - showCurrentIndicator={false} - helperText="Default language for OCR text extraction from your documents" + + + Configure languages for OCR text extraction. Multiple languages help with mixed-language documents. + + div': { width: '100%' } }}> + + + + + handleSettingsChange('autoDetectLanguageCombination', e.target.checked)} + disabled={loading} + /> + } + label="Auto-detect language combinations" /> + + Automatically suggest optimal language combinations based on document content analysis + { + upload: (file: File, languages?: string[]) => { const formData = new FormData() formData.append('file', file) + + // Add multiple languages if provided + if (languages && languages.length > 0) { + languages.forEach((lang, index) => { + formData.append(`ocr_languages[${index}]`, lang) + }) + } + return api.post('/documents', formData, { headers: { 'Content-Type': 'multipart/form-data', @@ -417,6 +425,7 @@ export interface AvailableLanguagesResponse { export interface RetryOcrRequest { language?: string + languages?: string[] } export const queueService = { @@ -450,9 +459,11 @@ export const ocrService = { return api.get('/ocr/health') }, - retryWithLanguage: (documentId: string, language?: string) => { + retryWithLanguage: (documentId: string, language?: string, languages?: string[]) => { const data: RetryOcrRequest = {} - if (language) { + if (languages && languages.length > 0) { + data.languages = languages + } else if (language) { data.language = language } return api.post(`/documents/${documentId}/retry-ocr`, data)