magarena/src/magic/translate/MText.java

324 lines
13 KiB
Java

package magic.translate;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Scanner;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.zip.CRC32;
import groovy.json.StringEscapeUtils;
import magic.data.GeneralConfig;
import magic.data.settings.BooleanSetting;
import magic.utility.MagicFileSystem;
import magic.utility.MagicSystem;
public final class MText {
private MText() { }
private static final Logger LOGGER = Logger.getLogger(MText.class.getName());
private static final String UTF_CHAR_SET = "UTF-8";
private static final String HEADER_CHAR = "¦";
// Not sure if it is a bug or by design but if no UTF character is written
// then (on Windows 7 anyway) it ignores UTF_CHAR_SET and encodes as ANSI.
// If you subsequently overwrite the template strings with translations that do
// use unicode characters no error is thrown but the translations are not loaded
// either. Therefore since the template file uses a prefix character to highlight
// untranslated strings in the UI use a unicode character to force correct encoding.
private static final String UTF_PREFIX = "\u25AB"; // small white square ▫
// Delimiter used to separate an abbreviation and the whole word/phrase.
// Needs to be something that will never appear in a normal English sentence.
private static final String ABBREVIATOR = "|+";
private static final CRC32 crc = new CRC32();
private static final Map<Long, String> translationsMap = new HashMap<>();
private static final Map<Long, String> annotations = new HashMap<>();
private static boolean useCustomFonts = false;
static {
try {
loadTranslationFile();
} catch (Exception ex) {
Logger.getLogger(MText.class.getName()).log(Level.WARNING, null, ex);
}
}
/**
* Converts (English) string into CRC32 number which is used
* as the mapping ID to identify the equivalent translation.
*/
private static Long getStringId(final String aString) {
crc.reset();
try {
crc.update(aString.getBytes(UTF_CHAR_SET));
return crc.getValue();
} catch (UnsupportedEncodingException ex) {
System.err.println(ex);
translationsMap.clear();
return 0L;
}
}
private static String getDisplayText(String text) {
return text.contains(ABBREVIATOR)
? text.substring(0, text.indexOf(ABBREVIATOR))
: text;
}
private static boolean isTranslating() {
return !translationsMap.isEmpty();
}
public static final String get(final String aString, final Object... args) {
if (isTranslating()) {
final Long stringId = getStringId(aString);
if (translationsMap.containsKey(stringId)) {
return String.format(translationsMap.get(stringId), args);
}
}
return getDisplayText(String.format(aString, args));
}
public static final String get(final String aString) {
if (isTranslating()) {
final Long stringId = getStringId(aString);
if (translationsMap.containsKey(stringId)) {
return translationsMap.get(stringId);
}
}
return getDisplayText(aString);
}
/**
* Returns translated string enclosed in {@literal <html>...</html>} tags.
*
* This is useful for automatically wrapping long strings.
*/
public static final String asHtml(final String aString) {
return "<html>" + get(aString) + "</html>";
}
private static Map<Long, String> getStringsMapFromFile(final File txtFile, final boolean unescape) throws FileNotFoundException {
final Map<Long, String> stringsMap = new LinkedHashMap<>();
try (final Scanner sc = new Scanner(txtFile, UTF_CHAR_SET)) {
while (sc.hasNextLine()) {
final String line = sc.nextLine().trim();
if (line.startsWith("#") || line.isEmpty()) {
// ignore comments and blank lines.
continue;
}
if (line.startsWith(HEADER_CHAR)) {
parseHeaderLine(line, txtFile.getName());
} else {
int equalsChar = line.indexOf('=');
long stringId = Long.valueOf(line.substring(0, equalsChar).trim());
String translation = line.substring(equalsChar + 1).trim();
stringsMap.put(stringId, unescape ? StringEscapeUtils.unescapeJava(translation) : translation);
}
}
}
return stringsMap;
}
private static void parseHeaderLine(String text, String fileName) {
String[] values = text.substring(1).split(HEADER_CHAR);
try {
// version = values[0];
useCustomFonts = Integer.valueOf(values[1]) == 1;
} catch (ArrayIndexOutOfBoundsException ex) {
LOGGER.log(Level.INFO, String.format(
"Parsing header line in '%s' at item : %s",
fileName, ex.getMessage())
);
} catch (RuntimeException ex) {
LOGGER.log(Level.WARNING, String.format(
"Error parsing header line in '%s' : %s",
fileName, ex.getMessage())
);
}
}
public static Map<Long, String> getUnescapedStringsMap(final File txtFile) throws FileNotFoundException {
return getStringsMapFromFile(txtFile, true);
}
public static Map<Long, String> getEscapedStringsMap(final File txtFile) throws FileNotFoundException {
return getStringsMapFromFile(txtFile, false);
}
public static void loadTranslationFile() throws FileNotFoundException {
useCustomFonts = false;
translationsMap.clear();
final String language = GeneralConfig.getInstance().getTranslation();
if (language.isEmpty()) {
useCustomFonts = true;
} else {
final Path dirPath = MagicFileSystem.getDataPath(MagicFileSystem.DataPath.TRANSLATIONS);
final File txtFile = dirPath.resolve(language + ".txt").toFile();
translationsMap.putAll(getUnescapedStringsMap(txtFile));
}
}
/**
* Returns the names of all classes in specified package (including sub-packages).
*/
public static List<String> getClassNamesInPackage(final File jarFile, String packageName) throws IOException {
final List<String> classes = new ArrayList<>();
if (jarFile == null || !jarFile.exists() || !jarFile.isFile()) {
throw new IOException("Unable to locate JAR file!\n\nTo manually specify the location please use the '-DjarFile' VM option.");
}
try (JarInputStream jarStream = new JarInputStream(new FileInputStream(jarFile))) {
packageName = packageName.replaceAll("\\.", "/");
while (true) {
final JarEntry jarEntry = jarStream.getNextJarEntry();
if (jarEntry == null) {
break;
}
final String entryName = jarEntry.getName();
if (entryName.startsWith(packageName) && entryName.endsWith(".class")) {
classes.add(entryName.replaceAll("/", "\\."));
}
}
}
return classes;
}
/**
* Use reflection to find all _S* strings.
*/
public static Map<Long, String> getUiStringsMap() throws URISyntaxException, IOException {
final Map<Long, String> stringsMap = new LinkedHashMap<>();
annotations.clear();
for (final String c : getClassNamesInPackage(MagicSystem.getJarFile(), "magic")) {
final String className = c.substring(0, c.length() - ".class".length());
try {
for (final Field f : Class.forName(className).getDeclaredFields()) {
final boolean isFieldValid =
f.getType() == String.class
&& f.getName().startsWith("_S")
&& Modifier.isStatic(f.getModifiers()); // prevents a UnsafeObjectFieldAccessorImpl error.
if (isFieldValid) {
f.setAccessible(true);
try {
final String fieldValue = (String) f.get(null);
final Long stringId = getStringId(fieldValue);
final String stringValue = UTF_PREFIX + StringEscapeUtils.escapeJava(getDisplayText(fieldValue));
if (!stringsMap.containsKey(stringId)) {
stringsMap.put(stringId, stringValue);
if (f.getAnnotation(StringContext.class) != null) {
annotations.put(stringId, f.getAnnotation(StringContext.class).eg());
}
} else if (!stringValue.equals(stringsMap.get(stringId))) {
throw new RuntimeException(
"Failed to generate translation file because the following strings have the same CRC32 value:-\n" +
stringValue + "\n" + stringsMap.get(stringId));
}
} catch (IllegalAccessException ex) {
System.err.println(ex);
}
}
}
} catch (ClassNotFoundException ex) {
System.err.println(ex);
}
}
return stringsMap;
}
private static String getHeaderLineData() {
return HEADER_CHAR + MagicSystem.VERSION + HEADER_CHAR + (useCustomFonts ? 1 : 0);
}
public static void createTranslationFile(File txtFile, Map<Long, String> stringsMap) throws FileNotFoundException, UnsupportedEncodingException {
try (final PrintWriter writer = new PrintWriter(txtFile, UTF_CHAR_SET)) {
writer.print(getHeaderLineData() + "\n");
for (Map.Entry<Long, String> entry : stringsMap.entrySet()) {
final Long key = entry.getKey();
if (annotations.containsKey(key)) {
writer.print(String.format("# %010d eg. %s\n", key, annotations.get(key)));
}
// CRC32 function returns 32 bit long = max 10 numerals. Pad if smaller.
writer.print(String.format("%010d = %s\n", key, entry.getValue()));
}
}
}
public static void createTranslationFIle(File txtFile) throws URISyntaxException, IOException {
createTranslationFile(txtFile, getUiStringsMap());
}
public static void disableTranslations() {
translationsMap.clear();
}
public static boolean isEnglish() {
return translationsMap.isEmpty();
}
public static String getTranslationVersion(String lang) {
if (!"English".equals(lang)) {
File file = MagicFileSystem.getTranslationFile(lang);
try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), UTF_CHAR_SET))) {
String line = br.readLine();
if (line != null && line.startsWith(HEADER_CHAR)) {
return line.substring(1).trim().split(HEADER_CHAR)[0];
}
} catch (IOException ex) {
LOGGER.log(Level.WARNING, null, ex);
}
}
return "";
}
public static boolean canUseCustomFonts() {
return useCustomFonts && GeneralConfig.get(BooleanSetting.CUSTOM_FONTS);
}
/**
* This is used to create a string consisting of an abbreviation and it's
* associated word or phrase. Only the abbreviation will be displayed in the
* UI or require translation whereas the whole string will be used to
* generate the CRC ID for the translation file entry.
*
* An example of where this is necessary is when "P" is used to mean
* games [P]layed or a creature's [P]ower. If the string "P" is used on its
* own then the CRC will be the same for both cases which is fine in English
* but the chances of "Played" and "Power" using the same abbreviation when
* translated is very unlikely. By combining the abbreviation and its
* meaning there is a much less chance of the CRCs being the same.
*/
public static String abbreviate(String s1, String s2) {
return s1 + ABBREVIATOR + s2;
}
}