feat(i18n): update translations and improve error handling messages

- Updated site tagline and footer description in multiple languages.
- Enhanced error messages for various scenarios in the API service.
- Added translations for new error codes related to AI features and PDF processing.
- Improved user feedback in the UI components by utilizing i18n for dynamic text.
- Refactored error handling in the API service to map backend error codes to user-friendly messages.
This commit is contained in:
Your Name
2026-04-05 10:12:22 +02:00
parent 8693834230
commit ade7abac46
14 changed files with 607 additions and 88 deletions

View File

@@ -12,7 +12,9 @@
"preview": "vite preview",
"lint": "eslint .",
"test": "vitest run",
"seo:generate": "node scripts/merge-keywords.mjs && node scripts/generate-seo-assets.mjs"
"seo:generate": "node scripts/merge-keywords.mjs && node scripts/generate-seo-assets.mjs",
"lint:i18n": "node scripts/check-i18n-keys.mjs",
"lint:hardcoded": "node scripts/check-hardcoded-text.mjs"
},
"dependencies": {
"@microsoft/clarity": "^1.0.2",

View File

@@ -0,0 +1,127 @@
/**
* check-hardcoded-text.mjs
* Scans shared/layout components for hardcoded English UI strings that should
* be replaced with t() calls.
*
* Heuristic: a JSX text node is flagged when it:
* - contains at least one space (multi-word)
* - is longer than 3 characters
* - starts with an uppercase letter or common English word
* - is NOT already wrapped in {t(...)}
* - is NOT a CSS class, URL, comment, code attribute, or aria-label value
*
* Usage: node scripts/check-hardcoded-text.mjs
* Exit code 1 if any potential hardcoded strings are found.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const ROOT = join(__dirname, '..');
/** Directories to scan */
const SCAN_DIRS = [
join(ROOT, 'src', 'components', 'shared'),
join(ROOT, 'src', 'components', 'layout'),
];
// Collect .tsx files
function* walkFiles(dir) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
yield* walkFiles(full);
} else if (/\.tsx$/.test(entry)) {
yield full;
}
}
}
/**
* Patterns that indicate the string is NOT a hardcoded UI label:
* - Only digits/punctuation
* - Looks like a URL, path, class name, CSS value
* - Already inside {t(…)}
* - Attribute values like className, href, src, id, type, etc.
*/
const SKIP_RE = [
/^[\d\s.,/:%-]+$/, // pure numbers/punctuation
/^https?:\/\//, // URLs
/^\/[a-z]/, // paths
/^[a-z][-a-z0-9]*$/, // single lowercase word (CSS class, attr value)
/^[a-z][a-zA-Z0-9]*=[a-z]/, // key=value attrs
];
function shouldSkip(str) {
const trimmed = str.trim();
if (trimmed.length <= 3) return true;
if (!/\s/.test(trimmed)) return true; // single word
if (!/[A-Z]/.test(trimmed[0])) return true; // doesn't start with uppercase
for (const re of SKIP_RE) if (re.test(trimmed)) return true;
return false;
}
/**
* Find JSX text content that is hardcoded English.
* Strategy: look for lines where text appears between JSX tags but is NOT
* wrapped in a {…} expression.
*
* Pattern: > Some Text Here < (with optional leading whitespace)
*/
const JSX_TEXT_RE = />\s*([A-Z][^<{}\n]{3,}?)\s*</g;
/**
* Also catch string literals used directly as prop values that look like
* display text: title="Some English Text" (but not className, id, etc.)
*/
const DISPLAY_PROP_RE = /(?:title|label|placeholder|aria-label|alt)="([^"]{4,})"/g;
const findings = [];
for (const dir of SCAN_DIRS) {
for (const file of walkFiles(dir)) {
const rel = relative(ROOT, file).replace(/\\/g, '/');
const content = readFileSync(file, 'utf8');
const lines = content.split('\n');
for (const [lineIdx, line] of lines.entries()) {
// Skip comment lines
if (/^\s*\/\//.test(line) || /^\s*\*/.test(line)) continue;
// Skip lines that are already pure t() calls
if (/\{t\(/.test(line)) continue;
// JSX text between tags
let m;
JSX_TEXT_RE.lastIndex = 0;
while ((m = JSX_TEXT_RE.exec(line)) !== null) {
const text = m[1].trim();
if (!shouldSkip(text)) {
findings.push({ file: rel, line: lineIdx + 1, text });
}
}
// Display props with raw English strings
DISPLAY_PROP_RE.lastIndex = 0;
while ((m = DISPLAY_PROP_RE.exec(line)) !== null) {
const text = m[1].trim();
if (!shouldSkip(text)) {
findings.push({ file: rel, line: lineIdx + 1, text: `[prop] ${text}` });
}
}
}
}
}
if (findings.length === 0) {
console.log('✓ No hardcoded UI text found in shared/layout components');
process.exit(0);
} else {
console.warn(`⚠ Found ${findings.length} potential hardcoded string(s):\n`);
for (const { file, line, text } of findings) {
console.warn(` ${file}:${line} → "${text}"`);
}
// Exit 1 to allow failing CI; change to process.exit(0) to make it advisory only
process.exit(1);
}

View File

@@ -0,0 +1,83 @@
/**
* check-i18n-keys.mjs
* Scans all .ts/.tsx files in src/ and verifies every static t('key') call
* exists as a dot-path entry in src/i18n/en.json.
*
* Usage: node scripts/check-i18n-keys.mjs
* Exit code 1 if any missing keys are found.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const ROOT = join(__dirname, '..');
const SRC = join(ROOT, 'src');
const EN_JSON = join(ROOT, 'src', 'i18n', 'en.json');
// Load en.json and build a flat Set of all dot-paths
function flattenKeys(obj, prefix = '') {
const keys = new Set();
for (const [k, v] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
for (const nested of flattenKeys(v, path)) keys.add(nested);
} else {
keys.add(path);
}
}
return keys;
}
const enJson = JSON.parse(readFileSync(EN_JSON, 'utf8'));
const definedKeys = flattenKeys(enJson);
// Collect all .ts/.tsx files under src/
function* walkFiles(dir) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
yield* walkFiles(full);
} else if (/\.(tsx?|jsx?)$/.test(entry) && !entry.endsWith('.d.ts')) {
yield full;
}
}
}
// Extract static string arguments from t('...') or t("...") calls.
// Matches: t('key'), t("key"), t(`key`), useTranslation().t('key'),
// as well as i18n.t('key') patterns.
const T_CALL_RE = /\bt\(\s*(['"`])([^'"`\s]+)\1/g;
const missing = [];
for (const file of walkFiles(SRC)) {
const rel = relative(ROOT, file).replace(/\\/g, '/');
const content = readFileSync(file, 'utf8');
const lines = content.split('\n');
for (const [lineIdx, line] of lines.entries()) {
let match;
T_CALL_RE.lastIndex = 0;
while ((match = T_CALL_RE.exec(line)) !== null) {
const key = match[2];
// Skip dynamic keys (contain ${) or non-string patterns
if (key.includes('${') || key.includes('(')) continue;
if (!definedKeys.has(key)) {
missing.push({ file: rel, line: lineIdx + 1, key });
}
}
}
}
if (missing.length === 0) {
console.log('✓ All t() keys are present in en.json');
process.exit(0);
} else {
console.error(`✗ Found ${missing.length} missing i18n key(s):\n`);
for (const { file, line, key } of missing) {
console.error(` ${file}:${line} → "${key}"`);
}
process.exit(1);
}

View File

@@ -2,49 +2,64 @@ import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowRight, FileText, Layers3 } from 'lucide-react';
const FOOTER_TOOLS = {
interface FooterTool {
slug: string;
i18nKey: string;
isLanding?: boolean;
isComparison?: boolean;
}
const FOOTER_TOOLS: Record<string, FooterTool[]> = {
PDF: [
{ slug: 'pdf-to-word', label: 'PDF to Word' },
{ slug: 'compress-pdf', label: 'Compress PDF' },
{ slug: 'merge-pdf', label: 'Merge PDF' },
{ slug: 'split-pdf', label: 'Split PDF' },
{ slug: 'pdf-to-images', label: 'PDF to Images' },
{ slug: 'protect-pdf', label: 'Protect PDF' },
{ slug: 'watermark-pdf', label: 'Watermark PDF' },
{ slug: 'pdf-editor', label: 'PDF Editor' },
{ slug: 'pdf-to-word', i18nKey: 'tools.pdfToWord.title' },
{ slug: 'compress-pdf', i18nKey: 'tools.compressPdf.title' },
{ slug: 'merge-pdf', i18nKey: 'tools.mergePdf.title' },
{ slug: 'split-pdf', i18nKey: 'tools.splitPdf.title' },
{ slug: 'pdf-to-images', i18nKey: 'tools.pdfToImages.title' },
{ slug: 'protect-pdf', i18nKey: 'tools.protectPdf.title' },
{ slug: 'watermark-pdf', i18nKey: 'tools.watermarkPdf.title' },
{ slug: 'pdf-editor', i18nKey: 'tools.pdfEditor.title' },
],
'Image & Convert': [
{ slug: 'compress-image', label: 'Compress Image' },
{ slug: 'image-converter', label: 'Image Converter' },
{ slug: 'image-resize', label: 'Image Resize' },
{ slug: 'remove-background', label: 'Remove Background' },
{ slug: 'word-to-pdf', label: 'Word to PDF' },
{ slug: 'html-to-pdf', label: 'HTML to PDF' },
{ slug: 'pdf-to-excel', label: 'PDF to Excel' },
{ slug: 'compress-image', i18nKey: 'tools.compressImage.title' },
{ slug: 'image-converter', i18nKey: 'tools.imageConvert.title' },
{ slug: 'image-resize', i18nKey: 'tools.imageResize.title' },
{ slug: 'remove-background', i18nKey: 'tools.removeBg.title' },
{ slug: 'word-to-pdf', i18nKey: 'tools.wordToPdf.title' },
{ slug: 'html-to-pdf', i18nKey: 'tools.htmlToPdf.title' },
{ slug: 'pdf-to-excel', i18nKey: 'tools.pdfToExcel.title' },
],
'AI & Utility': [
{ slug: 'chat-pdf', label: 'Chat with PDF' },
{ slug: 'summarize-pdf', label: 'Summarize PDF' },
{ slug: 'translate-pdf', label: 'Translate PDF' },
{ slug: 'ocr', label: 'OCR' },
{ slug: 'qr-code', label: 'QR Code Generator' },
{ slug: 'video-to-gif', label: 'Video to GIF' },
{ slug: 'word-counter', label: 'Word Counter' },
{ slug: 'chat-pdf', i18nKey: 'tools.chatPdf.title' },
{ slug: 'summarize-pdf', i18nKey: 'tools.summarizePdf.title' },
{ slug: 'translate-pdf', i18nKey: 'tools.translatePdf.title' },
{ slug: 'ocr', i18nKey: 'tools.ocr.title' },
{ slug: 'qr-code', i18nKey: 'tools.qrCode.title' },
{ slug: 'video-to-gif', i18nKey: 'tools.videoToGif.title' },
{ slug: 'word-counter', i18nKey: 'tools.wordCounter.title' },
],
Guides: [
{ slug: 'best-pdf-tools', label: 'Best PDF Tools', isLanding: true },
{ slug: 'free-pdf-tools-online', label: 'Free PDF Tools Online', isLanding: true },
{ slug: 'convert-files-online', label: 'Convert Files Online', isLanding: true },
{ slug: 'best-pdf-tools', i18nKey: 'footer.guides.bestPdfTools', isLanding: true },
{ slug: 'free-pdf-tools-online', i18nKey: 'footer.guides.freePdfToolsOnline', isLanding: true },
{ slug: 'convert-files-online', i18nKey: 'footer.guides.convertFilesOnline', isLanding: true },
],
Comparisons: [
{ slug: 'compress-pdf-vs-ilovepdf', label: 'Dociva vs iLovePDF', isComparison: true },
{ slug: 'merge-pdf-vs-smallpdf', label: 'Dociva vs Smallpdf', isComparison: true },
{ slug: 'pdf-to-word-vs-adobe-acrobat', label: 'Dociva vs Adobe Acrobat', isComparison: true },
{ slug: 'compress-image-vs-tinypng', label: 'Dociva vs TinyPNG', isComparison: true },
{ slug: 'ocr-vs-adobe-scan', label: 'Dociva vs Adobe Scan', isComparison: true },
{ slug: 'compress-pdf-vs-ilovepdf', i18nKey: 'footer.comparisons.compressPdfVsIlovepdf', isComparison: true },
{ slug: 'merge-pdf-vs-smallpdf', i18nKey: 'footer.comparisons.mergePdfVsSmallpdf', isComparison: true },
{ slug: 'pdf-to-word-vs-adobe-acrobat', i18nKey: 'footer.comparisons.pdfToWordVsAdobeAcrobat', isComparison: true },
{ slug: 'compress-image-vs-tinypng', i18nKey: 'footer.comparisons.compressImageVsTinypng', isComparison: true },
{ slug: 'ocr-vs-adobe-scan', i18nKey: 'footer.comparisons.ocrVsAdobeScan', isComparison: true },
],
};
const CATEGORY_KEYS: Record<string, string> = {
'PDF': 'footer.categories.pdf',
'Image & Convert': 'footer.categories.imageConvert',
'AI & Utility': 'footer.categories.aiUtility',
'Guides': 'footer.categories.guides',
'Comparisons': 'footer.categories.comparisons',
};
export default function Footer() {
const { t } = useTranslation();
@@ -63,16 +78,13 @@ export default function Footer() {
{t('common.appName')}
</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
{t('common.siteTagline', 'Online PDF and file workflows')}
{t('common.siteTagline')}
</p>
</div>
</div>
<p className="mt-6 max-w-md text-sm leading-7 text-slate-600 dark:text-slate-300">
{t(
'common.footerDescription',
'Convert, compress, edit, and automate document work in one browser-based workspace built for speed, clarity, and secure processing.'
)}
{t('common.footerDescription')}
</p>
<div className="mt-6 flex flex-wrap gap-3">
@@ -96,16 +108,16 @@ export default function Footer() {
{Object.entries(FOOTER_TOOLS).map(([category, tools]) => (
<div key={category}>
<h3 className="mb-4 text-xs font-bold uppercase tracking-[0.22em] text-slate-500 dark:text-slate-400">
{category}
{t(CATEGORY_KEYS[category] ?? category)}
</h3>
<ul className="space-y-2.5">
{tools.map((tool) => (
<li key={tool.slug}>
<Link
to={(tool as { slug: string; isLanding?: boolean; isComparison?: boolean }).isComparison ? `/compare/${tool.slug}` : (tool as { slug: string; isLanding?: boolean }).isLanding ? `/${tool.slug}` : `/tools/${tool.slug}`}
to={tool.isComparison ? `/compare/${tool.slug}` : tool.isLanding ? `/${tool.slug}` : `/tools/${tool.slug}`}
className="text-sm text-slate-600 transition-colors hover:text-primary-600 dark:text-slate-300 dark:hover:text-primary-400"
>
{tool.label}
{t(tool.i18nKey)}
</Link>
</li>
))}

View File

@@ -110,7 +110,7 @@ export default function Header() {
{t('common.appName')}
</span>
<span className="hidden text-xs font-medium text-slate-500 dark:text-slate-400 sm:block">
{t('common.siteTagline', 'Online PDF and file workflows')}
{t('common.siteTagline')}
</span>
</div>
</Link>
@@ -145,7 +145,7 @@ export default function Header() {
className="hidden items-center gap-2 rounded-full bg-slate-950 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:-translate-y-0.5 hover:bg-primary-600 lg:inline-flex dark:bg-white dark:text-slate-950 dark:hover:bg-primary-300"
>
<Sparkles className="h-4 w-4" />
{t('home.startFree', 'Start Free')}
{t('home.startFree')}
<ArrowRight className="h-3.5 w-3.5" />
</Link>
) : null}

View File

@@ -1,7 +1,8 @@
import { Component, type ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
import { withTranslation, type WithTranslation } from 'react-i18next';
interface Props {
interface Props extends WithTranslation {
children: ReactNode;
fallbackMessage?: string;
}
@@ -10,7 +11,7 @@ interface State {
hasError: boolean;
}
export default class ErrorBoundary extends Component<Props, State> {
class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
@@ -22,6 +23,7 @@ export default class ErrorBoundary extends Component<Props, State> {
};
render() {
const { t } = this.props;
if (this.state.hasError) {
return (
<div className="mx-auto max-w-lg py-16 text-center">
@@ -29,16 +31,16 @@ export default class ErrorBoundary extends Component<Props, State> {
<AlertTriangle className="h-8 w-8 text-red-600 dark:text-red-400" />
</div>
<h2 className="mb-2 text-xl font-semibold text-slate-800 dark:text-slate-200">
{this.props.fallbackMessage || 'Something went wrong'}
{this.props.fallbackMessage || t('common.errors.genericTitle')}
</h2>
<p className="mb-6 text-sm text-slate-500 dark:text-slate-400">
An unexpected error occurred. Please try again.
{t('common.errors.genericDesc')}
</p>
<button
onClick={this.handleReset}
className="rounded-lg bg-primary-600 px-6 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
>
Try Again
{t('common.errors.tryAgain')}
</button>
</div>
);
@@ -46,3 +48,5 @@ export default class ErrorBoundary extends Component<Props, State> {
return this.props.children;
}
}
export default withTranslation()(ErrorBoundary);

View File

@@ -172,10 +172,10 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT
{isUploading ? (
<>
<Clock className="h-5 w-5 animate-spin" />
{t('common.uploading', { defaultValue: 'Uploading...' })}
{t('common.uploading')}
</>
) : (
t('common.convert', { defaultValue: 'Convert' })
t('common.convert')
)}
</button>
</div>
@@ -195,8 +195,8 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT
<div className="flex items-center gap-3">
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
<div>
<h2 className="font-semibold text-green-900 dark:text-green-200">Success!</h2>
<p className="text-sm text-green-700 dark:text-green-300">Your file is ready</p>
<h2 className="font-semibold text-green-900 dark:text-green-200">{t('result.success')}</h2>
<p className="text-sm text-green-700 dark:text-green-300">{t('result.fileReady')}</p>
</div>
</div>
</div>
@@ -208,15 +208,15 @@ export default function ToolTemplate({ config, onGetExtraData, children }: ToolT
<div className="flex items-center gap-3">
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400" />
<div>
<h2 className="font-semibold text-red-900 dark:text-red-200">Error</h2>
<p className="text-sm text-red-700 dark:text-red-300">{error || 'Processing failed'}</p>
<h2 className="font-semibold text-red-900 dark:text-red-200">{t('common.error')}</h2>
<p className="text-sm text-red-700 dark:text-red-300">{error || t('common.errors.processingFailed')}</p>
</div>
</div>
</div>
)}
<button onClick={handleReset} className="btn-secondary w-full">
Process Another
{t('result.processAnother')}
</button>
</div>
)}

View File

@@ -98,7 +98,7 @@ export default function BarcodeGenerator() {
{phase === 'done' && downloadUrl && (
<div className="space-y-4 text-center">
<div className="rounded-2xl bg-white p-6 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<img src={downloadUrl} alt="Barcode" loading="lazy" decoding="async" className="mx-auto max-w-full" width="300" height="100" style={{aspectRatio:'3/1'}} />
<img src={downloadUrl} alt={t('tools.barcode.altText')} loading="lazy" decoding="async" className="mx-auto max-w-full" width="300" height="100" style={{aspectRatio:'3/1'}} />
</div>
<div className="flex gap-3">
<a href={downloadUrl} download className="btn-primary flex-1">{t('common.download')}</a>

View File

@@ -143,7 +143,7 @@ export default function ImageResize() {
type="number"
min="1"
max="10000"
placeholder="e.g. 800"
placeholder={t('tools.imageResize.widthPlaceholder')}
value={width}
onChange={(e) => {
setWidth(e.target.value);
@@ -160,7 +160,7 @@ export default function ImageResize() {
type="number"
min="1"
max="10000"
placeholder="e.g. 600"
placeholder={t('tools.imageResize.heightPlaceholder')}
value={height}
onChange={(e) => {
setHeight(e.target.value);

View File

@@ -113,7 +113,7 @@ export default function QrCodeGenerator() {
{phase === 'done' && result && result.status === 'completed' && downloadUrl && (
<div className="space-y-6 text-center">
<div className="rounded-2xl bg-white p-8 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<img src={downloadUrl} alt="QR Code" loading="lazy" decoding="async" className="mx-auto max-w-[300px] rounded-lg" width={size} height={size} style={{aspectRatio:'1/1'}} />
<img src={downloadUrl} alt={t('tools.qrCode.altText')} loading="lazy" decoding="async" className="mx-auto max-w-[300px] rounded-lg" width={size} height={size} style={{aspectRatio:'1/1'}} />
</div>
<div className="flex gap-3">
<a href={downloadUrl} download={result.filename || 'qrcode.png'}

View File

@@ -1,4 +1,4 @@
{
{
"common": {
"appName": "Dociva",
"tagline": "أدوات ملفات مجانية على الإنترنت",
@@ -35,6 +35,11 @@
"subject": "الموضوع",
"message": "الرسالة",
"name": "الاسم",
"siteTagline": "سير عمل PDF والملفات عبر الإنترنت",
"footerDescription": "حوّل، اضغط، عدّل، وأتمت عمل المستندات في مساحة عمل واحدة تعمل في المتصفح، مصممة للسرعة والوضوح والمعالجة الآمنة.",
"uploading": "جارٍ الرفع...",
"convert": "تحويل",
"sending": "جارٍ الإرسال...",
"errors": {
"fileTooLarge": "حجم الملف كبير جدًا. الحد الأقصى المسموح {{size}} ميجابايت.",
"invalidFileType": "نوع الملف غير صالح. الأنواع المقبولة: {{types}}",
@@ -44,7 +49,19 @@
"rateLimited": "طلبات كثيرة جدًا. يرجى الانتظار لحظة والمحاولة مجددًا.",
"serverError": "حدث خطأ في الخادم. يرجى المحاولة لاحقًا.",
"networkError": "خطأ في الشبكة. يرجى التحقق من اتصالك والمحاولة مرة أخرى.",
"noFileSelected": "لم يتم اختيار ملف. يرجى اختيار ملف للرفع."
"noFileSelected": "لم يتم اختيار ملف. يرجى اختيار ملف للرفع.",
"aiUnavailable": "ميزات الذكاء الاصطناعي غير متاحة مؤقتاً. يرجى المحاولة لاحقاً.",
"aiRateLimited": "خدمة الذكاء الاصطناعي مشغولة حالياً. يرجى المحاولة بعد قليل.",
"aiBudgetExceeded": "تم استنفاد حصة معالجة الذكاء الاصطناعي. يرجى المحاولة لاحقاً.",
"pdfEncrypted": "هذا الـ PDF محمي بكلمة مرور. يرجى إلغاء قفله أولاً.",
"pdfTextEmpty": "لم يُعثر على نص قابل للقراءة في هذا الـ PDF.",
"pdfNoTables": "لم تُعثر على جداول في هذا الـ PDF.",
"taskUnavailable": "الخدمة غير متاحة مؤقتاً. يرجى إعادة المحاولة بعد لحظة.",
"translationFailed": "فشلت خدمة الترجمة. يرجى المحاولة مرة أخرى.",
"invalidInput": "مدخلات غير صالحة. يرجى التحقق من إعداداتك والمحاولة مرة أخرى.",
"genericTitle": "حدث خطأ ما",
"genericDesc": "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى.",
"tryAgain": "حاول مرة أخرى"
}
},
"auth": {
@@ -141,7 +158,31 @@
"feature2Title": "دقة يمكنك الوثوق بها",
"feature2Desc": "احصل على ملفات دقيقة وقابلة للتعديل في ثوانٍ بدون فقدان للجودة.",
"feature3Title": "أمان مدمج",
"feature3Desc": "قم بالوصول إلى ملفاتك بأمان، محمية بتشفير تلقائي."
"feature3Desc": "قم بالوصول إلى ملفاتك بأمان، محمية بتشفير تلقائي.",
"startFree": "ابدأ مجاناً",
"heroBadge": "سير عمل مستندات حديثة",
"statsToolsLabel": "إجمالي الأدوات",
"statsPdfLabel": "سير عمل PDF",
"statsOtherLabel": "الصور والذكاء الاصطناعي والأدوات",
"statsAccessLabel": "نموذج الوصول",
"statsAccessValue": "بدون تسجيل",
"trustSecure": "حذف تلقائي للملفات",
"trustFast": "نتائج في ثوانٍ",
"trust30Tools": "أكثر من 30 أداة مجانية",
"trustNoSignup": "لا حاجة للتسجيل",
"ctaBrowseTools": "تصفح كل الأدوات",
"quickStartLabel": "نقاط انطلاق شائعة",
"heroUploadEyebrow": "ارفع وابدأ",
"heroUploadTitle": "اختر ملفاً وانطلق مباشرة إلى الأداة المناسبة",
"howItWorksLabel": "عملية بسيطة",
"howItWorksTitle": "حوّل وعدّل في ثلاث خطوات بسيطة",
"toolsDirectoryTitle": "اعثر على الأداة المناسبة بسرعة أكبر",
"otherTools": "أدوات أخرى",
"whyChooseLabel": "لماذا Dociva",
"ctaBannerLabel": "ابدأ اليوم",
"ctaBannerTitle": "هل أنت مستعد لتحويل ملفاتك؟",
"ctaBannerSubtitle": "انضم إلى آلاف المستخدمين الذين يحوّلون ويضغطون ويعدّلون ملفاتهم يومياً — مجاناً تماماً.",
"ctaCreateAccount": "إنشاء حساب مجاني"
},
"socialProof": {
"badge": "موثوق من فرق نشطة",
@@ -173,7 +214,11 @@
"أدوات المستندات بالذكاء الاصطناعي — التحدث مع PDF، التلخيص، الترجمة، استخراج الجداول",
"OCR — استخراج النص من الصور وملفات PDF الممسوحة ضوئياً بالعربية والإنجليزية والفرنسية",
"أدوات مساعدة — مولد QR، تحويل فيديو إلى GIF، عداد الكلمات، منظف النصوص"
]
],
"heroTitle": "تمكين إنتاجية المستندات في كل مكان",
"teamTitle": "فريقنا",
"valuesTitle": "قيمنا",
"ctaText": "هل لديك أسئلة؟ تواصل معنا."
},
"contact": {
"metaDescription": "تواصل مع فريق Dociva. أبلغ عن خطأ أو اطلب ميزة جديدة أو أرسل لنا رسالة.",
@@ -194,7 +239,12 @@
"subjectPlaceholder": "الموضوع",
"successMessage": "تم إرسال رسالتك! سنرد عليك قريباً.",
"directEmail": "أو راسلنا مباشرة على",
"responseTime": "نرد عادةً خلال 24-48 ساعة."
"responseTime": "نرد عادةً خلال 24-48 ساعة.",
"emailLabel": "البريد الإلكتروني:",
"phoneLabel": "الهاتف:",
"officeLabel": "المكتب:",
"connectTitle": "تواصل معنا",
"faqTitle": "الأسئلة الشائعة"
},
"privacy": {
"metaDescription": "سياسة الخصوصية لـ Dociva. تعرّف على كيفية تعاملنا مع ملفاتك وبياناتك بشفافية كاملة.",
@@ -643,7 +693,9 @@
"height": "الارتفاع (بكسل)",
"quality": "الجودة",
"lockAspect": "قفل نسبة العرض للارتفاع",
"aspectHint": "أدخل بُعداً واحداً — سيتم حساب الآخر تلقائياً للحفاظ على نسبة العرض للارتفاع."
"aspectHint": "أدخل بُعداً واحداً — سيتم حساب الآخر تلقائياً للحفاظ على نسبة العرض للارتفاع.",
"widthPlaceholder": "مثال: 800",
"heightPlaceholder": "مثال: 600"
},
"imageToSvg": {
"title": "تحويل الصورة إلى SVG",
@@ -989,7 +1041,8 @@
"shortDesc": "إنشاء رمز QR",
"dataLabel": "نص أو رابط",
"dataPlaceholder": "أدخل نصاً أو رابطاً أو أي بيانات...",
"sizeLabel": "الحجم"
"sizeLabel": "الحجم",
"altText": "رمز QR المُولَّد"
},
"htmlToPdf": {
"title": "HTML إلى PDF",
@@ -1124,7 +1177,29 @@
"dataLabel": "بيانات الباركود",
"dataPlaceholder": "أدخل البيانات للترميز...",
"typeLabel": "نوع الباركود",
"formatLabel": "تنسيق الإخراج"
"formatLabel": "تنسيق الإخراج",
"altText": "باركود مُنشأ"
}
},
"footer": {
"categories": {
"pdf": "PDF",
"imageConvert": "الصور والتحويل",
"aiUtility": "الذكاء الاصطناعي والأدوات",
"guides": "الأدلة",
"comparisons": "المقارنات"
},
"guides": {
"bestPdfTools": "أفضل أدوات PDF",
"freePdfToolsOnline": "أدوات PDF مجانية عبر الإنترنت",
"convertFilesOnline": "تحويل الملفات عبر الإنترنت"
},
"comparisons": {
"compressPdfVsIlovepdf": "Dociva مقابل iLovePDF",
"mergePdfVsSmallpdf": "Dociva مقابل Smallpdf",
"pdfToWordVsAdobeAcrobat": "Dociva مقابل Adobe Acrobat",
"compressImageVsTinypng": "Dociva مقابل TinyPNG",
"ocrVsAdobeScan": "Dociva مقابل Adobe Scan"
}
},
"account": {
@@ -1208,7 +1283,10 @@
"newSize": "الحجم الجديد",
"reduction": "نسبة التقليل",
"downloadReady": "ملفك جاهز للتحميل.",
"linkExpiry": "رابط التحميل ينتهي خلال 30 دقيقة."
"linkExpiry": "رابط التحميل ينتهي خلال 30 دقيقة.",
"success": "تم بنجاح!",
"fileReady": "ملفك جاهز",
"processAnother": "معالجة ملف آخر"
},
"downloadGate": {
"title": "سجّل لتحميل ملفك",

View File

@@ -1,4 +1,4 @@
{
{
"common": {
"appName": "Dociva",
"tagline": "Free Online File Tools",
@@ -35,6 +35,11 @@
"subject": "Subject",
"message": "Message",
"name": "Name",
"siteTagline": "Online PDF and file workflows",
"footerDescription": "Convert, compress, edit, and automate document work in one browser-based workspace built for speed, clarity, and secure processing.",
"uploading": "Uploading...",
"convert": "Convert",
"sending": "Sending...",
"errors": {
"fileTooLarge": "File is too large. Maximum size is {{size}}MB.",
"invalidFileType": "Invalid file type. Accepted: {{types}}",
@@ -44,7 +49,19 @@
"rateLimited": "Too many requests. Please wait a moment and try again.",
"serverError": "A server error occurred. Please try again later.",
"networkError": "Network error. Please check your connection and try again.",
"noFileSelected": "No file selected. Please choose a file to upload."
"noFileSelected": "No file selected. Please choose a file to upload.",
"aiUnavailable": "AI features are temporarily unavailable. Please try again later.",
"aiRateLimited": "AI service is currently busy. Please try again shortly.",
"aiBudgetExceeded": "AI processing quota exceeded. Please try again later.",
"pdfEncrypted": "This PDF is password-protected. Please unlock it first.",
"pdfTextEmpty": "No readable text found in this PDF.",
"pdfNoTables": "No tables found in this PDF.",
"taskUnavailable": "Service temporarily unavailable. Please retry in a moment.",
"translationFailed": "Translation service failed. Please try again.",
"invalidInput": "Invalid input. Please check your settings and try again.",
"genericTitle": "Something went wrong",
"genericDesc": "An unexpected error occurred. Please try again.",
"tryAgain": "Try Again"
}
},
"auth": {
@@ -141,7 +158,31 @@
"feature2Title": "Accuracy you can trust",
"feature2Desc": "Get pixel-perfect, editable files in seconds with zero quality loss.",
"feature3Title": "Built-in security",
"feature3Desc": "Access files securely, protected by automatic encryption."
"feature3Desc": "Access files securely, protected by automatic encryption.",
"startFree": "Start Free",
"heroBadge": "Modern document workflows",
"statsToolsLabel": "Total tools",
"statsPdfLabel": "PDF workflows",
"statsOtherLabel": "Image, AI & utility",
"statsAccessLabel": "Access model",
"statsAccessValue": "No signup",
"trustSecure": "Files auto-deleted",
"trustFast": "Results in seconds",
"trust30Tools": "30+ free tools",
"trustNoSignup": "No sign-up needed",
"ctaBrowseTools": "Browse All Tools",
"quickStartLabel": "Popular starting points",
"heroUploadEyebrow": "Upload and start",
"heroUploadTitle": "Choose a file and jump straight into the right tool",
"howItWorksLabel": "Simple process",
"howItWorksTitle": "Convert and edit in three simple steps",
"toolsDirectoryTitle": "Find the right tool faster",
"otherTools": "Other Tools",
"whyChooseLabel": "Why Dociva",
"ctaBannerLabel": "Get started today",
"ctaBannerTitle": "Ready to convert your files?",
"ctaBannerSubtitle": "Join thousands of users who convert, compress, and edit their files every day — completely free.",
"ctaCreateAccount": "Create Free Account"
},
"socialProof": {
"badge": "Trusted by active teams",
@@ -173,7 +214,11 @@
"AI document tools — chat with PDFs, summarize, translate, extract tables",
"OCR — extract text from images and scanned PDFs in English, Arabic, and French",
"Utility tools — QR code generator, video to GIF, word counter, text cleaner"
]
],
"heroTitle": "Empowering Document Productivity Worldwide",
"teamTitle": "Our Team",
"valuesTitle": "Our Values",
"ctaText": "Have questions? Get in touch."
},
"contact": {
"metaDescription": "Contact the Dociva team. Report bugs, request features, or send us a message.",
@@ -194,7 +239,12 @@
"subjectPlaceholder": "Subject",
"successMessage": "Your message has been sent! We'll get back to you soon.",
"directEmail": "Or email us directly at",
"responseTime": "We typically respond within 2448 hours."
"responseTime": "We typically respond within 2448 hours.",
"emailLabel": "Email:",
"phoneLabel": "Phone:",
"officeLabel": "Office:",
"connectTitle": "Connect With Us",
"faqTitle": "FAQ"
},
"privacy": {
"metaDescription": "Privacy policy for Dociva. Learn how we handle your files and data with full transparency.",
@@ -643,7 +693,9 @@
"height": "Height (px)",
"quality": "Quality",
"lockAspect": "Lock aspect ratio",
"aspectHint": "Enter one dimension — the other will auto-calculate to preserve aspect ratio."
"aspectHint": "Enter one dimension — the other will auto-calculate to preserve aspect ratio.",
"widthPlaceholder": "e.g. 800",
"heightPlaceholder": "e.g. 600"
},
"imageToSvg": {
"title": "Image to SVG",
@@ -989,7 +1041,8 @@
"shortDesc": "Generate QR Code",
"dataLabel": "Text or URL",
"dataPlaceholder": "Enter text, URL, or any data...",
"sizeLabel": "Size"
"sizeLabel": "Size",
"altText": "Generated QR Code"
},
"htmlToPdf": {
"title": "HTML to PDF",
@@ -1124,7 +1177,29 @@
"dataLabel": "Barcode Data",
"dataPlaceholder": "Enter data to encode...",
"typeLabel": "Barcode Type",
"formatLabel": "Output Format"
"formatLabel": "Output Format",
"altText": "Generated barcode"
}
},
"footer": {
"categories": {
"pdf": "PDF",
"imageConvert": "Image & Convert",
"aiUtility": "AI & Utility",
"guides": "Guides",
"comparisons": "Comparisons"
},
"guides": {
"bestPdfTools": "Best PDF Tools",
"freePdfToolsOnline": "Free PDF Tools Online",
"convertFilesOnline": "Convert Files Online"
},
"comparisons": {
"compressPdfVsIlovepdf": "Dociva vs iLovePDF",
"mergePdfVsSmallpdf": "Dociva vs Smallpdf",
"pdfToWordVsAdobeAcrobat": "Dociva vs Adobe Acrobat",
"compressImageVsTinypng": "Dociva vs TinyPNG",
"ocrVsAdobeScan": "Dociva vs Adobe Scan"
}
},
"account": {
@@ -1208,7 +1283,10 @@
"newSize": "New Size",
"reduction": "Reduction",
"downloadReady": "Your file is ready for download.",
"linkExpiry": "Download link expires in 30 minutes."
"linkExpiry": "Download link expires in 30 minutes.",
"success": "Success!",
"fileReady": "Your file is ready",
"processAnother": "Process Another"
},
"downloadGate": {
"title": "Sign up to download your file",

View File

@@ -1,4 +1,4 @@
{
{
"common": {
"appName": "Dociva",
"tagline": "Outils de fichiers en ligne gratuits",
@@ -35,6 +35,11 @@
"subject": "Sujet",
"message": "Message",
"name": "Nom",
"siteTagline": "Workflows PDF et fichiers en ligne",
"footerDescription": "Convertissez, compressez, modifiez et automatisez le traitement de documents dans un espace de travail basé sur le navigateur, conçu pour la rapidité, la clarté et le traitement sécurisé.",
"uploading": "Téléchargement en cours...",
"convert": "Convertir",
"sending": "Envoi en cours...",
"errors": {
"fileTooLarge": "Fichier trop volumineux. Taille maximale autorisée : {{size}} Mo.",
"invalidFileType": "Type de fichier non valide. Formats acceptés : {{types}}",
@@ -44,7 +49,19 @@
"rateLimited": "Trop de requêtes. Veuillez attendre un moment et réessayer.",
"serverError": "Une erreur serveur s'est produite. Veuillez réessayer plus tard.",
"networkError": "Erreur réseau. Veuillez vérifier votre connexion et réessayer.",
"noFileSelected": "Aucun fichier sélectionné. Veuillez choisir un fichier à télécharger."
"noFileSelected": "Aucun fichier sélectionné. Veuillez choisir un fichier à télécharger.",
"aiUnavailable": "Les fonctionnalités IA sont temporairement indisponibles. Veuillez réessayer plus tard.",
"aiRateLimited": "Le service IA est actuellement occupé. Veuillez réessayer dans un instant.",
"aiBudgetExceeded": "Quota de traitement IA dépassé. Veuillez réessayer plus tard.",
"pdfEncrypted": "Ce PDF est protégé par un mot de passe. Veuillez d'abord le déverrouiller.",
"pdfTextEmpty": "Aucun texte lisible trouvé dans ce PDF.",
"pdfNoTables": "Aucun tableau trouvé dans ce PDF.",
"taskUnavailable": "Service temporairement indisponible. Veuillez réessayer dans un instant.",
"translationFailed": "Le service de traduction a échoué. Veuillez réessayer.",
"invalidInput": "Entrée non valide. Veuillez vérifier vos paramètres et réessayer.",
"genericTitle": "Une erreur s'est produite",
"genericDesc": "Une erreur inattendue s'est produite. Veuillez réessayer.",
"tryAgain": "Réessayer"
}
},
"auth": {
@@ -141,7 +158,31 @@
"feature2Title": "Une précision de confiance",
"feature2Desc": "Obtenez des fichiers parfaits et modifiables en quelques secondes sans perte de qualité.",
"feature3Title": "Sécurité intégrée",
"feature3Desc": "Accédez aux fichiers en toute sécurité, protégés par un cryptage automatique."
"feature3Desc": "Accédez aux fichiers en toute sécurité, protégés par un cryptage automatique.",
"startFree": "Commencer gratuitement",
"heroBadge": "Workflows de documents modernes",
"statsToolsLabel": "Total des outils",
"statsPdfLabel": "Workflows PDF",
"statsOtherLabel": "Image, IA et utilitaires",
"statsAccessLabel": "Modèle d'accès",
"statsAccessValue": "Sans inscription",
"trustSecure": "Fichiers supprimés automatiquement",
"trustFast": "Résultats en quelques secondes",
"trust30Tools": "30+ outils gratuits",
"trustNoSignup": "Aucune inscription requise",
"ctaBrowseTools": "Parcourir tous les outils",
"quickStartLabel": "Points de départ populaires",
"heroUploadEyebrow": "Déposez et commencez",
"heroUploadTitle": "Choisissez un fichier et accédez directement au bon outil",
"howItWorksLabel": "Processus simple",
"howItWorksTitle": "Convertissez et modifiez en trois étapes simples",
"toolsDirectoryTitle": "Trouvez le bon outil plus rapidement",
"otherTools": "Autres outils",
"whyChooseLabel": "Pourquoi Dociva",
"ctaBannerLabel": "Commencez dès aujourd'hui",
"ctaBannerTitle": "Prêt à convertir vos fichiers ?",
"ctaBannerSubtitle": "Rejoignez des milliers d'utilisateurs qui convertissent, compressent et modifient leurs fichiers chaque jour — complètement gratuit.",
"ctaCreateAccount": "Créer un compte gratuit"
},
"socialProof": {
"badge": "Adopté par des équipes actives",
@@ -173,7 +214,11 @@
"Outils documentaires IA — discuter avec des PDF, résumer, traduire, extraire des tableaux",
"OCR — extraire du texte d'images et de PDF numérisés en anglais, arabe et français",
"Outils utilitaires — générateur de QR code, vidéo vers GIF, compteur de mots, nettoyeur de texte"
]
],
"heroTitle": "Améliorer la productivité documentaire dans le monde entier",
"teamTitle": "Notre équipe",
"valuesTitle": "Nos valeurs",
"ctaText": "Des questions ? Contactez-nous."
},
"contact": {
"metaDescription": "Contactez l'équipe Dociva. Signalez un bug, demandez une fonctionnalité ou envoyez-nous un message.",
@@ -194,7 +239,12 @@
"subjectPlaceholder": "Sujet",
"successMessage": "Votre message a été envoyé ! Nous vous répondrons bientôt.",
"directEmail": "Ou contactez-nous directement à",
"responseTime": "Nous répondons généralement sous 24 à 48 heures."
"responseTime": "Nous répondons généralement sous 24 à 48 heures.",
"emailLabel": "E-mail :",
"phoneLabel": "Téléphone :",
"officeLabel": "Bureau :",
"connectTitle": "Connectez-vous avec nous",
"faqTitle": "FAQ"
},
"privacy": {
"metaDescription": "Politique de confidentialité de Dociva. Découvrez comment nous gérons vos fichiers et données en toute transparence.",
@@ -643,7 +693,9 @@
"height": "Hauteur (px)",
"quality": "Qualité",
"lockAspect": "Verrouiller le rapport d'aspect",
"aspectHint": "Entrez une dimension — l'autre sera calculée automatiquement pour préserver le rapport d'aspect."
"aspectHint": "Entrez une dimension — l'autre sera calculée automatiquement pour préserver le rapport d'aspect.",
"widthPlaceholder": "ex. 800",
"heightPlaceholder": "ex. 600"
},
"imageToSvg": {
"title": "Image vers SVG",
@@ -989,7 +1041,8 @@
"shortDesc": "Générer un code QR",
"dataLabel": "Texte ou URL",
"dataPlaceholder": "Entrez du texte, une URL ou des données...",
"sizeLabel": "Taille"
"sizeLabel": "Taille",
"altText": "QR Code généré"
},
"htmlToPdf": {
"title": "HTML vers PDF",
@@ -1124,7 +1177,29 @@
"dataLabel": "Données du code-barres",
"dataPlaceholder": "Entrez les données à encoder...",
"typeLabel": "Type de code-barres",
"formatLabel": "Format de sortie"
"formatLabel": "Format de sortie",
"altText": "Code-barres généré"
}
},
"footer": {
"categories": {
"pdf": "PDF",
"imageConvert": "Image & Conversion",
"aiUtility": "IA & Utilitaires",
"guides": "Guides",
"comparisons": "Comparaisons"
},
"guides": {
"bestPdfTools": "Meilleurs outils PDF",
"freePdfToolsOnline": "Outils PDF gratuits en ligne",
"convertFilesOnline": "Convertir des fichiers en ligne"
},
"comparisons": {
"compressPdfVsIlovepdf": "Dociva vs iLovePDF",
"mergePdfVsSmallpdf": "Dociva vs Smallpdf",
"pdfToWordVsAdobeAcrobat": "Dociva vs Adobe Acrobat",
"compressImageVsTinypng": "Dociva vs TinyPNG",
"ocrVsAdobeScan": "Dociva vs Adobe Scan"
}
},
"account": {
@@ -1208,7 +1283,10 @@
"newSize": "Nouvelle taille",
"reduction": "Réduction",
"downloadReady": "Votre fichier est prêt à être téléchargé.",
"linkExpiry": "Le lien de téléchargement expire dans 30 minutes."
"linkExpiry": "Le lien de téléchargement expire dans 30 minutes.",
"success": "Succès !",
"fileReady": "Votre fichier est prêt",
"processAnother": "Traiter un autre fichier"
},
"downloadGate": {
"title": "Inscrivez-vous pour télécharger votre fichier",

View File

@@ -1,4 +1,5 @@
import axios, { type InternalAxiosRequestConfig } from 'axios';
import i18n from '@/i18n';
const CSRF_COOKIE_NAME = 'csrf_token';
const CSRF_HEADER_NAME = 'X-CSRF-Token';
@@ -160,21 +161,27 @@ api.interceptors.response.use(
}
if (error.response.status === 429) {
return Promise.reject(new Error('Too many requests. Please wait a moment and try again.'));
return Promise.reject(new Error(i18n.t('common.errors.rateLimited')));
}
const responseData = error.response.data;
const errorCode: string | undefined = responseData?.error_code;
if (errorCode) {
const mapped = resolveErrorCode(errorCode);
if (mapped) return Promise.reject(new Error(mapped));
}
const message =
responseData?.user_message ||
responseData?.error ||
responseData?.message ||
(typeof responseData === 'string' && responseData.trim()
? responseData.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
: null) ||
`Request failed (${error.response.status}).`;
i18n.t('common.errors.serverError');
return Promise.reject(new Error(message));
}
if (error.request) {
return Promise.reject(new Error('Network error. Please check your connection.'));
return Promise.reject(new Error(i18n.t('common.errors.networkError')));
}
return Promise.reject(error);
}
@@ -251,7 +258,57 @@ function isTaskErrorPayload(value: unknown): value is TaskErrorPayload {
return Boolean(value) && typeof value === 'object';
}
/**
* Maps a backend error_code to a fully translated message via i18n.
* Returns null when no specific mapping exists (caller should fall back to user_message or generic).
*/
export function resolveErrorCode(errorCode: string): string | null {
const map: Record<string, string> = {
TASK_FAILURE: i18n.t('common.errors.processingFailed'),
CELERY_NOT_REGISTERED: i18n.t('common.errors.taskUnavailable'),
OPENROUTER_UNAUTHORIZED: i18n.t('common.errors.aiUnavailable'),
OPENROUTER_RATE_LIMIT: i18n.t('common.errors.aiRateLimited'),
OPENROUTER_INSUFFICIENT_CREDITS: i18n.t('common.errors.aiRateLimited'),
OPENROUTER_SERVER_ERROR: i18n.t('common.errors.serverError'),
OPENROUTER_CONNECTION_ERROR: i18n.t('common.errors.networkError'),
OPENROUTER_TIMEOUT: i18n.t('common.errors.serverError'),
OPENROUTER_MISSING_API_KEY: i18n.t('common.errors.aiUnavailable'),
OPENROUTER_EMPTY_RESPONSE: i18n.t('common.errors.aiUnavailable'),
OPENROUTER_ERROR_PAYLOAD: i18n.t('common.errors.aiUnavailable'),
OPENROUTER_REQUEST_ERROR: i18n.t('common.errors.serverError'),
DEEPL_NOT_CONFIGURED: i18n.t('common.errors.translationFailed'),
DEEPL_UNSUPPORTED_TARGET_LANGUAGE: i18n.t('common.errors.invalidInput'),
DEEPL_TIMEOUT: i18n.t('common.errors.translationFailed'),
DEEPL_CONNECTION_ERROR: i18n.t('common.errors.networkError'),
DEEPL_REQUEST_ERROR: i18n.t('common.errors.translationFailed'),
DEEPL_RATE_LIMIT: i18n.t('common.errors.aiRateLimited'),
DEEPL_SERVER_ERROR: i18n.t('common.errors.serverError'),
DEEPL_CREDITS_OR_PERMISSIONS: i18n.t('common.errors.translationFailed'),
DEEPL_EMPTY_RESPONSE: i18n.t('common.errors.translationFailed'),
DEEPL_EMPTY_TEXT: i18n.t('common.errors.pdfTextEmpty'),
TRANSLATION_PROVIDER_FAILED: i18n.t('common.errors.translationFailed'),
AI_BUDGET_EXCEEDED: i18n.t('common.errors.aiBudgetExceeded'),
PDF_ENCRYPTED: i18n.t('common.errors.pdfEncrypted'),
PDF_TEXT_EXTRACTION_FAILED: i18n.t('common.errors.processingFailed'),
PDF_TEXT_EMPTY: i18n.t('common.errors.pdfTextEmpty'),
PDF_AI_INVALID_INPUT: i18n.t('common.errors.invalidInput'),
PDF_AI_ERROR: i18n.t('common.errors.processingFailed'),
PDF_TABLES_NOT_FOUND: i18n.t('common.errors.pdfNoTables'),
PDF_TABLE_EXTRACTION_FAILED: i18n.t('common.errors.processingFailed'),
TABULA_NOT_INSTALLED: i18n.t('common.errors.serverError'),
};
return map[errorCode] ?? null;
}
export function getTaskErrorMessage(error: unknown, fallback: string): string {
if (isTaskErrorPayload(error)) {
// Prefer a translated message keyed by error_code
if (typeof error.error_code === 'string') {
const translated = resolveErrorCode(error.error_code);
if (translated) return translated;
}
}
if (typeof error === 'string' && error.trim()) {
return error.trim();
}