mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
feat(admin): add tessdata language management for OCR and download support (#5519)
# Description of Changes ### What was changed - Added new admin-only API endpoints to: - List installed tessdata OCR languages - Fetch available tessdata languages from the official Tesseract repository - Download selected tessdata language files directly into the configured tessdata directory - Implemented server-side validation, safe language name handling, and directory writability checks. - Extended the Admin Advanced Settings UI to: - Display installed tessdata languages - Show available remote languages not yet installed - Allow selecting and downloading additional languages via a multi-select UI - Gracefully fall back to manual download links when the tessdata directory is not writable - Added new i18n strings for all related UI states (loading, success, error, permission warnings). ### Why the change was made - Managing OCR languages previously required manual filesystem interaction. - This change improves usability for administrators by enabling in-app management of tessdata languages while maintaining security constraints. - The writable directory check and manual fallback ensure compatibility with restricted or containerized environments. <img width="1282" height="832" alt="image" src="https://github.com/user-attachments/assets/aa958730-0ffb-4fd6-9af8-87c527a476e4" /> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Copilot <[email protected]>
This commit is contained in:
+227
-2
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Accordion, TextInput } from '@mantine/core';
|
||||
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Accordion, TextInput, MultiSelect } from '@mantine/core';
|
||||
import { alert } from '@app/components/toast';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
@@ -9,6 +9,7 @@ import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface AdvancedSettingsData {
|
||||
enableAlphaFunctionality?: boolean;
|
||||
@@ -173,6 +174,166 @@ export default function AdminAdvancedSection() {
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
|
||||
const [tessdataLanguages, setTessdataLanguages] = useState<string[]>([]);
|
||||
const [remoteTessdataLanguages, setRemoteTessdataLanguages] = useState<string[]>([]);
|
||||
const [tessdataDirWritable, setTessdataDirWritable] = useState<boolean>(true);
|
||||
const [manualDownloadLinks, setManualDownloadLinks] = useState<string[]>([]);
|
||||
const [tessdataLanguagesLoading, setTessdataLanguagesLoading] = useState(false);
|
||||
const [downloadLanguagesLoading, setDownloadLanguagesLoading] = useState(false);
|
||||
const [selectedDownloadLanguages, setSelectedDownloadLanguages] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginEnabled) return;
|
||||
|
||||
const fetchTessdataLanguages = async () => {
|
||||
setTessdataLanguagesLoading(true);
|
||||
try {
|
||||
const { data } = await apiClient.get<{ installed: string[]; available: string[]; writable?: boolean }>('/api/v1/ui-data/tessdata-languages', {
|
||||
suppressErrorToast: true
|
||||
});
|
||||
const installed = data.installed || [];
|
||||
const available = data.available || [];
|
||||
setTessdataLanguages(installed);
|
||||
setRemoteTessdataLanguages(available.filter((lang) => !installed.includes(lang)));
|
||||
setTessdataDirWritable(data.writable !== false);
|
||||
setManualDownloadLinks([]);
|
||||
} catch (error) {
|
||||
console.error('[AdminAdvancedSection] Failed to load tessdata languages', error);
|
||||
setTessdataLanguages([]);
|
||||
setRemoteTessdataLanguages([]);
|
||||
setTessdataDirWritable(true);
|
||||
setManualDownloadLinks([]);
|
||||
} finally {
|
||||
setTessdataLanguagesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTessdataLanguages();
|
||||
}, [loginEnabled]);
|
||||
|
||||
const refreshTessdataWithRetry = async (retries = 3, delayMs = 400) => {
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const { data } = await apiClient.get<{ installed: string[]; available: string[]; writable?: boolean }>(
|
||||
'/api/v1/ui-data/tessdata-languages',
|
||||
{ suppressErrorToast: true }
|
||||
);
|
||||
const installed = data.installed || [];
|
||||
const available = data.available || [];
|
||||
setTessdataLanguages(installed);
|
||||
setRemoteTessdataLanguages(available.filter((lang) => !installed.includes(lang)));
|
||||
setTessdataDirWritable(data.writable !== false);
|
||||
setManualDownloadLinks([]);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt === retries - 1) {
|
||||
console.error('[AdminAdvancedSection] Retry refresh tessdata failed', err);
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const safeLangRegex = useMemo(() => new RegExp('[^A-Za-z0-9_+\\-]', 'g'), []);
|
||||
|
||||
const handleDownloadTessdataLanguages = async () => {
|
||||
if (!loginEnabled) return;
|
||||
if (selectedDownloadLanguages.length === 0) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('admin.settings.advanced.tessdataDir.downloadMissingTitle', 'No language selected'),
|
||||
body: t('admin.settings.advanced.tessdataDir.downloadMissingBody', 'Please select at least one language to download.'),
|
||||
expandable: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Ensure selection is a subset of remote languages to prevent invalid requests
|
||||
const remoteSet = new Set(remoteTessdataLanguages);
|
||||
const invalidSelection = selectedDownloadLanguages.filter((lang) => !remoteSet.has(lang));
|
||||
if (invalidSelection.length > 0) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('admin.settings.advanced.tessdataDir.downloadInvalidTitle', 'Invalid selection'),
|
||||
body: t(
|
||||
'admin.settings.advanced.tessdataDir.downloadInvalidBody',
|
||||
'Some selected languages are not available to download. Please refresh and choose from the list.'
|
||||
),
|
||||
expandable: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setDownloadLanguagesLoading(true);
|
||||
try {
|
||||
await apiClient.post('/api/v1/ui-data/tessdata/download', { languages: selectedDownloadLanguages }, {
|
||||
suppressErrorToast: true
|
||||
});
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('admin.settings.advanced.tessdataDir.downloadSuccessTitle', 'Languages downloaded'),
|
||||
body: t('admin.settings.advanced.tessdataDir.downloadSuccessBody', 'The selected tessdata languages have been saved.'),
|
||||
});
|
||||
// Refresh installed list with retry in case filesystem sync is delayed
|
||||
await refreshTessdataWithRetry();
|
||||
setSelectedDownloadLanguages([]);
|
||||
setManualDownloadLinks([]);
|
||||
} catch (error) {
|
||||
console.error('[AdminAdvancedSection] Download tessdata languages failed', error);
|
||||
const response = (error as any)?.response;
|
||||
const status = response?.status;
|
||||
const serverMessage = response?.data?.message;
|
||||
|
||||
if (status === 403) {
|
||||
console.warn('[AdminAdvancedSection] Tessdata directory not writable, falling back to manual download:', serverMessage);
|
||||
setTessdataDirWritable(false);
|
||||
setManualDownloadLinks(
|
||||
selectedDownloadLanguages.map((lang) => {
|
||||
const safeLang = lang.replace(safeLangRegex, '');
|
||||
return `https://raw.githubusercontent.com/tesseract-ocr/tessdata/main/${safeLang}.traineddata`;
|
||||
})
|
||||
);
|
||||
const message = t('admin.settings.advanced.tessdataDir.downloadErrorPermission', {
|
||||
defaultValue:
|
||||
'Tessdata directory is not writable: {{message}}. Please choose a writable directory (e.g. under the application data folder) or adjust permissions.',
|
||||
message: serverMessage ?? settings.tessdataDir ?? 'unknown location',
|
||||
});
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.settings.advanced.tessdataDir.downloadErrorTitle', 'Download Failed'),
|
||||
body: message,
|
||||
expandable: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let message: string;
|
||||
if (!response) {
|
||||
message = t(
|
||||
'admin.settings.advanced.tessdataDir.downloadErrorNetwork',
|
||||
'Download failed due to a network error. Please check your connection and try again.'
|
||||
);
|
||||
} else if (status >= 500) {
|
||||
message = t(
|
||||
'admin.settings.advanced.tessdataDir.downloadErrorServer',
|
||||
'The server encountered an error while downloading tessdata languages. Please try again later.'
|
||||
);
|
||||
} else {
|
||||
message = t('admin.settings.advanced.tessdataDir.downloadErrorGeneric', {
|
||||
defaultValue: 'Download failed: {{message}}. Please try again later.',
|
||||
message: serverMessage ?? settings.tessdataDir ?? 'unknown location',
|
||||
});
|
||||
}
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.settings.advanced.tessdataDir.downloadErrorTitle', 'Download Failed'),
|
||||
body: message,
|
||||
expandable: false,
|
||||
});
|
||||
} finally {
|
||||
setDownloadLanguagesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
@@ -301,6 +462,7 @@ export default function AdminAdvancedSection() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tessdata Directory */}
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
@@ -315,6 +477,69 @@ export default function AdminAdvancedSection() {
|
||||
placeholder="/usr/share/tessdata"
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
{tessdataLanguagesLoading ? (
|
||||
<Group gap="xs" mt={6}>
|
||||
<Loader size="xs" />
|
||||
<Text size="xs">
|
||||
{t('admin.settings.advanced.tessdataDir.loadingLanguages', 'Loading installed tessdata languages...')}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
{tessdataLanguages.length > 0
|
||||
? `${t('admin.settings.advanced.tessdataDir.installedLanguages', 'Installed tessdata languages')}: ${tessdataLanguages.join(', ')}`
|
||||
: t('admin.settings.advanced.tessdataDir.noLanguages', 'No tessdata languages found in the configured directory')}
|
||||
</Text>
|
||||
)}
|
||||
<Stack gap="xs" mt="sm">
|
||||
<MultiSelect
|
||||
label={t('admin.settings.advanced.tessdataDir.downloadLabel', 'Download additional tessdata languages')}
|
||||
placeholder={t('admin.settings.advanced.tessdataDir.downloadPlaceholder', 'Select languages')}
|
||||
data={remoteTessdataLanguages.map((lang) => ({ value: lang, label: lang }))}
|
||||
searchable
|
||||
disabled={!loginEnabled || remoteTessdataLanguages.length === 0}
|
||||
value={selectedDownloadLanguages}
|
||||
onChange={setSelectedDownloadLanguages}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
nothingFoundMessage={t('admin.settings.advanced.tessdataDir.downloadNothingFound', 'No additional languages found')}
|
||||
/>
|
||||
{!tessdataDirWritable && (
|
||||
<Text size="xs" c="yellow.4">
|
||||
{t(
|
||||
'admin.settings.advanced.tessdataDir.permissionNotice',
|
||||
'The tessdata path is not writable. Downloads will be opened in the browser; please save the .traineddata files manually into the tessdata folder.'
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{!tessdataDirWritable && manualDownloadLinks.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'admin.settings.advanced.tessdataDir.manualLinks',
|
||||
'Manual downloads: click the links and place the files into the tessdata folder.'
|
||||
)}
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
{manualDownloadLinks.map((link) => (
|
||||
<a key={link} href={link} target="_blank" rel="noreferrer" style={{ fontSize: '12px' }}>
|
||||
{link}
|
||||
</a>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={handleDownloadTessdataLanguages}
|
||||
loading={downloadLanguagesLoading}
|
||||
disabled={!loginEnabled || remoteTessdataLanguages.length === 0}
|
||||
>
|
||||
{t('admin.settings.advanced.tessdataDir.downloadButton', 'Download selected languages')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
Reference in New Issue
Block a user