All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m29s
Un travail echoue ou expire cote serveur restait dans « Traductions
recentes » : sa page de relecture n'affichait rien (0 segments) avec une
erreur brute non traduite, et aucune suppression n'existait.
- serveur : nouvelle route DELETE /api/v1/translations/{job_id} —
retire le travail de la memoire et de Redis, supprime ses segments de
relecture ; idempotente (200 meme si le serveur avait deja oublie le
travail) ; refuse les travaux en cours (annuler d'abord) ; proprietaire
seul (404 pour le travail d'autrui, sans reveler son existence)
- interface : bouton corbeille sur chaque ligne des traductions recentes
(deux clics pour confirmer) qui nettoie la liste locale ET le serveur ;
l'historique serveur ne liste plus les travaux non termines
- page de relecture : message d'erreur traduit (fini le message brut) et
etat « rien a relire » explicite avec explication
- libelles ajoutes dans les 13 langues de l'interface
- 5 tests backend nouveaux (tests/test_delete_translation_history.py)
375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import type {
|
|
UseTranslationSubmitReturn,
|
|
TranslationConfig,
|
|
TranslationStatus,
|
|
TranslationSubmitResponse,
|
|
TranslationStatusResponse
|
|
} from './types';
|
|
import { API_BASE } from '@/lib/config';
|
|
const POLLING_INTERVAL_MS = 2000;
|
|
const MAX_POLLING_FAILURES = 3;
|
|
|
|
/* ── Job persistence: survive a refresh mid-translation ─────────── */
|
|
const ACTIVE_JOB_KEY = 'wordly:activeJob';
|
|
const RECENT_JOBS_KEY = 'wordly:recentJobs';
|
|
const MAX_RECENT_JOBS = 20;
|
|
const ACTIVE_JOB_TTL_MS = 24 * 60 * 60 * 1000; // discard stale entries after 24h
|
|
|
|
interface StoredJob { jobId: string; fileName: string | null; savedAt: number }
|
|
|
|
export interface RecentJob { jobId: string; fileName: string; completedAt: number }
|
|
|
|
/** Server history (last completed jobs, newest first) — empty when offline/unauthenticated. */
|
|
export async function fetchServerHistory(perPage = 6): Promise<RecentJob[]> {
|
|
if (typeof window === 'undefined') return [];
|
|
const token = localStorage.getItem('token');
|
|
if (!token) return [];
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/translations?per_page=${perPage}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (!res.ok) return [];
|
|
const json = await res.json();
|
|
return (json.data ?? [])
|
|
.filter((j: { status?: string | null }) => (j.status ?? 'completed') === 'completed')
|
|
.map((j: {
|
|
id: string; file_name?: string | null; status?: string | null;
|
|
completed_at?: string | null; created_at?: string | null;
|
|
}) => ({
|
|
jobId: j.id,
|
|
fileName: j.file_name ?? '',
|
|
completedAt: Date.parse(j.completed_at ?? j.created_at ?? '') || Date.now(),
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function getRecentJobs(): RecentJob[] {
|
|
if (typeof window === 'undefined') return [];
|
|
try {
|
|
const list = JSON.parse(localStorage.getItem(RECENT_JOBS_KEY) ?? '[]');
|
|
return Array.isArray(list) ? list : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Remove a job from this browser's local history. */
|
|
export function removeRecentJob(jobId: string) {
|
|
try {
|
|
const list = getRecentJobs().filter(j => j.jobId !== jobId);
|
|
localStorage.setItem(RECENT_JOBS_KEY, JSON.stringify(list));
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
/**
|
|
* Delete a job on the server (history entry + review segments).
|
|
* Silent by design: a 404 means the server already forgot the job, which
|
|
* is exactly the state the caller wants.
|
|
*/
|
|
export async function deleteServerJob(jobId: string): Promise<void> {
|
|
if (typeof window === 'undefined') return;
|
|
const token = localStorage.getItem('token');
|
|
if (!token) return;
|
|
try {
|
|
await fetch(`${API_BASE}/api/v1/translations/${jobId}`, {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
} catch { /* offline — the local entry is removed regardless */ }
|
|
}
|
|
|
|
function persistActiveJob(job: StoredJob | null) {
|
|
try {
|
|
if (!job) localStorage.removeItem(ACTIVE_JOB_KEY);
|
|
else localStorage.setItem(ACTIVE_JOB_KEY, JSON.stringify(job));
|
|
} catch { /* storage unavailable — session-only fallback */ }
|
|
}
|
|
|
|
export function pushRecentJob(job: RecentJob) {
|
|
try {
|
|
const list = getRecentJobs().filter(j => j.jobId !== job.jobId);
|
|
list.unshift(job);
|
|
localStorage.setItem(RECENT_JOBS_KEY, JSON.stringify(list.slice(0, MAX_RECENT_JOBS)));
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
|
const [jobId, setJobId] = useState<string | null>(null);
|
|
const [status, setStatus] = useState<TranslationStatus>('idle');
|
|
const [progress, setProgress] = useState(0);
|
|
const [currentStep, setCurrentStep] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [estimatedRemaining, setEstimatedRemaining] = useState<number | null>(null);
|
|
const [fileName, setFileName] = useState<string | null>(null);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [pollingFailures, setPollingFailures] = useState(0);
|
|
const [isPolling, setIsPolling] = useState(false);
|
|
|
|
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
const isPollingRef = useRef(false);
|
|
// Use a ref for failure count to avoid stale closure in the interval callback.
|
|
// If we relied on state, the setInterval callback would always read the initial
|
|
// value of pollingFailures (0) and never reach MAX_POLLING_FAILURES.
|
|
const pollingFailuresRef = useRef(0);
|
|
const resumeAttemptedRef = useRef(false);
|
|
|
|
const stopPolling = useCallback(() => {
|
|
if (pollingIntervalRef.current) {
|
|
clearInterval(pollingIntervalRef.current);
|
|
pollingIntervalRef.current = null;
|
|
}
|
|
isPollingRef.current = false;
|
|
setIsPolling(false);
|
|
}, []);
|
|
|
|
const pollProgress = useCallback(async (id: string) => {
|
|
if (isPollingRef.current) return;
|
|
|
|
isPollingRef.current = true;
|
|
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const headers: Record<string, string> = {};
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/translations/${id}`, { headers });
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 404) {
|
|
stopPolling();
|
|
setIsSubmitting(false);
|
|
setStatus('failed');
|
|
setError('Translation job not found');
|
|
return;
|
|
}
|
|
// 429 (rate-limited) is not a real failure — just skip this poll
|
|
if (response.status === 429) {
|
|
return;
|
|
}
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const data: TranslationStatusResponse = await response.json();
|
|
const job = data.data;
|
|
|
|
setStatus(job.status as TranslationStatus);
|
|
setProgress(job.progress_percent || 0);
|
|
setCurrentStep(job.current_step || '');
|
|
setEstimatedRemaining(data.meta.estimated_remaining_seconds ?? null);
|
|
pollingFailuresRef.current = 0;
|
|
setPollingFailures(0);
|
|
|
|
if (job.file_name) {
|
|
setFileName(job.file_name);
|
|
}
|
|
|
|
if (job.status === 'completed' || job.status === 'failed') {
|
|
stopPolling();
|
|
setIsSubmitting(false);
|
|
if (job.status === 'failed') {
|
|
setError(job.error_message || 'Translation failed');
|
|
}
|
|
if (job.status === 'completed') {
|
|
pushRecentJob({ jobId: id, fileName: job.file_name || '', completedAt: Date.now() });
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Polling error:', err);
|
|
pollingFailuresRef.current += 1;
|
|
setPollingFailures(pollingFailuresRef.current);
|
|
|
|
if (pollingFailuresRef.current >= MAX_POLLING_FAILURES) {
|
|
stopPolling();
|
|
setIsSubmitting(false);
|
|
setStatus('failed');
|
|
setError('Lost connection to translation service. Please check your internet connection and try again.');
|
|
}
|
|
} finally {
|
|
isPollingRef.current = false;
|
|
}
|
|
}, [stopPolling]);
|
|
|
|
const startPolling = useCallback((id: string) => {
|
|
stopPolling();
|
|
pollingFailuresRef.current = 0;
|
|
setIsPolling(true);
|
|
setPollingFailures(0);
|
|
|
|
pollProgress(id);
|
|
|
|
pollingIntervalRef.current = setInterval(() => {
|
|
pollProgress(id);
|
|
}, POLLING_INTERVAL_MS);
|
|
}, [pollProgress, stopPolling]);
|
|
|
|
const submitTranslation = useCallback(async (file: File, config: TranslationConfig) => {
|
|
setIsSubmitting(true);
|
|
setError(null);
|
|
setProgress(0);
|
|
setCurrentStep('Uploading file...');
|
|
setEstimatedRemaining(null);
|
|
setStatus('processing'); // IMPORTANT: Set to 'processing' IMMEDIATELY so progress bar shows
|
|
setFileName(file.name);
|
|
setJobId(null);
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('source_lang', config.sourceLang);
|
|
formData.append('target_lang', config.targetLang);
|
|
formData.append('mode', config.mode);
|
|
// Provider is configured server-side by admin — only send the provider name.
|
|
if (config.mode === 'llm' && config.provider) {
|
|
formData.append('provider', config.provider);
|
|
}
|
|
// PDF mode: layout (preserve layout) or text_only (clean text output)
|
|
if (config.pdfMode) {
|
|
formData.append('pdf_mode', config.pdfMode);
|
|
}
|
|
// Glossary for LLM translation (Pro only)
|
|
if (config.glossaryId) {
|
|
formData.append('glossary_id', config.glossaryId);
|
|
}
|
|
// Translate images toggle
|
|
if (config.translateImages !== undefined) {
|
|
formData.append('translate_images', String(config.translateImages));
|
|
}
|
|
// System prompt from Context page (Pro only)
|
|
const { settings } = await import('@/lib/store').then(m => m.useTranslationStore.getState());
|
|
if (settings.systemPrompt?.trim()) {
|
|
formData.append('custom_prompt', settings.systemPrompt.trim());
|
|
}
|
|
|
|
const token = localStorage.getItem('token');
|
|
const headers: Record<string, string> = {};
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/translate`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = `Translation failed: ${response.status}`;
|
|
try {
|
|
const errorData = await response.json();
|
|
errorMessage = errorData.message || errorData.error || errorMessage;
|
|
} catch {
|
|
// Response not JSON, use default message
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const data: TranslationSubmitResponse = await response.json();
|
|
|
|
setJobId(data.data.id);
|
|
setFileName(data.data.file_name || file.name);
|
|
setProgress(data.data.progress_percent || 5); // Start with at least 5%
|
|
setCurrentStep(data.data.current_step || 'Translating...');
|
|
|
|
startPolling(data.data.id);
|
|
} catch (err) {
|
|
setStatus('failed');
|
|
setError(err instanceof Error ? err.message : 'Translation failed');
|
|
setIsSubmitting(false);
|
|
}
|
|
// NOTE: Don't set isSubmitting(false) here - let polling handle the transition
|
|
}, [startPolling]);
|
|
|
|
/** Ask the backend to cancel the current job. Returns true on success. */
|
|
const cancelJob = useCallback(async (): Promise<boolean> => {
|
|
const id = jobId;
|
|
if (!id) return false;
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const headers: Record<string, string> = {};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
const res = await fetch(`${API_BASE}/api/v1/translations/${id}/cancel`, {
|
|
method: 'POST',
|
|
headers,
|
|
});
|
|
if (!res.ok) return false;
|
|
stopPolling();
|
|
persistActiveJob(null);
|
|
setIsSubmitting(false);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, [jobId, stopPolling]);
|
|
|
|
const reset = useCallback(() => {
|
|
stopPolling();
|
|
persistActiveJob(null);
|
|
setJobId(null);
|
|
setStatus('idle');
|
|
setProgress(0);
|
|
setCurrentStep('');
|
|
setError(null);
|
|
setEstimatedRemaining(null);
|
|
setFileName(null);
|
|
setIsSubmitting(false);
|
|
setPollingFailures(0);
|
|
}, [stopPolling]);
|
|
|
|
// Persist the active job whenever it changes so a refresh can resume polling.
|
|
useEffect(() => {
|
|
if (jobId) persistActiveJob({ jobId, fileName, savedAt: Date.now() });
|
|
}, [jobId, fileName]);
|
|
|
|
// Resume an interrupted job once on mount (page refreshed mid-translation).
|
|
useEffect(() => {
|
|
if (resumeAttemptedRef.current) return;
|
|
resumeAttemptedRef.current = true;
|
|
try {
|
|
const raw = localStorage.getItem(ACTIVE_JOB_KEY);
|
|
if (!raw) return;
|
|
const stored: StoredJob = JSON.parse(raw);
|
|
if (!stored?.jobId || Date.now() - stored.savedAt > ACTIVE_JOB_TTL_MS) {
|
|
persistActiveJob(null);
|
|
return;
|
|
}
|
|
setJobId(stored.jobId);
|
|
if (stored.fileName) setFileName(stored.fileName);
|
|
setStatus('processing');
|
|
setProgress(0);
|
|
startPolling(stored.jobId);
|
|
} catch {
|
|
persistActiveJob(null);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
stopPolling();
|
|
};
|
|
}, [stopPolling]);
|
|
|
|
return {
|
|
submitTranslation,
|
|
cancelJob,
|
|
jobId,
|
|
status,
|
|
progress,
|
|
currentStep,
|
|
error,
|
|
estimatedRemaining,
|
|
fileName,
|
|
reset,
|
|
isSubmitting,
|
|
isPolling,
|
|
pollingFailures,
|
|
};
|
|
}
|