Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions common/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ apply from: rootProject.file('buildSrc/shared.gradle')
// In this section you declare the dependencies for your production and test code
dependencies {
implementation(libs.findbugs.jsr305)

implementation(libs.icu4j)
implementation(libs.bundles.log4j)
implementation(libs.slf4j.simple)
implementation(libs.apache.commons.logging)

implementation(libs.apache.commons.lang)
implementation(libs.flexmark.all)
implementation(libs.gson)

implementation(libs.bundles.imageio)
Expand Down
220 changes: 177 additions & 43 deletions common/src/main/java/net/rptools/maptool/language/I18N.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,15 @@
*/
package net.rptools.maptool.language;

import java.text.MessageFormat;
import java.util.Enumeration;
import java.util.LinkedList;
import java.util.List;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import com.ibm.icu.text.MessageFormat;
import com.vladsch.flexmark.util.sequence.Escaping;
import java.util.*;
import java.util.function.BiFunction;
import java.util.regex.Pattern;
import javax.swing.Action;
import javax.swing.JMenu;
import javax.annotation.Nullable;
import javax.swing.*;
import net.rptools.lib.OsDetection;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

Expand All @@ -45,11 +44,13 @@ public class I18N {
private static final ResourceBundle BUNDLE;
private static final Logger log = LogManager.getLogger(I18N.class);
private static final String DESCRIPTION_EXTENSION = ".description";

private static final char MNEMONIC_MARKER = '&';
private static final Pattern MNEMONIC_PREFIX_PATTERN =
Pattern.compile(MNEMONIC_MARKER + "([a-z0-9])", Pattern.CASE_INSENSITIVE);
private static Enumeration<String> keys;

static {
// Put here to make breakpointing easier. :)
// Put here to make break-pointing easier. :)
BUNDLE = ResourceBundle.getBundle("net.rptools.maptool.language.i18n");
I18nTools report = new I18nTools(false);
}
Expand All @@ -72,17 +73,6 @@ public static JMenu createMenu(String key) {
return menu;
}

/**
* Returns the description text for the given key. This text normally appears in the statusbar of
* the main application frame. The input key has the string DESCRIPTION_EXTENSION appended to it.
*
* @param key the key to use for the i18n lookup.
* @return the i81n version of the string.
*/
public static String getDescription(String key) {
return getString(key + DESCRIPTION_EXTENSION);
}

/**
* Returns the character to use as the menu mnemonic for the given key. This method searches the
* properties file for the given key. If the value contains an ampersand ("&amp;") the character
Expand All @@ -91,19 +81,34 @@ public static String getDescription(String key) {
* @param key the component to search for
* @return the character to use as the mnemonic (as an <code>int</code>)
*/
public static int getMnemonic(String key) {
private static int getMnemonic(String key) {
String value = getString(key);
if (value == null || value.length() < 2) return -1;

int index = value.indexOf('&');
if (value == null || value.length() < 2) {
return -1;
}
// replace HTML entities with characters to prevent spurious results - should not happen but
// this is not Utopia
value = convertText.apply(value, false);
int index = value.indexOf(MNEMONIC_MARKER);
if (index != -1 && index + 1 < value.length()) {
return Character.toUpperCase(value.charAt(index + 1));
}
return -1;
}

/**
* Returns the String that results from a lookup within the properties file.
* Returns the description text for the given key. This text normally appears in the status-bar of
* the main application frame. The input key has the string DESCRIPTION_EXTENSION appended to it.
*
* @param key the key to use for the i18n lookup.
* @return the i81n version of the string.
*/
private static String getDescription(String key) {
return getString(key + DESCRIPTION_EXTENSION);
}

/**
* Returns the String matching the key within the properties file.
*
* @param key the component to search for
* @param bundle the resource bundle to get the i18n string from.
Expand All @@ -118,7 +123,7 @@ public static String getString(String key, ResourceBundle bundle) {
}

/**
* Returns the String that results from a lookup within the properties file.
* Returns the String matching the key within the properties file.
*
* @param key the component to search for
* @return the String found or <code>null</code>
Expand All @@ -132,9 +137,9 @@ public static String getString(String key) {
}

/**
* Returns the text associated with the given key after removing any menu mnemonic. So for the key
* <b>action.loadMap</b> that has the value {@code &Load Map} in the properties file, this method
* returns "Load Map".
* Returns the String matching the key within the properties file after removing any menu
* mnemonic. So for the key <b>action.loadMap</b> that has the value {@code &Load Map} in the
* properties file, this method returns "Load Map".
*
* @param key the component to search for
* @return the String found with mnemonics removed, or the input key if not found
Expand All @@ -150,32 +155,161 @@ public static String getText(String key) {
log.debug("Cannot find key '" + key + "' in properties file.");
return key;
}
return value.replaceAll("\\&", "");
// remove mnemonic marker and return value
return convertText.apply(value, true);
}

/**
* Functionally identical to {@link #getText(String key)} except that this one bundles the
* formatting calls into this code. This version of the method is truly only needed when the
* string being retrieved contains parameters. In MapTool, this commonly means the player's name
* or a filename. See the "Parameterized Strings" section of the <b>i18n.properties</b> file for
* example usage. Full documentation for this technique can be found under {@link
* MessageFormat#format}.
* To avoid breaking HTML encoded characters when dealing with &amp;, e.g. <code>
* &amp;lt;div&amp;gt;</code> for <code>&lt;div&gt;</code>, or returning a false positive for a
* mnemonic key, we need to replace entities with their actual characters first.
*/
private static final BiFunction<String, Boolean, String> convertText =
(string, stripAmpersand) -> {
if (string.indexOf(MNEMONIC_MARKER) == -1) {
return string;
} else {
string = Escaping.unescapeString(string);
if (stripAmpersand) {
return MNEMONIC_PREFIX_PATTERN.matcher(string).replaceAll("$1");
}
return Escaping.escapeHtml(string, false);
}
};

/**
* Simple functionality &ndash; similar to {@link #getText(String key)} &ndash; with simple
* indexed argument replacement. Use this version where the target string pattern contains
* placeholders in the form <code>{n}</code> where n is an integer.
*
* <p>See the "Parameterised Strings" section of the <b>i18n.properties</b> file for example
* usage. Full documentation for this technique can be found under {@link
* MessageFormat#format(String, Object...)}.
*
* @param key the <code>propertyKey</code> to use for lookup in the properties file
* @param args parameters needed for formatting purposes
* @param args parameters (in order) needed for formatting purposes
* @return the formatted String
*/
public static String getText(String key, Object... args) {
// If the key doesn't exist in the file, the key becomes the format and
// nothing will be substituted; it's a little extra work, but is not the normal case
// anyway.
String msg = MessageFormat.format(getText(key), args);
return msg;
return java.text.MessageFormat.format(getText(key), args);
}

/** Used as a named argument in selecting between different messages. */
public enum MessageType {
brief,
verbose,
qualified,
qualifiedOptions,
custom,
other, // the default value in select statements
}

@SafeVarargs
public static String getMessage(
String key, MessageType messageType, Pair<String, Object>... namedArguments) {
return getMessage(key, messageType, Arrays.stream(namedArguments).toList());
}

public static String getMessage(
String key, MessageType messageType, List<Pair<String, Object>> namedArguments) {
Map<String, Object> namedArgs = new HashMap<>();
if (messageType == null) {
namedArgs.put("messageType", "");
} else {
namedArgs.put("messageType", messageType.toString());
}
for (Pair<String, Object> pair : namedArguments) {
namedArgs.put(pair.getKey(), pair.getValue());
}
try {
return MessageFormat.format(getText(key), namedArgs);
} catch (IllegalArgumentException iae) {
log.error(iae.getMessage(), iae);
return "";
}
}

public static String getMessage(String key, MessageType messageType) {
return getMessage(key, messageType, null, null);
}

public static String getMessage(String key, MessageType messageType, String functionName) {
return getMessage(key, messageType, functionName, null);
}

public static String getMessage(
String key, MessageType messageType, String functionName, Object paramIndex) {
return getMessage(key, messageType, functionName, paramIndex, null);
}

public static String getMessage(
String key,
MessageType messageType,
String functionName,
Object paramIndex,
Object paramValue) {
return getMessage(key, messageType, functionName, paramIndex, paramValue, null);
}

public static String getMessage(
String key,
MessageType messageType,
String functionName,
Object paramIndex,
Object paramValue,
Object results) {
return getMessage(key, messageType, functionName, paramIndex, paramValue, results, null);
}

public static String getMessage(
String key,
MessageType messageType,
String functionName,
Object paramIndex,
Object paramValue,
Object results,
Object options) {
return getMessage(
key, messageType, functionName, paramIndex, paramValue, results, options, null);
}

public static String getMessage(
String key,
MessageType messageType,
String functionName,
Object paramIndex,
Object paramValue,
Object results,
Object options,
@Nullable Pair<String, Object>[] namedArguments) {
List<Pair<String, Object>> pairs = new ArrayList<>();
if (namedArguments != null) {
pairs.addAll(List.of(namedArguments));
}
if (functionName != null) {
pairs.add(Pair.of("functionName", functionName));
}
if (paramIndex instanceof String string && !string.isBlank() || paramIndex instanceof Integer) {
pairs.add(Pair.of("paramIndex", paramIndex));
}
if (paramValue != null) {
pairs.add(Pair.of("paramValue", paramValue));
}
if (results != null) {
pairs.add(Pair.of("results", results));
}
if (options != null) {
pairs.add(Pair.of("options", options));
}
return getMessage(key, messageType, pairs);
}

/**
* Set all of the I18N values on an <code>Action</code> by retrieving said values from the
* properties file.
* Set all the I18N values on an <code>Action</code> by retrieving said values from the properties
* file.
*
* <p>Uses the <code>key</code> as the index for the properties file to set the <b>Action.NAME</b>
* field of <b>action</b>.
Expand Down Expand Up @@ -226,7 +360,7 @@ public static List<String> getMatchingKeys(String regex) {
public static List<String> getMatchingKeys(Pattern regex) {
Enumeration<String> keys = BUNDLE.getKeys();

List<String> menuItemKeys = new LinkedList<String>();
List<String> menuItemKeys = new LinkedList<>();
while (keys.hasMoreElements()) {
String key = keys.nextElement();
if (regex.matcher(key).find()) {
Expand Down
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ apache-commons-logging = { group = "commons-logging", name = "commons-logging",
sentry = { group = "io.sentry", name = "sentry", version.ref = "sentry" }
sentry-log4j = { group = "io.sentry", name = "sentry-log4j2", version.ref = "sentry" }

#I18n
icu4j = { group = "com.ibm.icu", name = "icu4j", version = "78.3" }

# Networking
# Web RTC
websocket = { group = "org.java-websocket", name = "Java-WebSocket", version = "1.6.0" }
Expand Down
Loading
Loading