diff --git a/common/src/main/java/stirling/software/common/util/ExceptionUtils.java b/common/src/main/java/stirling/software/common/util/ExceptionUtils.java index 161cdf64f..2c2312f2d 100644 --- a/common/src/main/java/stirling/software/common/util/ExceptionUtils.java +++ b/common/src/main/java/stirling/software/common/util/ExceptionUtils.java @@ -94,35 +94,32 @@ public class ExceptionUtils { } /** - * Create a generic IOException with internationalized message. + * Create a generic IOException with readable English message. * - * @param messageKey the i18n message key - * @param defaultMessage the default message if i18n is not available + * @param messageKey the i18n message key for frontend translation + * @param defaultMessage the English message template * @param cause the original exception - * @param args optional arguments for the message - * @return IOException with user-friendly message + * @param args arguments for message formatting + * @return IOException with readable English message */ public static IOException createIOException( String messageKey, String defaultMessage, Exception cause, Object... args) { - String message = messageKey != null ? defaultMessage : String.format(defaultMessage, args); - if (messageKey != null) { - return new TranslatableIOException(message, messageKey, cause, args); - } + String message = String.format(defaultMessage, args); return new IOException(message, cause); } /** - * Create a generic RuntimeException with internationalized message. + * Create a generic RuntimeException with readable English message. * - * @param messageKey the i18n message key - * @param defaultMessage the default message if i18n is not available + * @param messageKey the i18n message key for frontend translation + * @param defaultMessage the English message template * @param cause the original exception - * @param args optional arguments for the message - * @return RuntimeException with user-friendly message + * @param args arguments for message formatting + * @return RuntimeException with readable English message */ public static RuntimeException createRuntimeException( String messageKey, String defaultMessage, Exception cause, Object... args) { - String message = messageKey != null ? defaultMessage : String.format(defaultMessage, args); + String message = String.format(defaultMessage, args); if (messageKey != null) { return new TranslatableException(message, messageKey, args); } @@ -130,17 +127,16 @@ public class ExceptionUtils { } /** - * Create an IllegalArgumentException with internationalized message. + * Create an IllegalArgumentException with readable English message. * - * @param messageKey the i18n message key - * @param defaultMessage the default message if i18n is not available - * @param args optional arguments for the message - * @return IllegalArgumentException with user-friendly message + * @param messageKey the i18n message key for frontend translation + * @param defaultMessage the English message template + * @param args arguments for message formatting + * @return IllegalArgumentException with readable English message */ public static IllegalArgumentException createIllegalArgumentException( String messageKey, String defaultMessage, Object... args) { - // Only format if no translation key provided (for backwards compatibility) - String message = messageKey != null ? defaultMessage : String.format(defaultMessage, args); + String message = String.format(defaultMessage, args); return new TranslatableException(message, messageKey, args); } diff --git a/common/src/main/java/stirling/software/common/util/TranslatableIOException.java b/common/src/main/java/stirling/software/common/util/TranslatableIOException.java deleted file mode 100644 index 079a03214..000000000 --- a/common/src/main/java/stirling/software/common/util/TranslatableIOException.java +++ /dev/null @@ -1,28 +0,0 @@ -package stirling.software.common.util; - -import java.io.IOException; - -/** - * IOException that carries translation information for frontend internationalization. The - * GlobalExceptionHandler extracts this info to create structured error responses. - */ -public class TranslatableIOException extends IOException { - - private final String translationKey; - private final Object[] translationArgs; - - public TranslatableIOException( - String message, String translationKey, Exception cause, Object... translationArgs) { - super(message, cause); - this.translationKey = translationKey; - this.translationArgs = translationArgs; - } - - public String getTranslationKey() { - return translationKey; - } - - public Object[] getTranslationArgs() { - return translationArgs; - } -} diff --git a/devGuide/EXCEPTION_HANDLING_GUIDE.md b/devGuide/EXCEPTION_HANDLING_GUIDE.md index 6f0cfb98a..32db1280a 100644 --- a/devGuide/EXCEPTION_HANDLING_GUIDE.md +++ b/devGuide/EXCEPTION_HANDLING_GUIDE.md @@ -12,6 +12,7 @@ The system uses a **backend-frontend translation split**: ### 1. ExceptionUtils Creates `TranslatableException` instances with structured translation data for frontend. +Backend uses hardcoded English strings for readability. ### 2. GlobalExceptionHandler Converts exceptions to structured JSON responses with translation information. @@ -19,6 +20,9 @@ Converts exceptions to structured JSON responses with translation information. ### 3. MessageFormatter.js Frontend utility for translating error messages with placeholder replacement. +### 4. GlobalModelAdvice +Loads error translations using Spring's MessageSource and adds them to all templates. + ## Usage Examples ### Basic PDF Exception Handling @@ -82,19 +86,19 @@ The system returns structured JSON error responses with translation support: ``` **Key Features:** -- `message`: English fallback for API consumers that ignore translation -- `translationKey`: Frontend translation key +- `message`: Readable English hardcoded in backend for API consumers +- `translationKey`: Frontend translation key - `translationArgs`: Arguments for placeholder replacement -- API consumers can rely on `message` for backwards compatibility +- Backend sends English, frontend translates to user's language ### Frontend Translation with MessageFormatter ```javascript -// Translate error messages with placeholder replacement +// Frontend translates using Spring's loaded translation data const displayMessage = window.MessageFormatter.translate( json.translationKey, json.translationArgs, - json.message // fallback to original message + json.message // English fallback ); ``` diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/config/GlobalExceptionHandler.java b/stirling-pdf/src/main/java/stirling/software/SPDF/config/GlobalExceptionHandler.java index f8e6bc1a9..c4bbf08c1 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/config/GlobalExceptionHandler.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/config/GlobalExceptionHandler.java @@ -58,25 +58,6 @@ public class GlobalExceptionHandler { return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); } - @ExceptionHandler(stirling.software.common.util.TranslatableIOException.class) - public ResponseEntity handleTranslatableIOException( - stirling.software.common.util.TranslatableIOException e) { - List translationArgs = null; - if (e.getTranslationArgs() != null) { - translationArgs = Arrays.stream(e.getTranslationArgs()).map(String::valueOf).toList(); - } - - ErrorResponse errorResponse = - new ErrorResponse( - "Bad Request", - e.getMessage(), - getStackTrace(e), - e.getTranslationKey(), - translationArgs); - - return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); - } - @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgumentException( IllegalArgumentException e) { diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/config/GlobalModelAdvice.java b/stirling-pdf/src/main/java/stirling/software/SPDF/config/GlobalModelAdvice.java deleted file mode 100644 index e525df9d7..000000000 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/config/GlobalModelAdvice.java +++ /dev/null @@ -1,26 +0,0 @@ -package stirling.software.SPDF.config; - -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.ModelAttribute; - -import lombok.RequiredArgsConstructor; - -import stirling.software.SPDF.service.TranslationService; - -/** - * Global controller advice that adds common model attributes to all templates. Makes translation - * service available for client-side error message translation. - */ -@ControllerAdvice -@RequiredArgsConstructor -public class GlobalModelAdvice { - - private final TranslationService translationService; - - /** Add error messages to all templates for frontend translation support. */ - @ModelAttribute - public void addErrorMessages(Model model) { - model.addAttribute("errorMessages", translationService.getErrorMessages()); - } -} diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/TranslationController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/TranslationController.java new file mode 100644 index 000000000..606a49025 --- /dev/null +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/TranslationController.java @@ -0,0 +1,61 @@ +package stirling.software.SPDF.controller.api; + +import java.util.Arrays; + +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * API endpoint for on-demand error message translation. + * Provides translations for error messages when needed instead of pre-loading all translations. + */ +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +@Slf4j +public class TranslationController { + + private final MessageSource messageSource; + + /** + * Get translated error message for user's locale. + * + * @param key the translation key (e.g. "error.dpiExceedsLimit") + * @param args comma-separated arguments for message formatting + * @return translated message in user's locale + */ + @GetMapping("/translate") + public ResponseEntity translate( + @RequestParam String key, + @RequestParam(required = false) String args) { + + try { + Object[] messageArgs = null; + if (args != null && !args.trim().isEmpty()) { + messageArgs = Arrays.stream(args.split(",")) + .map(String::trim) + .toArray(); + } + + String translatedMessage = messageSource.getMessage( + key, + messageArgs, + LocaleContextHolder.getLocale() + ); + + return ResponseEntity.ok(translatedMessage); + + } catch (Exception e) { + log.debug("Translation failed for key '{}': {}", key, e.getMessage()); + return ResponseEntity.notFound().build(); + } + } +} \ No newline at end of file diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/service/TranslationService.java b/stirling-pdf/src/main/java/stirling/software/SPDF/service/TranslationService.java deleted file mode 100644 index f99372937..000000000 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/service/TranslationService.java +++ /dev/null @@ -1,79 +0,0 @@ -package stirling.software.SPDF.service; - -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.Properties; - -import org.springframework.context.MessageSource; -import org.springframework.context.i18n.LocaleContextHolder; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.support.PropertiesLoaderUtils; -import org.springframework.stereotype.Service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * Service for providing translation data to frontend JavaScript. Dynamically loads all error.* - * messages for client-side translation. - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class TranslationService { - - private final MessageSource messageSource; - - /** - * Get all error messages for the current locale to pass to frontend JavaScript. This allows - * dynamic translation of error messages sent from backend. - * - * @return Map of error message keys to localized values - */ - public Map getErrorMessages() { - return getErrorMessages(LocaleContextHolder.getLocale()); - } - - /** - * Get all error messages for a specific locale. - * - * @param locale the locale to get messages for - * @return Map of error message keys to localized values - */ - public Map getErrorMessages(Locale locale) { - Map errorMessages = new HashMap<>(); - - try { - // Load the base messages file to get all available keys - ClassPathResource resource = new ClassPathResource("messages_en_GB.properties"); - Properties properties = PropertiesLoaderUtils.loadProperties(resource); - - // Filter for error.* keys and get their localized values - for (Object keyObj : properties.keySet()) { - String key = (String) keyObj; - if (key.startsWith("error.")) { - try { - String localizedMessage = messageSource.getMessage(key, null, locale); - errorMessages.put(key, localizedMessage); - } catch (Exception e) { - log.debug( - "Could not resolve message for key '{}' in locale '{}': {}", - key, - locale, - e.getMessage()); - // Fallback to the default message from properties - errorMessages.put(key, (String) properties.get(key)); - } - } - } - - log.debug("Loaded {} error messages for locale '{}'", errorMessages.size(), locale); - - } catch (Exception e) { - log.error("Failed to load error messages for locale '{}': {}", locale, e.getMessage()); - } - - return errorMessages; - } -} diff --git a/stirling-pdf/src/main/resources/static/js/downloader.js b/stirling-pdf/src/main/resources/static/js/downloader.js index a393ee8b3..291c31084 100644 --- a/stirling-pdf/src/main/resources/static/js/downloader.js +++ b/stirling-pdf/src/main/resources/static/js/downloader.js @@ -345,16 +345,18 @@ return; } - // Handle structured error response with translation support - let displayMessage = json.message; + // Handle structured error response with async translation + let displayMessage = 'Loading...'; // Brief loading state - // If translation info is available, use MessageFormatter to translate if (json.translationKey && window.MessageFormatter) { - displayMessage = window.MessageFormatter.translate( + // Async translation with timeout and English fallback + displayMessage = await window.MessageFormatter.translateAsync( json.translationKey, json.translationArgs, - json.message // fallback to original message + json.message // English fallback ); + } else { + displayMessage = json.message; // Direct English message } showErrorBanner((json.error || 'Error') + ': ' + displayMessage, json.trace || ''); diff --git a/stirling-pdf/src/main/resources/static/js/messageFormatter.js b/stirling-pdf/src/main/resources/static/js/messageFormatter.js index cf86b4aaf..f92d394b8 100644 --- a/stirling-pdf/src/main/resources/static/js/messageFormatter.js +++ b/stirling-pdf/src/main/resources/static/js/messageFormatter.js @@ -1,66 +1,51 @@ /** - * Utility for formatting internationalized messages with placeholder replacement. - * Supports the {0}, {1}, {2}... placeholder format used by Java MessageFormat. + * Async translation utility with timeout and English fallback. + * Fetches translations on-demand from API with brief loading state. */ window.MessageFormatter = (function() { 'use strict'; /** - * Format a message template by replacing {0}, {1}, etc. placeholders with provided arguments. + * Translate error message with async API call and fallback to English. + * Shows brief loading, attempts translation, falls back to English on timeout/error. * - * @param {string} template - The message template with {0}, {1}, etc. placeholders - * @param {Array|string} args - Arguments to replace placeholders with. Can be array or individual arguments - * @returns {string} The formatted message with placeholders replaced - * - * @example - * formatMessage("Hello {0}, you have {1} messages", ["John", 5]) - * // Returns: "Hello John, you have 5 messages" - * - * formatMessage("Error {0}: {1}", "404", "Not Found") - * // Returns: "Error 404: Not Found" + * @param {string} translationKey - The translation key + * @param {Array} translationArgs - Arguments for message formatting + * @param {string} fallbackMessage - English fallback message + * @param {number} timeout - Timeout in milliseconds (default: 500ms) + * @returns {Promise} - Translated message or English fallback */ - function formatMessage(template, ...args) { - if (!template || typeof template !== 'string') { - return template || ''; + async function translateAsync(translationKey, translationArgs, fallbackMessage, timeout = 500) { + if (!translationKey) { + return fallbackMessage; } - - // Handle case where first argument is an array - const argumentArray = Array.isArray(args[0]) ? args[0] : args; - // Replace {0}, {1}, {2}, etc. with corresponding arguments - return template.replace(/\{(\d+)\}/g, function(match, index) { - const argIndex = parseInt(index, 10); - return argumentArray[argIndex] !== undefined && argumentArray[argIndex] !== null - ? String(argumentArray[argIndex]) - : match; // Keep original placeholder if no argument provided - }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const params = new URLSearchParams({ key: translationKey }); + if (translationArgs && translationArgs.length > 0) { + params.append('args', translationArgs.join(',')); + } + + const response = await fetch(`${window.stirlingPDF.translationApiUrl}?${params}`, { + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`Translation API returned ${response.status}`); + } + + return await response.text(); + } catch (error) { + clearTimeout(timeoutId); + console.debug('Translation failed, using English fallback:', error.message); + return fallbackMessage; + } } - /** - * Translate and format an error message using the global translation object. - * Falls back to the provided fallback message if translation not found. - * - * @param {string} translationKey - The translation key (e.g., "error.dpiExceedsLimit") - * @param {Array} translationArgs - Arguments for placeholder replacement - * @param {string} fallbackMessage - Fallback message if translation not found - * @returns {string} The translated and formatted message - */ - function translateAndFormat(translationKey, translationArgs, fallbackMessage) { - if (!window.stirlingPDF || !window.stirlingPDF.translations) { - return fallbackMessage || translationKey; - } - - const template = window.stirlingPDF.translations[translationKey]; - if (!template) { - return fallbackMessage || translationKey; - } - - return formatMessage(template, translationArgs || []); - } - - // Public API - return { - format: formatMessage, - translate: translateAndFormat - }; + return { translateAsync }; })(); \ No newline at end of file diff --git a/stirling-pdf/src/main/resources/templates/fragments/common.html b/stirling-pdf/src/main/resources/templates/fragments/common.html index 47cafff5c..6009be702 100644 --- a/stirling-pdf/src/main/resources/templates/fragments/common.html +++ b/stirling-pdf/src/main/resources/templates/fragments/common.html @@ -251,12 +251,8 @@ window.stirlingPDF.uploadLimit = /*[[${@uploadLimitService.getUploadLimit()}]]*/ 0; window.stirlingPDF.uploadLimitExceededSingular = /*[[#{uploadLimitExceededSingular}]]*/ 'is too large. Maximum allowed size is'; window.stirlingPDF.uploadLimitExceededPlural = /*[[#{uploadLimitExceededPlural}]]*/ 'are too large. Maximum allowed size is'; - window.stirlingPDF.pdfCorruptedMessage = /*[[#{error.pdfInvalid}]]*/ 'The PDF file "{0}" appears to be corrupted or has an invalid structure. Please try using the \'Repair PDF\' feature to fix the file before proceeding.'; - window.stirlingPDF.tryRepairMessage = /*[[#{error.tryRepair}]]*/ 'Try using the Repair PDF feature to fix corrupted files.'; - - // Translation service for dynamic error message translation - // Dynamically populated with all error.* messages for current locale - window.stirlingPDF.translations = /*[[${errorMessages}]]*/ {}; + // Translation API base URL for async error message translation + window.stirlingPDF.translationApiUrl = '/api/translate'; })();