Skip to content

Commit f6394be

Browse files
Merge pull request #5953 from bubblobill/ICU4J-Implementation
Implemented ICU4J
2 parents 932774c + 49b20a1 commit f6394be

6 files changed

Lines changed: 273 additions & 61 deletions

File tree

common/build.gradle

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ dependencies {
1111

1212
implementation(libs.bundles.log4j)
1313
implementation(libs.slf4j.simple)
14+
implementation(libs.flexmark.all)
1415
implementation(libs.apache.commons.logging)
15-
16+
implementation(libs.apache.commons.lang)
17+
implementation(libs.icu4j)
1618
implementation(libs.gson)
1719

1820
implementation(libs.bundles.imageio)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* This software Copyright by the RPTools.net development team, and
3+
* licensed under the Affero GPL Version 3 or, at your option, any later
4+
* version.
5+
*
6+
* MapTool Source Code is distributed in the hope that it will be
7+
* useful, but WITHOUT ANY WARRANTY; without even the implied warranty
8+
* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
9+
*
10+
* You should have received a copy of the GNU Affero General Public
11+
* License * along with this source Code. If not, please visit
12+
* <http://www.gnu.org/licenses/> and specifically the Affero license
13+
* text at <http://www.gnu.org/licenses/agpl.html>.
14+
*/
15+
package net.rptools.maptool.language;
16+
17+
import com.google.gson.JsonElement;
18+
import java.util.Arrays;
19+
import java.util.HashMap;
20+
import java.util.List;
21+
import java.util.Map;
22+
23+
public abstract class AbstractMessageFactory {
24+
protected final Map<String, Object> messageParams;
25+
protected String msgKey;
26+
27+
protected AbstractMessageFactory(final String i18nKey) {
28+
this.msgKey = i18nKey;
29+
messageParams = new HashMap<>();
30+
}
31+
32+
/** Persuade likely value types to something meaningful */
33+
protected String stringify(Object value) {
34+
return switch (value) {
35+
case JsonElement je -> je.toString();
36+
case List<?> list -> Arrays.deepToString(list.toArray());
37+
case Object[] array -> Arrays.deepToString(array);
38+
case null, default -> String.valueOf(value);
39+
};
40+
}
41+
42+
public AbstractMessageFactory namedValue(final String name, final Object value) {
43+
messageParams.put(name, stringify(value));
44+
return this;
45+
}
46+
47+
public String build() {
48+
return I18N.getMessage(msgKey, messageParams);
49+
}
50+
}

common/src/main/java/net/rptools/maptool/language/I18N.java

Lines changed: 131 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,13 @@
1414
*/
1515
package net.rptools.maptool.language;
1616

17-
import java.text.MessageFormat;
18-
import java.util.Enumeration;
19-
import java.util.LinkedList;
20-
import java.util.List;
21-
import java.util.MissingResourceException;
22-
import java.util.ResourceBundle;
17+
import com.ibm.icu.text.MessageFormat;
18+
import com.vladsch.flexmark.util.sequence.Escaping;
19+
import java.util.*;
2320
import java.util.regex.Pattern;
24-
import javax.swing.Action;
25-
import javax.swing.JMenu;
21+
import javax.swing.*;
2622
import net.rptools.lib.OsDetection;
23+
import org.apache.commons.lang3.tuple.Pair;
2724
import org.apache.logging.log4j.LogManager;
2825
import org.apache.logging.log4j.Logger;
2926

@@ -35,21 +32,24 @@
3532
*
3633
* <p>As MapTool uses a base name for the string and extensions for alternate pieces (such as <code>
3734
* action.loadMap</code> as the base and <code>action.loadMap.accel</code> as the menu accelerator
38-
* key) there are different methods used to return the different components.
35+
* key) different methods are used to return the different components.
3936
*
4037
* <p>The ResourceBundle name is <b>net.rptools.maptool.language.i18n</b>.
4138
*
4239
* @author tcroft
4340
*/
41+
@SuppressWarnings("unused")
4442
public class I18N {
4543
private static final ResourceBundle BUNDLE;
4644
private static final Logger log = LogManager.getLogger(I18N.class);
4745
private static final String DESCRIPTION_EXTENSION = ".description";
48-
46+
private static final char MNEMONIC_MARKER = '&';
47+
private static final Pattern MNEMONIC_PREFIX_PATTERN =
48+
Pattern.compile(MNEMONIC_MARKER + "([a-z0-9])", Pattern.CASE_INSENSITIVE);
4949
private static Enumeration<String> keys;
5050

5151
static {
52-
// Put here to make breakpointing easier. :)
52+
// Put here to make break-pointing easier. :)
5353
BUNDLE = ResourceBundle.getBundle("net.rptools.maptool.language.i18n");
5454
I18nTools report = new I18nTools(false);
5555
}
@@ -72,38 +72,42 @@ public static JMenu createMenu(String key) {
7272
return menu;
7373
}
7474

75-
/**
76-
* Returns the description text for the given key. This text normally appears in the statusbar of
77-
* the main application frame. The input key has the string DESCRIPTION_EXTENSION appended to it.
78-
*
79-
* @param key the key to use for the i18n lookup.
80-
* @return the i81n version of the string.
81-
*/
82-
public static String getDescription(String key) {
83-
return getString(key + DESCRIPTION_EXTENSION);
84-
}
85-
8675
/**
8776
* Returns the character to use as the menu mnemonic for the given key. This method searches the
88-
* properties file for the given key. If the value contains an ampersand ("&amp;") the character
89-
* following the ampersand is converted to uppercase and returned.
77+
* properties file for the given key. Where the value contains an ampersand ("&amp;") the
78+
* following character is converted to uppercase and returned.
9079
*
9180
* @param key the component to search for
9281
* @return the character to use as the mnemonic (as an <code>int</code>)
9382
*/
94-
public static int getMnemonic(String key) {
83+
private static int getMnemonic(String key) {
9584
String value = getString(key);
96-
if (value == null || value.length() < 2) return -1;
97-
98-
int index = value.indexOf('&');
85+
if (value == null || value.length() < 2) {
86+
return -1;
87+
}
88+
// replace HTML entities with characters to prevent spurious results - should not happen but
89+
// this is not Utopia
90+
value = replaceHtmlEntities(value, false);
91+
int index = value.indexOf(MNEMONIC_MARKER);
9992
if (index != -1 && index + 1 < value.length()) {
10093
return Character.toUpperCase(value.charAt(index + 1));
10194
}
10295
return -1;
10396
}
10497

10598
/**
106-
* Returns the String that results from a lookup within the properties file.
99+
* Returns the description text for the given key. This text normally appears in the status-bar of
100+
* the main application frame. The input key has the string DESCRIPTION_EXTENSION appended to it.
101+
*
102+
* @param key the key to use for the i18n lookup.
103+
* @return the i81n version of the string.
104+
*/
105+
private static String getDescription(String key) {
106+
return getString(key + DESCRIPTION_EXTENSION);
107+
}
108+
109+
/**
110+
* Returns the String matching the key within the properties file.
107111
*
108112
* @param key the component to search for
109113
* @param bundle the resource bundle to get the i18n string from.
@@ -118,7 +122,7 @@ public static String getString(String key, ResourceBundle bundle) {
118122
}
119123

120124
/**
121-
* Returns the String that results from a lookup within the properties file.
125+
* Returns the String matching the key within the properties file.
122126
*
123127
* @param key the component to search for
124128
* @return the String found or <code>null</code>
@@ -132,9 +136,9 @@ public static String getString(String key) {
132136
}
133137

134138
/**
135-
* Returns the text associated with the given key after removing any menu mnemonic. So for the key
136-
* <b>action.loadMap</b> that has the value {@code &Load Map} in the properties file, this method
137-
* returns "Load Map".
139+
* Returns the String matching the key within the properties file after removing any menu
140+
* mnemonic. So for the key <b>action.loadMap</b> that has the value {@code &Load Map} in the
141+
* properties file, this method returns "Load Map".
138142
*
139143
* @param key the component to search for
140144
* @return the String found with mnemonics removed, or the input key if not found
@@ -147,50 +151,114 @@ public static String getText(String key) {
147151

148152
String value = getString(key);
149153
if (value == null) {
150-
log.debug("Cannot find key '" + key + "' in properties file.");
154+
log.debug("Cannot find key '{}' in properties file.", key);
151155
return key;
152156
}
153-
return value.replaceAll("\\&", "");
157+
// remove mnemonic marker and return value
158+
return replaceHtmlEntities(value, true);
154159
}
155160

156161
/**
157-
* Functionally identical to {@link #getText(String key)} except that this one bundles the
158-
* formatting calls into this code. This version of the method is truly only needed when the
159-
* string being retrieved contains parameters. In MapTool, this commonly means the player's name
160-
* or a filename. See the "Parameterized Strings" section of the <b>i18n.properties</b> file for
161-
* example usage. Full documentation for this technique can be found under {@link
162-
* MessageFormat#format}.
162+
* To avoid breaking HTML encoded characters when dealing with &amp;, e.g. <code>
163+
* &amp;lt;div&amp;gt;</code> for <code>&lt;div&gt;</code>, or returning a false positive for a
164+
* mnemonic key, we need to replace entities with their actual characters first.
165+
*/
166+
private static String replaceHtmlEntities(String string, boolean stripAmpersand) {
167+
if (string.indexOf(MNEMONIC_MARKER) == -1) {
168+
return string;
169+
} else {
170+
string = Escaping.unescapeString(string);
171+
if (stripAmpersand) {
172+
return MNEMONIC_PREFIX_PATTERN.matcher(string).replaceAll("$1");
173+
}
174+
return Escaping.escapeHtml(string, false);
175+
}
176+
}
177+
178+
/**
179+
* Simple functionality to {@link #getText(String key)} using indexed argument replacement. Use
180+
* this version where the target string pattern contains placeholders in the form <code>{n}</code>
181+
* where n is an integer.
182+
*
183+
* <p>See the "Parameterised Strings" section of the <b>i18n.properties</b> file for example
184+
* usage. Full documentation for this technique can be found under {@link
185+
* MessageFormat#format(String, Object...)}.
163186
*
164187
* @param key the <code>propertyKey</code> to use for lookup in the properties file
165-
* @param args parameters needed for formatting purposes
188+
* @param args parameters (in order) needed for formatting purposes
166189
* @return the formatted String
167190
*/
168191
public static String getText(String key, Object... args) {
169192
// If the key doesn't exist in the file, the key becomes the format and
170193
// nothing will be substituted; it's a little extra work, but is not the normal case
171194
// anyway.
172-
String msg = MessageFormat.format(getText(key), args);
173-
return msg;
195+
return java.text.MessageFormat.format(getText(key), args);
196+
}
197+
198+
/**
199+
* Localised message with no argument substitution.
200+
*
201+
* @param key The key to look up for the message.
202+
* @return The localised message text.
203+
*/
204+
public static String getMessage(String key) {
205+
return getMessage(key, new ArrayList<>());
206+
}
207+
208+
/**
209+
* Message composition for use with named arguments. Use when the message pattern string contains
210+
* field names, for example: <code>
211+
* Argument at index {paramIndex} to function {functionName} is invalid.</code>
212+
*
213+
* @param key The key to look up for the message.
214+
* @param namedArguments List of pairs containing the parameter name and the substitution value.
215+
* @return Localised message with parameter placeholders replaced.
216+
*/
217+
public static String getMessage(String key, List<Pair<String, Object>> namedArguments) {
218+
Map<String, Object> namedArgs = new HashMap<>();
219+
for (Pair<String, Object> pair : namedArguments) {
220+
namedArgs.put(pair.getKey(), pair.getValue());
221+
}
222+
return getMessage(key, namedArgs);
174223
}
175224

176225
/**
177-
* Set all of the I18N values on an <code>Action</code> by retrieving said values from the
178-
* properties file.
226+
* Message composition for use with named arguments. Use when the message pattern string contains
227+
* field names, for example: <code>
228+
* Argument at index {paramIndex} to function {functionName} is invalid.</code>
229+
*
230+
* @param key The key to look up for the message.
231+
* @param namedArguments Map&lt;String, Object&gt; containing the parameter name and associated
232+
* value.
233+
* @return Localised message with parameter placeholders replaced.
234+
*/
235+
public static String getMessage(String key, Map<String, Object> namedArguments) {
236+
try {
237+
return MessageFormat.format(getText(key), namedArguments);
238+
} catch (IllegalArgumentException iae) {
239+
log.error(iae.getMessage(), iae);
240+
return "";
241+
}
242+
}
243+
244+
/**
245+
* Set all the I18N values on an <code>Action</code> by retrieving said values from the properties
246+
* file.
179247
*
180248
* <p>Uses the <code>key</code> as the index for the properties file to set the <b>Action.NAME</b>
181249
* field of <b>action</b>.
182250
*
183251
* <p>The string used for the <b>NAME</b> is searched for an ampersand ("&amp;") to determine the
184-
* mnemonic used by any menu item (no mnemonic is set if there is no ampersand). If there is one,
185-
* the <b>Action.MNEMONIC_KEY</b> property is set.
252+
* mnemonic used by any menu item (no mnemonic is set without an ampersand). Where it exists the
253+
* <b>Action.MNEMONIC_KEY</b> property is set.
186254
*
187255
* <p>The <code>key</code> string has "<code>.accel</code>" appended to it and the properties file
188-
* is searched again, this time to obtain a string representing the shortcut key. If there is one,
189-
* the <b>Action.ACCELERATOR_KEY</b> property is set.
256+
* is searched again, this time getting a string representing the shortcut key. Where found, the
257+
* <b>Action.ACCELERATOR_KEY</b> property is set.
190258
*
191259
* <p>The <code>key</code> string has "<code>.description</code>" appended to it and the
192-
* properties file is searched again, this time to obtain a string representing the status bar
193-
* help message. If there is one, the <b>Action.SHORT_DESCRIPTION</b> property is set.
260+
* properties file is searched again, this time to get a string representing the status bar help
261+
* message. If found, the <b>Action.SHORT_DESCRIPTION</b> property is set.
194262
*
195263
* @param key String to use as an index into the <b>i18n.properties</b> file
196264
* @param action Action used to store the retrieved settings
@@ -226,7 +294,7 @@ public static List<String> getMatchingKeys(String regex) {
226294
public static List<String> getMatchingKeys(Pattern regex) {
227295
Enumeration<String> keys = BUNDLE.getKeys();
228296

229-
List<String> menuItemKeys = new LinkedList<String>();
297+
List<String> menuItemKeys = new LinkedList<>();
230298
while (keys.hasMoreElements()) {
231299
String key = keys.nextElement();
232300
if (regex.matcher(key).find()) {
@@ -235,4 +303,14 @@ public static List<String> getMatchingKeys(Pattern regex) {
235303
}
236304
return menuItemKeys;
237305
}
306+
307+
public static class MessageFactory extends AbstractMessageFactory {
308+
protected MessageFactory(String i18nKey) {
309+
super(i18nKey);
310+
}
311+
312+
public static MessageFactory forKey(String i18nKey) {
313+
return new MessageFactory(i18nKey);
314+
}
315+
}
238316
}

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ apache-commons-logging = { group = "commons-logging", name = "commons-logging",
2626
# For Sentry bug reporting
2727
sentry = { group = "io.sentry", name = "sentry", version.ref = "sentry" }
2828
sentry-log4j = { group = "io.sentry", name = "sentry-log4j2", version.ref = "sentry" }
29-
29+
icu4j = { group = "com.ibm.icu", name = "icu4j", version = "78.3" }
3030
# Networking
3131
# Web RTC
3232
websocket = { group = "org.java-websocket", name = "Java-WebSocket", version = "1.6.0" }

0 commit comments

Comments
 (0)