From 071ce44ad6a33b69d5820f1ba3ab1e396118c439 Mon Sep 17 00:00:00 2001 From: ewalid Date: Tue, 13 Jan 2026 21:13:41 +0100 Subject: [PATCH] Add real-time translation progress via SSE streaming - Add /translate-stream SSE endpoint for real-time progress updates - Add cell count estimation display before translation starts - Implement SSE progress tracking in useTranslate hook with automatic fallback to regular endpoint for corporate networks - Update ProgressIndicator to show actual translation percentage - Add /count and /preview REST endpoints for API completeness - Add sse-starlette dependency for server-sent events support Co-Authored-By: Claude Opus 4.5 --- frontend/src/api/client.ts | 44 ++- .../features/translate/ProgressIndicator.tsx | 85 ++++-- .../features/translate/TranslateForm.tsx | 61 +++- frontend/src/hooks/useTranslate.ts | 273 ++++++++++++++++- frontend/src/types/index.ts | 20 ++ pyproject.toml | 1 + src/rosetta/api/app.py | 288 ++++++++++++++++++ uv.lock | 15 + 8 files changed, 749 insertions(+), 38 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 113e090..8cb50a8 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import type { TranslateRequest, TranslateResponse, SheetsResponse, FeedbackRequest, FeedbackResponse } from '../types'; +import type { TranslateRequest, TranslateResponse, SheetsResponse, FeedbackRequest, FeedbackResponse, EstimateResponse } from '../types'; import { generateOutputFilename } from '../lib/utils'; const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; @@ -36,6 +36,48 @@ export async function getSheets(file: File): Promise { } } +export async function getEstimate(file: File, sheets?: string[]): Promise { + const formData = new FormData(); + formData.append('file', file); + + if (sheets && sheets.length > 0) { + formData.append('sheets', sheets.join(',')); + } + + try { + const response = await fetch(`${API_URL}/estimate`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = errorData.detail || `Failed to get estimate with status ${response.status}`; + return { + success: false, + error: errorMessage, + }; + } + + const data = await response.json(); + return { + success: true, + estimate: { + cellCount: data.cell_count, + estimatedCostUsd: data.estimated_cost_usd, + estimatedTimeSeconds: data.estimated_time_seconds, + estimatedTimeDisplay: data.estimated_time_display, + }, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Network error occurred'; + return { + success: false, + error: errorMessage, + }; + } +} + export async function translateFile(request: TranslateRequest): Promise { const formData = new FormData(); diff --git a/frontend/src/components/features/translate/ProgressIndicator.tsx b/frontend/src/components/features/translate/ProgressIndicator.tsx index 2fa12c7..f9ada1a 100644 --- a/frontend/src/components/features/translate/ProgressIndicator.tsx +++ b/frontend/src/components/features/translate/ProgressIndicator.tsx @@ -1,41 +1,83 @@ import { motion, AnimatePresence } from 'framer-motion'; import { FileText, Languages, CheckCircle2 } from 'lucide-react'; -import type { TranslateStatus } from '../../../types'; +import type { TranslateStatus, TranslationProgress } from '../../../types'; import './Progress.css'; interface ProgressIndicatorProps { status: TranslateStatus; + progress?: TranslationProgress | null; } const stages = [ - { - key: 'uploading' as const, + { + key: 'uploading' as const, label: 'Uploading file', icon: FileText, - progress: 33 + baseProgress: 10 }, - { - key: 'translating' as const, + { + key: 'translating' as const, label: 'Translating content', icon: Languages, - progress: 66 + baseProgress: 50 }, - { - key: 'success' as const, + { + key: 'success' as const, label: 'Complete', icon: CheckCircle2, - progress: 100 + baseProgress: 100 }, ]; -export function ProgressIndicator({ status }: ProgressIndicatorProps) { +function getStageLabel(stage: string): string { + switch (stage) { + case 'extracting': + return 'Extracting cells...'; + case 'translating': + return 'Translating...'; + case 'rich_text': + return 'Processing rich text...'; + case 'dropdowns': + return 'Translating dropdowns...'; + case 'writing': + return 'Writing file...'; + case 'complete': + return 'Complete!'; + default: + return 'Processing...'; + } +} + +export function ProgressIndicator({ status, progress }: ProgressIndicatorProps) { if (status === 'idle' || status === 'error') { return null; } - const currentStage = stages.find(s => s.key === status) || stages[0]; + // Calculate progress percentage + let displayProgress: number; + if (status === 'uploading') { + displayProgress = 5; + } else if (status === 'translating' && progress) { + // Map the translation progress (0-100) to 10-95 range + displayProgress = Math.max(10, Math.min(95, 10 + (progress.percentage * 0.85))); + } else if (status === 'success') { + displayProgress = 100; + } else { + displayProgress = 10; + } + const currentIndex = stages.findIndex(s => s.key === status); - const progress = currentStage.progress; + + // Build the label for translating status + let translatingLabel = 'Translating content'; + if (status === 'translating' && progress) { + const stageText = getStageLabel(progress.stage); + if (progress.total > 0) { + translatingLabel = `${stageText} ${progress.translated}/${progress.total} cells`; + } else { + translatingLabel = stageText; + } + } return (
- {progress}% + {Math.round(displayProgress)}%
@@ -86,12 +128,15 @@ export function ProgressIndicator({ status }: ProgressIndicatorProps) { const isComplete = index < currentIndex || status === 'success'; const Icon = stage.icon; + // Use dynamic label for translating stage + const label = stage.key === 'translating' && isActive ? translatingLabel : stage.label; + return ( - {stage.label} + {label} ); @@ -125,7 +170,7 @@ export function ProgressIndicator({ status }: ProgressIndicatorProps) { {/* Status hint */} - {status === 'translating' && ( + {status === 'translating' && !progress && ( ([]); const [showAdvanced, setShowAdvanced] = useState(false); const [loadingSheets, setLoadingSheets] = useState(false); + const [estimate, setEstimate] = useState(null); + const [loadingEstimate, setLoadingEstimate] = useState(false); const [isDragging, setIsDragging] = useState(false); const [recaptchaToken, setRecaptchaToken] = useState(null); const recaptchaRef = useRef(null); const containerRef = useRef(null); - const { status, error, filename, translate, downloadResult, reset } = useTranslate(); + const { status, error, filename, progress, translate, downloadResult, reset } = useTranslate(); // Fetch sheets when file is selected useEffect(() => { @@ -34,6 +36,7 @@ export function TranslateForm() { if (!selectedFile) { setSheets([]); setSelectedSheets([]); + setEstimate(null); return; } @@ -52,6 +55,34 @@ export function TranslateForm() { fetchSheets(); }, [selectedFile]); + // Fetch estimate when file or selected sheets change + useEffect(() => { + async function fetchEstimate() { + if (!selectedFile) { + setEstimate(null); + return; + } + + // Wait for sheets to be loaded before fetching estimate + if (loadingSheets) return; + + setLoadingEstimate(true); + const sheetsToEstimate = selectedSheets.length > 0 && selectedSheets.length < sheets.length + ? selectedSheets + : undefined; + const response = await getEstimate(selectedFile.file, sheetsToEstimate); + setLoadingEstimate(false); + + if (response.success && response.estimate) { + setEstimate(response.estimate); + } else { + setEstimate(null); + } + } + + fetchEstimate(); + }, [selectedFile, selectedSheets, sheets.length, loadingSheets]); + const handleFileSelect = useCallback((fileInfo: FileInfo | null) => { setSelectedFile(fileInfo); if (status !== 'idle') { @@ -328,7 +359,7 @@ export function TranslateForm() { disabled={isTranslating} />

- Provide domain-specific context to help the AI understand your document better for more accurate translations. + Provide domain-specific context for more accurate translations.

@@ -368,13 +399,31 @@ export function TranslateForm() { )} + {/* Estimation Display */} + {selectedFile && (estimate || loadingEstimate) && ( + + {loadingEstimate ? ( + Analyzing file... + ) : estimate ? ( +
+ + {estimate.cellCount.toLocaleString()} cells to translate +
+ ) : null} +
+ )} +

Supports files up to 50MB

{/* Progress Indicator */} - {isTranslating && } + {isTranslating && } {/* Result Display */} diff --git a/frontend/src/hooks/useTranslate.ts b/frontend/src/hooks/useTranslate.ts index 4fdd948..c0a2761 100644 --- a/frontend/src/hooks/useTranslate.ts +++ b/frontend/src/hooks/useTranslate.ts @@ -1,44 +1,289 @@ import { useState, useCallback, useRef } from 'react'; import { translateFile, downloadBlob } from '../api/client'; -import type { TranslateRequest, TranslateStatus } from '../types'; +import type { TranslateRequest, TranslateStatus, TranslationProgress } from '../types'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + +// Cache SSE support check result +let sseSupported: boolean | null = null; interface UseTranslateReturn { status: TranslateStatus; error: string | undefined; filename: string | undefined; + progress: TranslationProgress | null; translate: (request: TranslateRequest) => Promise; downloadResult: () => void; reset: () => void; } +function base64ToBlob(base64: string, mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'): Blob { + const byteCharacters = atob(base64); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + return new Blob([byteArray], { type: mimeType }); +} + +/** + * Check if SSE/streaming is supported by testing the health endpoint. + * Corporate proxies often buffer responses or block streaming. + */ +async function checkSSESupport(): Promise { + // Return cached result if available + if (sseSupported !== null) { + return sseSupported; + } + + try { + // Quick test: check if fetch with streaming works + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + + const response = await fetch(`${API_URL}/health`, { + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + // Check if we can get a reader (streaming support) + if (!response.body?.getReader) { + sseSupported = false; + return false; + } + + // Additional check: some proxies modify headers or add buffering + const cacheControl = response.headers.get('cache-control'); + + // If proxy adds caching headers, SSE might not work properly + if (cacheControl?.includes('transform') || cacheControl?.includes('cache')) { + console.info('[SSE] Proxy detected, using fallback endpoint'); + sseSupported = false; + return false; + } + + sseSupported = true; + return true; + } catch { + // Network error or timeout - SSE likely won't work + sseSupported = false; + return false; + } +} + export function useTranslate(): UseTranslateReturn { const [status, setStatus] = useState('idle'); const [error, setError] = useState(); const [filename, setFilename] = useState(); + const [progress, setProgress] = useState(null); const resultBlobRef = useRef(null); + const abortControllerRef = useRef(null); - const translate = useCallback(async (request: TranslateRequest) => { + const translateWithRegularEndpoint = useCallback(async (request: TranslateRequest) => { setStatus('uploading'); setError(undefined); + setProgress(null); resultBlobRef.current = null; setFilename(undefined); - // Simulate a brief upload phase - await new Promise((resolve) => setTimeout(resolve, 500)); + // Simulate upload progress briefly + await new Promise((resolve) => setTimeout(resolve, 300)); setStatus('translating'); - const response = await translateFile(request); + // Use simulated progress for non-SSE fallback + const progressInterval = setInterval(() => { + setProgress((prev) => { + if (!prev) return { translated: 0, total: 100, percentage: 10, stage: 'translating' }; + const newPercentage = Math.min(prev.percentage + 5, 90); + return { ...prev, percentage: newPercentage }; + }); + }, 1000); + + try { + const response = await translateFile(request); - if (response.success && response.blob) { - resultBlobRef.current = response.blob; - setFilename(response.filename); - setStatus('success'); - } else { - setError(response.error); + clearInterval(progressInterval); + + if (response.success && response.blob) { + resultBlobRef.current = response.blob; + setFilename(response.filename); + setProgress(null); + setStatus('success'); + } else { + setError(response.error); + setProgress(null); + setStatus('error'); + } + } catch (err) { + clearInterval(progressInterval); + const errorMessage = err instanceof Error ? err.message : 'Network error occurred'; + setError(errorMessage); + setProgress(null); setStatus('error'); } }, []); + const translateWithSSE = useCallback(async (request: TranslateRequest): Promise => { + setStatus('uploading'); + setError(undefined); + setProgress(null); + resultBlobRef.current = null; + setFilename(undefined); + + const formData = new FormData(); + formData.append('file', request.file); + formData.append('target_lang', request.targetLanguage); + + if (request.sourceLanguage) { + formData.append('source_lang', request.sourceLanguage); + } + + if (request.context) { + formData.append('context', request.context); + } + + if (request.sheets && request.sheets.length > 0) { + formData.append('sheets', request.sheets.join(',')); + } + + if (request.recaptchaToken) { + formData.append('recaptcha_token', request.recaptchaToken); + } + + // Create abort controller for timeout + abortControllerRef.current = new AbortController(); + + try { + const response = await fetch(`${API_URL}/translate-stream`, { + method: 'POST', + body: formData, + signal: abortControllerRef.current.signal, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = errorData.detail || `Translation failed with status ${response.status}`; + setError(errorMessage); + setStatus('error'); + return true; // Error handled, don't fallback + } + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + + setStatus('translating'); + + if (!reader) { + // No streaming support - signal to use fallback + return false; + } + + let buffer = ''; + let receivedProgress = false; + let completed = false; + + // Set a timeout for receiving first progress update + const progressTimeoutId = setTimeout(() => { + if (!receivedProgress && !completed) { + console.info('[SSE] No progress received, connection may be buffered'); + // Don't abort here - let it continue, the translation might still work + } + }, 10000); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + + // Keep incomplete line in buffer + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + + if (data.percentage !== undefined && data.stage !== undefined) { + // Progress update + receivedProgress = true; + setProgress({ + translated: data.translated, + total: data.total, + percentage: data.percentage, + stage: data.stage, + }); + } else if (data.file_base64) { + // Translation complete + completed = true; + clearTimeout(progressTimeoutId); + const blob = base64ToBlob(data.file_base64); + resultBlobRef.current = blob; + setFilename(data.filename); + setProgress(null); + setStatus('success'); + } else if (data.error) { + // Error from server + completed = true; + clearTimeout(progressTimeoutId); + setError(data.error); + setProgress(null); + setStatus('error'); + } + } catch { + // Ignore invalid JSON lines + } + } + } + } + + clearTimeout(progressTimeoutId); + + // If we finished reading but didn't complete, something went wrong + if (!completed) { + // Stream ended unexpectedly - might be proxy issue + return false; + } + + return true; // Successfully handled with SSE + } catch (err) { + // Check if it was aborted intentionally + if (err instanceof Error && err.name === 'AbortError') { + return true; // Don't fallback on intentional abort + } + + // Network error - might be proxy blocking SSE + console.info('[SSE] Streaming failed, will use fallback:', err); + return false; + } + }, []); + + const translate = useCallback(async (request: TranslateRequest) => { + // Check if SSE is likely to work + const canUseSSE = await checkSSESupport(); + + if (!canUseSSE) { + // Use regular endpoint directly + console.info('[Translate] Using regular endpoint (SSE not supported)'); + await translateWithRegularEndpoint(request); + return; + } + + // Try SSE first + const sseHandled = await translateWithSSE(request); + + if (!sseHandled) { + // SSE failed mid-stream, fall back to regular endpoint + console.info('[Translate] SSE failed, falling back to regular endpoint'); + // Mark SSE as not supported for future requests + sseSupported = false; + await translateWithRegularEndpoint(request); + } + }, [translateWithSSE, translateWithRegularEndpoint]); + const downloadResult = useCallback(() => { if (resultBlobRef.current && filename) { downloadBlob(resultBlobRef.current, filename); @@ -46,9 +291,14 @@ export function useTranslate(): UseTranslateReturn { }, [filename]); const reset = useCallback(() => { + // Abort any in-flight request + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + setStatus('idle'); setError(undefined); setFilename(undefined); + setProgress(null); resultBlobRef.current = null; }, []); @@ -56,6 +306,7 @@ export function useTranslate(): UseTranslateReturn { status, error, filename, + progress, translate, downloadResult, reset, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index abc5d15..f38b0fb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -46,3 +46,23 @@ export interface FeedbackResponse { message?: string; error?: string; } + +export interface EstimateResult { + cellCount: number; + estimatedCostUsd: number; + estimatedTimeSeconds: number; + estimatedTimeDisplay: string; +} + +export interface EstimateResponse { + success: boolean; + estimate?: EstimateResult; + error?: string; +} + +export interface TranslationProgress { + translated: number; + total: number; + percentage: number; + stage: string; +} diff --git a/pyproject.toml b/pyproject.toml index e5a977b..24666bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "uvicorn>=0.32.0", "python-multipart>=0.0.12", "requests>=2.31.0", + "sse-starlette>=2.0.0", ] [project.optional-dependencies] diff --git a/src/rosetta/api/app.py b/src/rosetta/api/app.py index d6cc86c..fdeef7c 100644 --- a/src/rosetta/api/app.py +++ b/src/rosetta/api/app.py @@ -2,6 +2,9 @@ import os import tempfile +import base64 +import queue +import threading from pathlib import Path from typing import Optional @@ -11,11 +14,13 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse from starlette.middleware.base import BaseHTTPMiddleware +from sse_starlette.sse import EventSourceResponse from openpyxl import load_workbook import json import asyncio from rosetta.services.translation_service import count_cells, translate_file +from rosetta.services import ExcelExtractor from .mcp import router as mcp_router, COST_PER_1000_CELLS_USD # Load environment variables from .env file @@ -232,6 +237,139 @@ async def get_sheets( input_path.unlink(missing_ok=True) +def col_to_letter(col: int) -> str: + """Convert column number to Excel letter (1 -> A, 27 -> AA).""" + result = "" + while col > 0: + col, remainder = divmod(col - 1, 26) + result = chr(65 + remainder) + result + return result + + +@app.post("/count") +async def count_translatable( + file: UploadFile = File(..., description="Excel file to count translatable cells"), + sheets: Optional[str] = Form(None, description="Comma-separated sheet names (all if omitted)"), +) -> dict: + """Count translatable cells in an Excel file. + + Returns the number of cells containing text that would be translated. + Excludes formulas, numbers, dates, and empty cells. + """ + # Validate file type + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + if not file.filename.lower().endswith((".xlsx", ".xlsm", ".xltx", ".xltm")): + raise HTTPException( + status_code=400, + detail="Invalid file type. Only Excel files (.xlsx, .xlsm, .xltx, .xltm) are supported", + ) + + # Read file content + content = await file.read() + + # Check file size + if len(content) > MAX_FILE_SIZE: + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size is {MAX_FILE_SIZE // (1024 * 1024)}MB", + ) + + # Parse sheets parameter + sheets_set = None + if sheets: + sheets_set = {s.strip() for s in sheets.split(",") if s.strip()} + + # Save to temp file for processing + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp_input: + tmp_input.write(content) + input_path = Path(tmp_input.name) + + try: + cell_count = count_cells(input_path, sheets_set) + scope = f"sheets: {', '.join(sorted(sheets_set))}" if sheets_set else "all sheets" + + return { + "cell_count": cell_count, + "scope": scope, + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Count failed: {str(e)}") + finally: + input_path.unlink(missing_ok=True) + + +@app.post("/preview") +async def preview_cells( + file: UploadFile = File(..., description="Excel file to preview"), + limit: int = Form(10, ge=1, le=50, description="Maximum number of cells to preview (1-50)"), + sheets: Optional[str] = Form(None, description="Comma-separated sheet names (all if omitted)"), +) -> dict: + """Preview translatable cells from an Excel file. + + Returns the first N cells that would be translated, showing their + location and content. Useful for understanding what will be translated + before running a full translation. + """ + # Validate file type + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + if not file.filename.lower().endswith((".xlsx", ".xlsm", ".xltx", ".xltm")): + raise HTTPException( + status_code=400, + detail="Invalid file type. Only Excel files (.xlsx, .xlsm, .xltx, .xltm) are supported", + ) + + # Read file content + content = await file.read() + + # Check file size + if len(content) > MAX_FILE_SIZE: + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size is {MAX_FILE_SIZE // (1024 * 1024)}MB", + ) + + # Parse sheets parameter + sheets_set = None + if sheets: + sheets_set = {s.strip() for s in sheets.split(",") if s.strip()} + + # Save to temp file for processing + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp_input: + tmp_input.write(content) + input_path = Path(tmp_input.name) + + try: + with ExcelExtractor(input_path, sheets=sheets_set) as extractor: + cells = [] + for i, cell in enumerate(extractor.extract_cells()): + if i >= limit: + break + col_letter = col_to_letter(cell.col) + cells.append({ + "sheet": cell.sheet, + "cell": f"{col_letter}{cell.row}", + "content": cell.value[:200] if len(cell.value) > 200 else cell.value, + }) + + scope = f"sheets: {', '.join(sorted(sheets_set))}" if sheets_set else "all sheets" + + return { + "cells": cells, + "total_shown": len(cells), + "scope": scope, + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Preview failed: {str(e)}") + finally: + input_path.unlink(missing_ok=True) + + def verify_recaptcha(token: Optional[str]) -> bool: """Verify reCAPTCHA token with Google's API.""" if not RECAPTCHA_SECRET_KEY: @@ -362,3 +500,153 @@ async def translate( finally: # Cleanup input file (output file cleaned up after response is sent) input_path.unlink(missing_ok=True) + + +@app.post("/translate-stream") +async def translate_stream( + file: UploadFile = File(..., description="Excel file to translate"), + target_lang: str = Form(..., description="Target language (e.g., french, spanish)"), + source_lang: Optional[str] = Form(None, description="Source language (auto-detect if omitted)"), + context: Optional[str] = Form(None, description="Additional context for accurate translations"), + sheets: Optional[str] = Form(None, description="Comma-separated sheet names (all if omitted)"), + recaptcha_token: Optional[str] = Form(None, description="reCAPTCHA token for verification"), +): + """Translate an Excel file with real-time progress streaming via SSE. + + Returns Server-Sent Events with progress updates and the final translated file. + """ + # Verify reCAPTCHA token + if not verify_recaptcha(recaptcha_token): + raise HTTPException( + status_code=400, + detail="reCAPTCHA verification failed. Please complete the reCAPTCHA challenge.", + ) + + # Validate file type + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + if not file.filename.lower().endswith((".xlsx", ".xlsm", ".xltx", ".xltm")): + raise HTTPException( + status_code=400, + detail="Invalid file type. Only Excel files (.xlsx, .xlsm, .xltx, .xltm) are supported", + ) + + # Read file content + content = await file.read() + + # Check file size + if len(content) > MAX_FILE_SIZE: + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size is {MAX_FILE_SIZE // (1024 * 1024)}MB", + ) + + # Parse sheets parameter + sheets_set = None + if sheets: + sheets_set = {s.strip() for s in sheets.split(",") if s.strip()} + + # Save to temp file for processing + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp_input: + tmp_input.write(content) + input_path = Path(tmp_input.name) + + # Check cell count + try: + cell_count = count_cells(input_path, sheets_set) + if cell_count > MAX_CELLS: + input_path.unlink(missing_ok=True) + raise HTTPException( + status_code=400, + detail=f"Too many cells ({cell_count}). Maximum is {MAX_CELLS} cells per request", + ) + + if cell_count == 0: + input_path.unlink(missing_ok=True) + raise HTTPException( + status_code=400, + detail="No translatable content found in the file", + ) + except HTTPException: + raise + except Exception as e: + input_path.unlink(missing_ok=True) + raise HTTPException(status_code=500, detail=f"File validation failed: {str(e)}") + + # Create output path + output_path = input_path.with_name(f"{input_path.stem}_translated.xlsx") + output_filename = file.filename.replace(".xlsx", f"_{target_lang}.xlsx") + + # Progress queue for thread communication + progress_queue: queue.Queue = queue.Queue() + result_holder = {"result": None, "error": None} + + def progress_callback(translated: int, total: int, stage: str): + """Callback to report translation progress.""" + progress_queue.put({ + "translated": translated, + "total": total, + "stage": stage, + "percentage": round((translated / total) * 100) if total > 0 else 0 + }) + + def run_translation(): + """Run translation in a separate thread.""" + try: + result_holder["result"] = translate_file( + input_file=input_path, + output_file=output_path, + target_lang=target_lang, + source_lang=source_lang, + context=context, + sheets=sheets_set, + progress_callback=progress_callback, + ) + except Exception as e: + result_holder["error"] = str(e) + + # Start translation in background thread + thread = threading.Thread(target=run_translation) + thread.start() + + async def event_generator(): + """Generate SSE events for progress and completion.""" + try: + while thread.is_alive() or not progress_queue.empty(): + try: + progress = progress_queue.get(timeout=0.1) + yield { + "event": "progress", + "data": json.dumps(progress) + } + except queue.Empty: + await asyncio.sleep(0.1) + + # Wait for thread to complete + thread.join() + + # Send final result or error + if result_holder["error"]: + yield { + "event": "error", + "data": json.dumps({"error": result_holder["error"]}) + } + else: + # Encode translated file as base64 for SSE + with open(output_path, "rb") as f: + file_base64 = base64.b64encode(f.read()).decode() + yield { + "event": "complete", + "data": json.dumps({ + "filename": output_filename, + "file_base64": file_base64, + "cell_count": result_holder["result"]["cell_count"] + }) + } + finally: + # Cleanup temp files + input_path.unlink(missing_ok=True) + output_path.unlink(missing_ok=True) + + return EventSourceResponse(event_generator()) diff --git a/uv.lock b/uv.lock index 9dda6ef..eabe81c 100644 --- a/uv.lock +++ b/uv.lock @@ -843,6 +843,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "python-multipart" }, { name = "requests" }, + { name = "sse-starlette" }, { name = "uvicorn" }, ] @@ -873,6 +874,7 @@ requires-dist = [ { name = "python-multipart", specifier = ">=0.0.12" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.0" }, + { name = "sse-starlette", specifier = ">=2.0.0" }, { name = "uvicorn", specifier = ">=0.32.0" }, ] provides-extras = ["dev"] @@ -912,6 +914,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/f5df66cb383efdbf4f2db23cabb27f51b1dcb737efaf8a558f6f1d195134/sse_starlette-3.1.2.tar.gz", hash = "sha256:55eff034207a83a0eb86de9a68099bd0157838f0b8b999a1b742005c71e33618", size = 26303, upload-time = "2025-12-31T08:02:20.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/95/8c4b76eec9ae574474e5d2997557cebf764bcd3586458956c30631ae08f4/sse_starlette-3.1.2-py3-none-any.whl", hash = "sha256:cd800dd349f4521b317b9391d3796fa97b71748a4da9b9e00aafab32dda375c8", size = 12484, upload-time = "2025-12-31T08:02:18.894Z" }, +] + [[package]] name = "starlette" version = "0.50.0"