package com.zy.common.i18n;
|
|
import com.core.common.Cools;
|
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.stereotype.Service;
|
import org.springframework.context.i18n.LocaleContextHolder;
|
|
import java.io.File;
|
import java.io.BufferedReader;
|
import java.io.FileInputStream;
|
import java.io.IOException;
|
import java.io.InputStream;
|
import java.io.InputStreamReader;
|
import java.nio.charset.StandardCharsets;
|
import java.text.MessageFormat;
|
import java.util.*;
|
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.regex.Matcher;
|
import java.util.regex.Pattern;
|
import java.util.regex.PatternSyntaxException;
|
|
@Service
|
public class I18nMessageService {
|
|
private static final String MESSAGE_BUNDLE = "messages";
|
private static final String LEGACY_BUNDLE = "legacy";
|
|
private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
|
@Autowired
|
private I18nProperties properties;
|
|
public Locale getCurrentLocale() {
|
Locale locale = LocaleContextHolder.getLocale();
|
return locale == null ? I18nLocaleUtils.defaultLocale(properties) : locale;
|
}
|
|
public Locale resolveLocale(String requested) {
|
return I18nLocaleUtils.resolveLocale(requested, properties);
|
}
|
|
public String getDefaultLocaleTag() {
|
return properties.getDefaultLocale();
|
}
|
|
public List<String> getSupportedLocaleTags() {
|
return properties.getSupportedLocales();
|
}
|
|
public String getMessage(String key, Object... args) {
|
return getMessage(key, getCurrentLocale(), args);
|
}
|
|
public String getMessage(String key, Locale locale, Object... args) {
|
if (Cools.isEmpty(key)) {
|
return "";
|
}
|
Locale resolvedLocale = locale == null ? I18nLocaleUtils.defaultLocale(properties) : locale;
|
String value = mergedBundle(resolvedLocale, MESSAGE_BUNDLE).get(key);
|
if (value == null && !I18nLocaleUtils.isDefaultLocale(resolvedLocale, properties)) {
|
value = mergedBundle(I18nLocaleUtils.defaultLocale(properties), MESSAGE_BUNDLE).get(key);
|
}
|
if (value == null) {
|
return key;
|
}
|
return args == null || args.length == 0 ? value : MessageFormat.format(value, args);
|
}
|
|
public boolean hasMessage(String key, Locale locale) {
|
return mergedBundle(locale == null ? I18nLocaleUtils.defaultLocale(properties) : locale, MESSAGE_BUNDLE).containsKey(key);
|
}
|
|
public Map<String, String> getMessages(Locale locale) {
|
return new LinkedHashMap<>(mergedBundle(locale, MESSAGE_BUNDLE));
|
}
|
|
public Map<String, String> getLegacyMessages(Locale locale) {
|
return new LinkedHashMap<>(mergedBundle(locale, LEGACY_BUNDLE));
|
}
|
|
public String translateLegacy(String text) {
|
return translateLegacy(text, getCurrentLocale());
|
}
|
|
public String translateLegacy(String text, Locale locale) {
|
if (Cools.isEmpty(text)) {
|
return text;
|
}
|
Locale resolvedLocale = locale == null ? I18nLocaleUtils.defaultLocale(properties) : locale;
|
if (I18nLocaleUtils.isDefaultLocale(resolvedLocale, properties)) {
|
return text;
|
}
|
Map<String, String> bundle = mergedBundle(resolvedLocale, LEGACY_BUNDLE);
|
String direct = directTranslate(text, bundle);
|
if (!text.equals(direct)) {
|
return direct;
|
}
|
String regex = regexTranslate(text, bundle);
|
if (!text.equals(regex)) {
|
return regex;
|
}
|
return fragmentTranslate(text, bundle);
|
}
|
|
public String resolveResourceText(String fallbackName, String code, Long id) {
|
Locale locale = getCurrentLocale();
|
String key = resourceKey(code, id);
|
if (hasMessage(key, locale)) {
|
return getMessage(key, locale);
|
}
|
if (!I18nLocaleUtils.isDefaultLocale(locale, properties)) {
|
String humanized = I18nLocaleUtils.humanizeCode(code);
|
if (!Cools.isEmpty(humanized)) {
|
return humanized;
|
}
|
}
|
return fallbackName;
|
}
|
|
public String resolvePermissionText(String fallbackName, String action, Long id) {
|
Locale locale = getCurrentLocale();
|
String key = permissionKey(action, id);
|
if (hasMessage(key, locale)) {
|
return getMessage(key, locale);
|
}
|
if (!I18nLocaleUtils.isDefaultLocale(locale, properties)) {
|
String humanized = I18nLocaleUtils.humanizeCode(action);
|
if (!Cools.isEmpty(humanized)) {
|
return humanized;
|
}
|
}
|
return fallbackName;
|
}
|
|
public String resourceKey(String code, Long id) {
|
return "resource." + normalizeKey(code, id, "resource");
|
}
|
|
public String permissionKey(String action, Long id) {
|
return "permission." + normalizeKey(action, id, "permission");
|
}
|
|
private Map<String, String> mergedBundle(Locale locale, String bundleName) {
|
Locale resolvedLocale = locale == null ? I18nLocaleUtils.defaultLocale(properties) : locale;
|
LinkedHashMap<String, String> merged = new LinkedHashMap<>();
|
Locale defaultLocale = I18nLocaleUtils.defaultLocale(properties);
|
merged.putAll(loadBundle(defaultLocale, bundleName));
|
if (!I18nLocaleUtils.toTag(defaultLocale).equalsIgnoreCase(I18nLocaleUtils.toTag(resolvedLocale))) {
|
merged.putAll(loadBundle(resolvedLocale, bundleName));
|
}
|
return merged;
|
}
|
|
private Map<String, String> loadBundle(Locale locale, String bundleName) {
|
String localeTag = I18nLocaleUtils.toTag(locale);
|
String cacheKey = localeTag + ":" + bundleName;
|
CacheEntry cacheEntry = cache.get(cacheKey);
|
File externalFile = externalBundle(localeTag, bundleName);
|
long refreshMillis = Math.max(1, properties.getRefreshSeconds()) * 1000L;
|
long externalLastModified = externalFile.exists() ? externalFile.lastModified() : -1L;
|
if (cacheEntry != null
|
&& (System.currentTimeMillis() - cacheEntry.loadedAt) < refreshMillis
|
&& cacheEntry.externalLastModified == externalLastModified) {
|
return cacheEntry.values;
|
}
|
synchronized (cache.computeIfAbsent(cacheKey, key -> new CacheEntry())) {
|
CacheEntry latest = cache.get(cacheKey);
|
if (latest != null
|
&& (System.currentTimeMillis() - latest.loadedAt) < refreshMillis
|
&& latest.externalLastModified == externalLastModified) {
|
return latest.values;
|
}
|
LinkedHashMap<String, String> values = new LinkedHashMap<>();
|
readClasspathBundle(values, localeTag, bundleName);
|
readExternalBundle(values, externalFile);
|
CacheEntry updated = new CacheEntry();
|
updated.values = values;
|
updated.loadedAt = System.currentTimeMillis();
|
updated.externalLastModified = externalLastModified;
|
cache.put(cacheKey, updated);
|
return updated.values;
|
}
|
}
|
|
private void readClasspathBundle(Map<String, String> values, String localeTag, String bundleName) {
|
ClassPathResource resource = new ClassPathResource("i18n/" + localeTag + "/" + bundleName + ".properties");
|
if (!resource.exists()) {
|
return;
|
}
|
try (InputStream inputStream = resource.getInputStream()) {
|
loadProperties(values, inputStream);
|
} catch (IOException ex) {
|
throw new IllegalStateException("Failed to load classpath i18n bundle: " + resource.getPath(), ex);
|
}
|
}
|
|
private void readExternalBundle(Map<String, String> values, File file) {
|
if (!file.exists() || !file.isFile()) {
|
return;
|
}
|
try (InputStream inputStream = new FileInputStream(file)) {
|
loadProperties(values, inputStream);
|
} catch (IOException ex) {
|
throw new IllegalStateException("Failed to load external i18n bundle: " + file.getAbsolutePath(), ex);
|
}
|
}
|
|
private void loadProperties(Map<String, String> values, InputStream inputStream) throws IOException {
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
|
String line;
|
while ((line = reader.readLine()) != null) {
|
parsePropertyLine(values, line);
|
}
|
}
|
}
|
|
private void parsePropertyLine(Map<String, String> values, String line) {
|
if (line == null) {
|
return;
|
}
|
String trimmed = line.trim();
|
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("!")) {
|
return;
|
}
|
int separatorIndex = findSeparator(line);
|
String rawKey = separatorIndex >= 0 ? line.substring(0, separatorIndex) : line;
|
String rawValue = separatorIndex >= 0 ? line.substring(separatorIndex + 1) : "";
|
while (!rawValue.isEmpty() && Character.isWhitespace(rawValue.charAt(0))) {
|
rawValue = rawValue.substring(1);
|
}
|
String key = unescapePropertyToken(rawKey);
|
String value = unescapePropertyToken(rawValue);
|
if (!key.isEmpty()) {
|
values.put(key, value);
|
}
|
}
|
|
private int findSeparator(String line) {
|
boolean escaping = false;
|
for (int i = 0; i < line.length(); i++) {
|
char ch = line.charAt(i);
|
if (escaping) {
|
escaping = false;
|
continue;
|
}
|
if (ch == '\\') {
|
escaping = true;
|
continue;
|
}
|
if (ch == '=' || ch == ':') {
|
return i;
|
}
|
}
|
return -1;
|
}
|
|
private String unescapePropertyToken(String text) {
|
StringBuilder builder = new StringBuilder(text.length());
|
boolean escaping = false;
|
for (int i = 0; i < text.length(); i++) {
|
char ch = text.charAt(i);
|
if (!escaping) {
|
if (ch == '\\') {
|
escaping = true;
|
} else {
|
builder.append(ch);
|
}
|
continue;
|
}
|
switch (ch) {
|
case 't':
|
builder.append('\t');
|
break;
|
case 'r':
|
builder.append('\r');
|
break;
|
case 'n':
|
builder.append('\n');
|
break;
|
case 'f':
|
builder.append('\f');
|
break;
|
case 'u':
|
if (i + 4 < text.length()) {
|
String hex = text.substring(i + 1, i + 5);
|
try {
|
builder.append((char) Integer.parseInt(hex, 16));
|
i += 4;
|
break;
|
} catch (NumberFormatException ex) {
|
builder.append('u');
|
break;
|
}
|
}
|
builder.append('u');
|
break;
|
default:
|
builder.append(ch);
|
break;
|
}
|
escaping = false;
|
}
|
if (escaping) {
|
builder.append('\\');
|
}
|
return builder.toString();
|
}
|
|
private File externalBundle(String localeTag, String bundleName) {
|
return new File(properties.getPackPath(), localeTag + File.separator + bundleName + ".properties");
|
}
|
|
private String directTranslate(String text, Map<String, String> bundle) {
|
String trimmed = text.trim();
|
String translated = bundle.get(trimmed);
|
if (translated == null) {
|
return text;
|
}
|
return preserveOuterWhitespace(text, translated);
|
}
|
|
private String regexTranslate(String text, Map<String, String> bundle) {
|
String trimmed = text == null ? "" : text.trim();
|
for (Map.Entry<String, String> entry : bundle.entrySet()) {
|
String key = entry.getKey();
|
if (Cools.isEmpty(key) || !key.startsWith("regex:")) {
|
continue;
|
}
|
String patternText = key.substring("regex:".length());
|
if (Cools.isEmpty(patternText)) {
|
continue;
|
}
|
try {
|
Pattern pattern = Pattern.compile(patternText);
|
if (!trimmed.isEmpty()) {
|
Matcher trimmedMatcher = pattern.matcher(trimmed);
|
if (trimmedMatcher.matches()) {
|
return preserveOuterWhitespace(text, trimmedMatcher.replaceAll(entry.getValue()));
|
}
|
}
|
Matcher matcher = pattern.matcher(text);
|
if (matcher.find()) {
|
return matcher.replaceAll(entry.getValue());
|
}
|
} catch (PatternSyntaxException ex) {
|
// Ignore invalid regex entries so a bad pack does not break all translations.
|
}
|
}
|
return text;
|
}
|
|
private String fragmentTranslate(String text, Map<String, String> bundle) {
|
List<Map.Entry<String, String>> entries = new ArrayList<>(bundle.entrySet());
|
entries.sort((left, right) -> Integer.compare(right.getKey().length(), left.getKey().length()));
|
String result = text;
|
for (Map.Entry<String, String> entry : entries) {
|
if (Cools.isEmpty(entry.getKey())
|
|| entry.getKey().length() < 2
|
|| entry.getKey().startsWith("regex:")
|
|| entry.getKey().equals(entry.getValue())) {
|
continue;
|
}
|
result = result.replace(entry.getKey(), entry.getValue());
|
}
|
return result;
|
}
|
|
private String preserveOuterWhitespace(String original, String translated) {
|
String trimmed = original == null ? "" : original.trim();
|
if (trimmed.isEmpty()) {
|
return translated;
|
}
|
int leading = original.indexOf(trimmed);
|
int trailing = original.length() - leading - trimmed.length();
|
StringBuilder builder = new StringBuilder();
|
if (leading > 0) {
|
builder.append(original, 0, leading);
|
}
|
builder.append(translated);
|
if (trailing > 0) {
|
builder.append(original.substring(original.length() - trailing));
|
}
|
return builder.toString();
|
}
|
|
private String normalizeKey(String raw, Long id, String prefix) {
|
if (Cools.isEmpty(raw)) {
|
return prefix + "." + (id == null ? "unknown" : id);
|
}
|
String normalized = raw.replaceAll("\\.html", "");
|
normalized = normalized.replaceAll("[^A-Za-z0-9]+", ".");
|
normalized = normalized.replaceAll("\\.+", ".");
|
normalized = normalized.replaceAll("^\\.|\\.$", "");
|
if (Cools.isEmpty(normalized)) {
|
return prefix + "." + (id == null ? "unknown" : id);
|
}
|
return normalized;
|
}
|
|
private static class CacheEntry {
|
private Map<String, String> values = new LinkedHashMap<>();
|
private long loadedAt;
|
private long externalLastModified = -1L;
|
}
|
}
|