diff --git a/common/build.gradle b/common/build.gradle index 2ad0f14070..36c38d4a8b 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -11,8 +11,10 @@ dependencies { implementation(libs.bundles.log4j) implementation(libs.slf4j.simple) + implementation(libs.flexmark.all) implementation(libs.apache.commons.logging) - + implementation(libs.apache.commons.lang) + implementation(libs.icu4j) implementation(libs.gson) implementation(libs.bundles.imageio) diff --git a/common/src/main/java/net/rptools/maptool/language/AbstractMessageFactory.java b/common/src/main/java/net/rptools/maptool/language/AbstractMessageFactory.java new file mode 100644 index 0000000000..e305a2dee3 --- /dev/null +++ b/common/src/main/java/net/rptools/maptool/language/AbstractMessageFactory.java @@ -0,0 +1,54 @@ +/* + * 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.language; + +import com.google.gson.JsonElement; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public abstract class AbstractMessageFactory { + protected final Map messageParams; + protected String msgKey; + + protected AbstractMessageFactory(final String i18nKey) { + this.msgKey = i18nKey; + messageParams = new HashMap<>(); + } + + /** Persuade likely value types to something meaningful */ + protected Function stringify = + value -> { + if (value instanceof JsonElement je) { + return je.toString(); + } else if (value instanceof List list) { + return Arrays.deepToString(list.toArray()); + } else if (value instanceof Object[] array) { + return Arrays.deepToString(array); + } + return String.valueOf(value); + }; + + public AbstractMessageFactory namedValue(final String name, final Object value) { + messageParams.put(name, stringify.apply(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..e82e3c2da7 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,14 @@ */ 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.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; @@ -41,15 +39,18 @@ * * @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,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 ("&") the character @@ -91,11 +81,15 @@ public static String getDescription(String key) { * @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 = convertText.apply(value, false); + int index = value.indexOf(MNEMONIC_MARKER); if (index != -1 && index + 1 < value.length()) { return Character.toUpperCase(value.charAt(index + 1)); } @@ -103,7 +97,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 +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 null @@ -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 - * 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 @@ -150,32 +155,97 @@ 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 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. + * &lt;div&gt; for <div>, or returning a false positive for a + * mnemonic key, we need to replace entities with their actual characters first. + */ + private static final BiFunction 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 – similar to {@link #getText(String key)} – with simple + * 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. @@ -226,7 +296,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 +305,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 353737ab63..bbb06c3776 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,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 3f0d4167d9..ac6cb5f31d 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 @@ -2066,7 +2066,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. @@ -2114,21 +2114,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}". @@ -2577,7 +2587,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