This commit is contained in:
Anthony Stirling 2025-07-02 15:15:46 +01:00
parent 558e35de62
commit ce417469d4
10 changed files with 135 additions and 243 deletions

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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
- `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
);
```

View File

@ -58,25 +58,6 @@ public class GlobalExceptionHandler {
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(stirling.software.common.util.TranslatableIOException.class)
public ResponseEntity<ErrorResponse> handleTranslatableIOException(
stirling.software.common.util.TranslatableIOException e) {
List<String> 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<ErrorResponse> handleIllegalArgumentException(
IllegalArgumentException e) {

View File

@ -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());
}
}

View File

@ -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<String> 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();
}
}
}

View File

@ -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<String, String> 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<String, String> getErrorMessages(Locale locale) {
Map<String, String> 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;
}
}

View File

@ -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 || '');

View File

@ -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<string>} - 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;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// 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
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}`);
}
/**
* 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;
return await response.text();
} catch (error) {
clearTimeout(timeoutId);
console.debug('Translation failed, using English fallback:', error.message);
return fallbackMessage;
}
}
const template = window.stirlingPDF.translations[translationKey];
if (!template) {
return fallbackMessage || translationKey;
}
return formatMessage(template, translationArgs || []);
}
// Public API
return {
format: formatMessage,
translate: translateAndFormat
};
return { translateAsync };
})();

View File

@ -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';
})();
</script>
<script type="module" th:src="@{'/pdfjs-legacy/pdf.mjs'}"></script>