messageParams;
+ protected String msgKey;
+
+ protected AbstractMessageFactory(final String i18nKey) {
+ this.msgKey = i18nKey;
+ messageParams = new HashMap<>();
+ }
+
+ /** Persuade likely value types to something meaningful */
+ protected String stringify(Object value) {
+ return switch (value) {
+ case JsonElement je -> je.toString();
+ case List> list -> Arrays.deepToString(list.toArray());
+ case Object[] array -> Arrays.deepToString(array);
+ case null, default -> String.valueOf(value);
+ };
+ }
+
+ public AbstractMessageFactory namedValue(final String name, final Object value) {
+ messageParams.put(name, stringify(value));
+ return this;
+ }
+
+ public String build() {
+ return I18N.getMessage(msgKey, messageParams);
+ }
+}
diff --git a/common/src/main/java/net/rptools/maptool/language/I18N.java b/common/src/main/java/net/rptools/maptool/language/I18N.java
index 2e2a429ac9..20e78ae092 100644
--- a/common/src/main/java/net/rptools/maptool/language/I18N.java
+++ b/common/src/main/java/net/rptools/maptool/language/I18N.java
@@ -14,16 +14,13 @@
*/
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.regex.Pattern;
-import javax.swing.Action;
-import javax.swing.JMenu;
+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;
@@ -35,21 +32,24 @@
*
* As MapTool uses a base name for the string and extensions for alternate pieces (such as
* action.loadMap as the base and action.loadMap.accel as the menu accelerator
- * key) there are different methods used to return the different components.
+ * key) different methods are used to return the different components.
*
*
The ResourceBundle name is net.rptools.maptool.language.i18n.
*
* @author tcroft
*/
+@SuppressWarnings("unused")
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 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);
}
@@ -72,30 +72,23 @@ 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 ("&") the character
- * following the ampersand is converted to uppercase and returned.
+ * properties file for the given key. Where the value contains an ampersand ("&") the
+ * following character is converted to uppercase and returned.
*
* @param key the component to search for
* @return the character to use as the mnemonic (as an int)
*/
- 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 = replaceHtmlEntities(value, false);
+ int index = value.indexOf(MNEMONIC_MARKER);
if (index != -1 && index + 1 < value.length()) {
return Character.toUpperCase(value.charAt(index + 1));
}
@@ -103,7 +96,18 @@ public static int getMnemonic(String key) {
}
/**
- * 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.
@@ -118,7 +122,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 null
@@ -132,9 +136,9 @@ public static String getString(String key) {
}
/**
- * Returns the text associated with the given key after removing any menu mnemonic. So for the key
- * action.loadMap 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 action.loadMap 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
@@ -147,50 +151,114 @@ public static String getText(String key) {
String value = getString(key);
if (value == null) {
- log.debug("Cannot find key '" + key + "' in properties file.");
+ log.debug("Cannot find key '{}' in properties file.", key);
return key;
}
- return value.replaceAll("\\&", "");
+ // remove mnemonic marker and return value
+ return replaceHtmlEntities(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 i18n.properties 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 &, e.g.
+ * <div> for <div>, or returning a false positive for a
+ * mnemonic key, we need to replace entities with their actual characters first.
+ */
+ private static String replaceHtmlEntities(String string, boolean 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 to {@link #getText(String key)} using indexed argument replacement. Use
+ * this version where the target string pattern contains placeholders in the form {n}
+ * where n is an integer.
+ *
+ * See the "Parameterised Strings" section of the i18n.properties file for example
+ * usage. Full documentation for this technique can be found under {@link
+ * MessageFormat#format(String, Object...)}.
*
* @param key the propertyKey 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);
+ }
+
+ /**
+ * Localised message with no argument substitution.
+ *
+ * @param key The key to look up for the message.
+ * @return The localised message text.
+ */
+ public static String getMessage(String key) {
+ return getMessage(key, new ArrayList<>());
+ }
+
+ /**
+ * Message composition for use with named arguments. Use when the message pattern string contains
+ * field names, for example:
+ * Argument at index {paramIndex} to function {functionName} is invalid.
+ *
+ * @param key The key to look up for the message.
+ * @param namedArguments List of pairs containing the parameter name and the substitution value.
+ * @return Localised message with parameter placeholders replaced.
+ */
+ public static String getMessage(String key, List> namedArguments) {
+ Map namedArgs = new HashMap<>();
+ for (Pair pair : namedArguments) {
+ namedArgs.put(pair.getKey(), pair.getValue());
+ }
+ return getMessage(key, namedArgs);
}
/**
- * Set all of the I18N values on an Action by retrieving said values from the
- * properties file.
+ * Message composition for use with named arguments. Use when the message pattern string contains
+ * field names, for example:
+ * Argument at index {paramIndex} to function {functionName} is invalid.
+ *
+ * @param key The key to look up for the message.
+ * @param namedArguments Map<String, Object> containing the parameter name and associated
+ * value.
+ * @return Localised message with parameter placeholders replaced.
+ */
+ public static String getMessage(String key, Map namedArguments) {
+ try {
+ return MessageFormat.format(getText(key), namedArguments);
+ } catch (IllegalArgumentException iae) {
+ log.error(iae.getMessage(), iae);
+ return "";
+ }
+ }
+
+ /**
+ * Set all the I18N values on an Action by retrieving said values from the properties
+ * file.
*
* Uses the key as the index for the properties file to set the Action.NAME
* field of action.
*
*
The string used for the NAME is searched for an ampersand ("&") to determine the
- * mnemonic used by any menu item (no mnemonic is set if there is no ampersand). If there is one,
- * the Action.MNEMONIC_KEY property is set.
+ * mnemonic used by any menu item (no mnemonic is set without an ampersand). Where it exists the
+ * Action.MNEMONIC_KEY property is set.
*
*
The key string has ".accel" appended to it and the properties file
- * is searched again, this time to obtain a string representing the shortcut key. If there is one,
- * the Action.ACCELERATOR_KEY property is set.
+ * is searched again, this time getting a string representing the shortcut key. Where found, the
+ * Action.ACCELERATOR_KEY property is set.
*
*
The key string has ".description" appended to it and the
- * properties file is searched again, this time to obtain a string representing the status bar
- * help message. If there is one, the Action.SHORT_DESCRIPTION property is set.
+ * properties file is searched again, this time to get a string representing the status bar help
+ * message. If found, the Action.SHORT_DESCRIPTION property is set.
*
* @param key String to use as an index into the i18n.properties file
* @param action Action used to store the retrieved settings
@@ -226,7 +294,7 @@ public static List getMatchingKeys(String regex) {
public static List getMatchingKeys(Pattern regex) {
Enumeration keys = BUNDLE.getKeys();
- List menuItemKeys = new LinkedList();
+ List menuItemKeys = new LinkedList<>();
while (keys.hasMoreElements()) {
String key = keys.nextElement();
if (regex.matcher(key).find()) {
@@ -235,4 +303,14 @@ public static List getMatchingKeys(Pattern regex) {
}
return menuItemKeys;
}
+
+ public static class MessageFactory extends AbstractMessageFactory {
+ protected MessageFactory(String i18nKey) {
+ super(i18nKey);
+ }
+
+ public static MessageFactory forKey(String i18nKey) {
+ return new MessageFactory(i18nKey);
+ }
+ }
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 57c4224764..a754f518e5 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -27,7 +27,7 @@ apache-commons-logging = { group = "commons-logging", name = "commons-logging",
# For Sentry bug reporting
sentry = { group = "io.sentry", name = "sentry", version.ref = "sentry" }
sentry-log4j = { group = "io.sentry", name = "sentry-log4j2", version.ref = "sentry" }
-
+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" }
diff --git a/src/main/java/net/rptools/maptool/client/functions/exceptions/ParserExceptionFactory.java b/src/main/java/net/rptools/maptool/client/functions/exceptions/ParserExceptionFactory.java
new file mode 100644
index 0000000000..a12d66fc91
--- /dev/null
+++ b/src/main/java/net/rptools/maptool/client/functions/exceptions/ParserExceptionFactory.java
@@ -0,0 +1,72 @@
+/*
+ * This software Copyright by the RPTools.net development team, and
+ * licensed under the Affero GPL Version 3 or, at your option, any later
+ * version.
+ *
+ * MapTool Source Code is distributed in the hope that it will be
+ * useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License * along with this source Code. If not, please visit
+ * and specifically the Affero license
+ * text at .
+ */
+package net.rptools.maptool.client.functions.exceptions;
+
+import net.rptools.maptool.language.AbstractMessageFactory;
+import net.rptools.maptool.language.I18N;
+import net.rptools.parser.ParserException;
+
+public class ParserExceptionFactory extends AbstractMessageFactory {
+ private Throwable throwable;
+
+ protected ParserExceptionFactory(final String i18nKey) {
+ super(i18nKey);
+ throwable = null;
+ }
+
+ protected ParserExceptionFactory(final Throwable cause) {
+ super(null);
+ throwable = cause;
+ }
+
+ public ParserException exception() {
+ if (throwable != null) {
+ return new ParserException(throwable);
+ } else if (msgKey != null) {
+ return new ParserException(I18N.getMessage(msgKey, messageParams));
+ } else {
+ return new ParserException(I18N.getMessage("macro.function.general.unknownError"));
+ }
+ }
+
+ public static ParserExceptionFactory forKey(String i18nKey) {
+ return new ParserExceptionFactory(i18nKey);
+ }
+
+ public ParserExceptionFactory forThrowable(final Throwable cause) {
+ throwable = cause;
+ return this;
+ }
+
+ public ParserExceptionFactory functionName(final String functionName) {
+ return (ParserExceptionFactory) namedValue("functionName", functionName);
+ }
+
+ public ParserExceptionFactory parameterIndex(final int parameterIndex) {
+ return (ParserExceptionFactory) namedValue("parameterIndex", parameterIndex);
+ }
+
+ public ParserExceptionFactory parameterValue(Object parameterValue) {
+ return (ParserExceptionFactory) namedValue("parameterValue", parameterValue);
+ }
+
+ public ParserExceptionFactory results(String results) {
+ return (ParserExceptionFactory) namedValue("results", results);
+ }
+
+ public ParserExceptionFactory options(String options) {
+ return (ParserExceptionFactory) namedValue("options", options);
+ }
+}
diff --git a/src/main/resources/net/rptools/maptool/language/i18n.properties b/src/main/resources/net/rptools/maptool/language/i18n.properties
index 5e8b246149..9fb4d00495 100644
--- a/src/main/resources/net/rptools/maptool/language/i18n.properties
+++ b/src/main/resources/net/rptools/maptool/language/i18n.properties
@@ -1511,7 +1511,7 @@ action.gatherDebugInfo.description = Collects information about your
action.gridLineWight = Grid &Line Weight
# These are url menu actions. Each item consists of three keys. The first key is
# action.url.## which contains the displayed string. The second key is
-# action.url.##.description which contains the url to load when the action is
+# action.url.##.description which contains the url to load when the action is
# executed. The last key is action.url.##.icon which contains the image embedded
# inside the MapTool JAR file which appears on the menu item.
action.helpurl.discord = Discord
@@ -2068,7 +2068,7 @@ macro.function.addAllToInitiativeFunction.mustBeGM = Only the GM has the permiss
# MacroArgsFunctions
macro.function.args.incorrectParam = Function "{0}" must be called with exactly 1 numeric parameter.
macro.function.args.outOfRange = Argument index {1} out of range (max of {2}) in function "{0}".
-# assert Function
+# assert Function
# {0} is the error message specified when calling assert() for message.
# Note that I'm leaving off the double quotes on this one, too. I think it
# will look better that way.
@@ -2116,21 +2116,31 @@ macro.function.general.noImpersonated = Error executing "{0}": the
# enough that no thousands separator will be needed either. So leaving
# off the ",number" means they'll be treated as strings and simply
# output as-is. Which is fine. :)
+macro.function.general.unknownError = Unknown error
macro.function.general.reservedJS = {0} is a reserved function in the js. prefix.
macro.function.general.missingKey = Argument {1, number} in function {0} is missing the required key "{2}". Required keys are; "{3}"
+
+macro.function.general.onlyGM = Only the GM can call the "{0}" function.
macro.function.general.noPermJS = You do not have permission to use the "{0}" namespace.
macro.function.general.noPerm = You do not have permission to call the "{0}" function.
macro.function.general.noPermOther = You do not have permission to access another token in the "{0}" function.
+
macro.function.general.notEnoughParam = Function "{0}" requires at least {1} parameters; {2} were provided.
-macro.function.general.onlyGM = Only the GM can call the "{0}" function.
macro.function.general.tooManyParam = Function "{0}" requires no more than {1} parameters; {2} were provided.
+macro.function.general.wrongNumParam = Function "{0}" requires exactly {1} parameters; {2} were provided.
+macro.function.general.invalidParam = Argument at index {parameterIndex} to function {functionName} is invalid.
+macro.function.general.invalidParam.forContext = Argument at index {parameterIndex} to function {functionName} is invalid for {context}.
+macro.function.general.invalidParam.validValues = Argument at index {parameterIndex} to function {functionName} is invalid. Valid values are: {options}.
+macro.function.general.invalidParam.emptyList = Function "{0}": string list at argument {1} cannot be empty
+
macro.function.general.unknownFunction = Unknown function name "{0}".
macro.function.general.unknownToken = Error executing "{0}": the token name or id "{1}" is unknown.
macro.function.general.unknownPropertyType = Error executing "{0}": the property type "{1}" is unknown.
macro.function.general.unknownProperty = Error executing "{0}": the property "{1}" is unknown for type "{2}".
macro.function.general.unknownTokenOnMap = Error executing "{0}": the token name or id "{1}" is unknown on map "{2}".
-macro.function.general.wrongNumParam = Function "{0}" requires exactly {1} parameters; {2} were provided.
-macro.function.general.listCannotBeEmpty = {0}: string list at argument {1} cannot be empty
+
+macro.function.general.noUniqueResult = Function {functionName} could not find a unique match.
+
# Token Distance functions
# I.e. ONE_TWO_ONE or ONE_ONE_ONE
macro.function.getDistance.invalidMetric = Invalid metric type "{0}".
@@ -2588,7 +2598,7 @@ msg.title.importProperties = Import Properties
msg.title.loadAssetTree = Load Asset Tree
msg.title.loadCampaign = Load Campaign
msg.autosave.wait = Waiting up to {0} seconds for autosave to finish...
-# I'm trying to add some consistency to the property names. So...
+# I'm trying to add some consistency to the property names. So...
# "msg.title.*" are strings used as the titles of dialogs and frames
# created by the application.
msg.title.loadMap = Load Map