diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 353737ab63..57c4224764 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ sentry = "7.22.5" jide = "3.7.9" flatlaf = "3.6.1" handlebars = "4.4.0" +handlebars-jackson = "4.3.1" jai-imageio = "1.4.0" graalvm-js = "21.2.0" pdfbox = "3.0.0" @@ -94,6 +95,7 @@ jsvg = { group = "com.github.weisj", name = "jsvg", version = "1.4.0" } handlebars = { group = "com.github.jknack", name = "handlebars", version.ref = "handlebars" } handlebars-helpers = { group = "com.github.jknack", name = "handlebars-helpers", version.ref = "handlebars" } +handlebars-jackson-helper = { group = "com.github.jknack", name = "handlebars-jackson2", version.ref = "handlebars-jackson" } # Apache commons and other utilities # parsing of configuration data @@ -213,7 +215,7 @@ flatlaf = [ "flatlaf-extras", "flatlaf-jide-oss", ] -handlebars = ["handlebars", "handlebars-helpers"] +handlebars = ["handlebars", "handlebars-helpers", "handlebars-jackson-helper"] junit = ["junit-api", "junit-engine", "junit-params"] jai-imageio = ["jai-imageio-core", "jai-imageio-jpeg"] graalvm-js = ["graalvm-js", "graalvm-js-scriptengine"] diff --git a/src/main/java/net/rptools/maptool/util/HandlebarsHelpers.java b/src/main/java/net/rptools/maptool/util/HandlebarsHelpers.java new file mode 100644 index 0000000000..f5cb29a99b --- /dev/null +++ b/src/main/java/net/rptools/maptool/util/HandlebarsHelpers.java @@ -0,0 +1,439 @@ +/* + * 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.util; + +import com.github.jknack.handlebars.*; +import com.github.jknack.handlebars.helper.ConditionalHelpers; +import com.github.jknack.handlebars.helper.EmbeddedHelper; +import com.github.jknack.handlebars.helper.LogHelper; +import com.github.jknack.handlebars.helper.StringHelpers; +import com.github.jknack.handlebars.helper.ext.AssignHelper; +import com.github.jknack.handlebars.helper.ext.IncludeHelper; +import com.github.jknack.handlebars.helper.ext.NumberHelper; +import com.vladsch.flexmark.html.HtmlRenderer; +import com.vladsch.flexmark.parser.Parser; +import com.vladsch.flexmark.util.ast.Node; +import com.vladsch.flexmark.util.data.MutableDataSet; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.function.Function; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class HandlebarsHelpers { + private static final Logger log = LoggerFactory.getLogger(HandlebarsHelpers.class); + + static Handlebars registerHelpers(Handlebars handlebars) { + StringHelpers.register(handlebars); + Arrays.stream(ConditionalHelpers.values()).forEach(h -> handlebars.registerHelper(h.name(), h)); + handlebars.registerHelper("json", Jackson2Helper.INSTANCE); + NumberHelper.register(handlebars); + handlebars.registerHelper(EmbeddedHelper.NAME, EmbeddedHelper.INSTANCE); + handlebars.registerHelper(AssignHelper.NAME, AssignHelper.INSTANCE); + handlebars.registerHelper(IncludeHelper.NAME, IncludeHelper.INSTANCE); + handlebars.registerHelper(MarkdownHelper.NAME, MarkdownHelper.INSTANCE); + handlebars.registerHelper(Base64EncodeHelper.NAME, Base64EncodeHelper.INSTANCE); + handlebars.registerHelper(HBLogger.NAME, HBLogger.INSTANCE); + Arrays.stream(HandlebarsHelpers.MathsHelpers.values()) + .forEach(h -> handlebars.registerHelper(h.name(), h)); + + return handlebars; + } + + static class Base64EncodeHelper implements Helper { + /** A singleton instance of this helper. */ + public static final Helper INSTANCE = new Base64EncodeHelper(); + + /** The helper's name. */ + public static final String NAME = "base64Encode"; + + /** + * Turns the textual form of the value into a base64-encoded string. For example: + * + *
+     * <script type="application/json;base64" id="jsonProperty">
+     *   {{ base64Encode properties[0].value }}
+     * </script>
+     * <script type="application/javascript">
+     * const jsonProperty = JSON.parse(atob(document.getElementById("jsonProperty").innerText));
+     * </script>
+     * 
+ */ + @SuppressWarnings("SpellCheckingInspection") + @Override + public Object apply(final Object context, final Options options) { + if (context == null || context instanceof String s && s.isBlank()) { + return ""; + } else { + byte[] message = context.toString().getBytes(StandardCharsets.UTF_8); + return new Handlebars.SafeString(Base64.getUrlEncoder().encodeToString(message)); + } + } + } + + public enum MathsHelpers implements Helper { + add { + @Override + public Object apply(final Object a, final Options options) throws IOException { + try { + List numbers = numberList(a, options); + checkOperandCount(1, -1, numbers); + BigDecimal result = numbers.removeLast(); + while (!numbers.isEmpty()) { + result = result.add(numbers.removeLast(), MATH_CONTEXT); + } + return result.toPlainString(); + } catch (NumberFormatException | ArithmeticException e) { + log.debug("Function \"add\" - {}", e.getLocalizedMessage(), e); + return "NaN"; + } + } + }, + subtract { + @Override + public Object apply(final Object a, final Options options) throws IOException { + try { + List numbers = numberList(a, options); + checkOperandCount(1, -1, numbers); + BigDecimal result = numbers.removeLast(); + while (!numbers.isEmpty()) { + result = result.subtract(numbers.removeLast(), MATH_CONTEXT); + } + return result.toPlainString(); + } catch (NumberFormatException | ArithmeticException e) { + log.debug("Function \"subtract\" - {}", e.getLocalizedMessage(), e); + return "NaN"; + } + } + }, + multiply { + @Override + public Object apply(final Object a, final Options options) throws IOException { + try { + List numbers = numberList(a, options).reversed(); + checkOperandCount(1, -1, numbers); + BigDecimal result = numbers.removeLast(); + while (!numbers.isEmpty()) { + result = result.multiply(numbers.removeLast(), MATH_CONTEXT); + } + return result.toPlainString(); + } catch (NumberFormatException | ArithmeticException e) { + log.debug("Function \"multiply\" - {}", e.getLocalizedMessage(), e); + return "NaN"; + } + } + }, + divide { + @Override + public Object apply(final Object a, final Options options) throws IOException { + try { + List numbers = numberList(a, options).reversed(); + checkOperandCount(1, -1, numbers); + BigDecimal result = numbers.removeLast(); + while (!numbers.isEmpty()) { + result = result.divide(numbers.removeLast(), MATH_CONTEXT); + } + return result.toPlainString(); + } catch (NumberFormatException | ArithmeticException e) { + log.debug("Function \"divide\" - {}", e.getLocalizedMessage(), e); + return "NaN"; + } + } + }, + max { + @Override + public Object apply(final Object a, final Options options) throws IOException { + try { + List numbers = numberList(a, options); + checkOperandCount(1, -1, numbers); + BigDecimal result = numbers.removeLast(); + while (!numbers.isEmpty()) { + result = result.max(numbers.removeLast()); + } + return result.toPlainString(); + } catch (NumberFormatException | ArithmeticException e) { + log.debug("Function \"max\" - {}", e.getLocalizedMessage(), e); + return "NaN"; + } + } + }, + min { + @Override + public Object apply(final Object a, final Options options) throws IOException { + try { + List numbers = numberList(a, options); + checkOperandCount(1, -1, numbers); + BigDecimal result = numbers.removeLast(); + while (!numbers.isEmpty()) { + result = result.min(numbers.removeLast()); + } + return result.toPlainString(); + } catch (NumberFormatException | ArithmeticException e) { + log.debug("Function \"min\" - {}", e.getLocalizedMessage(), e); + return "NaN"; + } + } + }, + mod { + @Override + public Object apply(final Object a, final Options options) throws IOException { + try { + List numbers = numberList(a, options); + checkOperandCount(2, -1, numbers); + BigDecimal result = numbers.removeLast(); + while (!numbers.isEmpty()) { + result = result.remainder(numbers.removeLast(), MATH_CONTEXT); + } + return result.toPlainString(); + } catch (NumberFormatException | ArithmeticException e) { + log.debug("Function \"mod\" - {}", e.getLocalizedMessage(), e); + return "NaN"; + } + } + }, + div { + @Override + public Object apply(final Object a, final Options options) throws IOException { + try { + List numbers = numberList(a, options).reversed(); + checkOperandCount(2, -1, numbers); + BigDecimal result = numbers.removeLast(); + while (!numbers.isEmpty()) { + result = result.divideToIntegralValue(numbers.removeLast()); + } + return result.toPlainString(); + } catch (NumberFormatException | ArithmeticException e) { + log.debug("Function \"div\" - {}", e.getLocalizedMessage(), e); + return "NaN"; + } + } + }, + pow { + @Override + public Object apply(final Object a, final Options options) throws IOException { + try { + List numbers = numberList(a, options); + checkOperandCount(2, -1, numbers); + BigDecimal result = numbers.removeLast(); + while (!numbers.isEmpty()) { + result = result.pow(numbers.removeLast().intValue(), MATH_CONTEXT); + } + return result.toPlainString(); + } catch (NumberFormatException | ArithmeticException e) { + log.debug("Function \"pow\" - {}", e.getLocalizedMessage(), e); + return "NaN"; + } + } + }, + abs { + @Override + public Object apply(final Object a, final Options options) throws IOException { + try { + List numbers = numberList(a, options); + checkOperandCount(1, 1, numbers); + return numbers.removeFirst().abs().toPlainString(); + } catch (NumberFormatException | ArithmeticException e) { + log.debug(e.getLocalizedMessage(), e); + return "NaN"; + } + } + }, + sqrt { + @Override + public Object apply(final Object a, final Options options) throws IOException { + try { + List numbers = numberList(a, options); + checkOperandCount(1, 1, numbers); + return new BigDecimal(a.toString()).sqrt(MATH_CONTEXT).toString(); + } catch (NumberFormatException | ArithmeticException e) { + log.debug(e.getLocalizedMessage(), e); + return "NaN"; + } + } + }, + /** 2 x Pi */ + tau { + @Override + public Object apply(final Object a, final Options options) throws IOException { + return String.valueOf(Math.TAU); + } + }, + pi { + @Override + public Object apply(final Object a, final Options options) throws IOException { + return String.valueOf(Math.PI); + } + }, + ; + private static final MathContext MATH_CONTEXT = new MathContext(16, RoundingMode.HALF_EVEN); + + /** + * Validate the correct number of operands in list. + * + * @param minRequired minimum required numberList to preform operation + * @param maxAllowed maximum required numberList to preform operation + * @param operands list of BigDecimal to check + * @throws ArithmeticException if operand list is out of bounds + */ + void checkOperandCount(int minRequired, int maxAllowed, List operands) + throws ArithmeticException { + if (maxAllowed > -1 && operands.size() > maxAllowed) { + throw new ArithmeticException("Too many operands."); + } + if (operands.size() < minRequired) { + throw new ArithmeticException("Not enough operands."); + } + } + + /** Convert passed object to BigDecimal */ + private static final Function toBigDecimal = + o -> { + try { + return new BigDecimal(String.valueOf(o), MATH_CONTEXT); + } catch (NumberFormatException ignored) { + return BigDecimal.valueOf(Double.NaN); + } + }; + + /** + * Compose passed values into a list of BigDecimal + * + * @param args list of object/object[] + * @return List of BigDecimal + */ + List numberList(Object... args) { + // ("num1", 1)("num2", "-1")("arr1", new Object[]{0.5, "1", 2, "3.5"}); + List values = new LinkedList<>(); + for (Object o : args) { + switch (o) { + case String[] strings -> { + for (String string : strings) { + values.add(toBigDecimal.apply(string)); + } + } + case String string -> { + string = string.strip(); + + if (string.startsWith("[") && string.contains("]")) { + /* deal with literal arrays */ + values.addAll( + numberList((Object) string.substring(1, string.indexOf("]")).split(","))); + } else if (string.contains(" ")) { + /* split space delimited values */ + values.addAll(numberList(String.join(" ", string))); + } else { + values.add(toBigDecimal.apply(string)); + } + } + case Object[] objectArray -> { + /* contents of options.params */ + for (var object : objectArray) { + values.add(toBigDecimal.apply(object)); + } + } + case Options options -> { + /* if named arguments exist, try converting them to BigDecimal */ + for (String key : options.hash.keySet()) { + try { + BigDecimal bd = new BigDecimal(options.hash(key).toString()); + values.add(bd); + } catch (NumberFormatException ignored) { + } + } + /* add values from parameter array */ + for (Object param : options.params) { + values.addAll(numberList(param)); + } + } + case null -> {} + default -> + /* hooray, it's just a single thing to convert */ + values.add(toBigDecimal.apply(o)); + } + } + return values; + } + } + + @SuppressWarnings("LoggerInitializedWithForeignClass") + public static class HBLogger extends LogHelper { + private static final Logger log = LoggerFactory.getLogger(HandlebarsHelpers.class); + + /** A singleton instance of this helper. */ + public static final Helper INSTANCE = new HBLogger(); + + /** The helper's name. */ + public static final String NAME = "log"; + + @Override + public Object apply(Object context, Options options) throws IOException { + StringBuilder sb = new StringBuilder(); + String level = options.hash("level", "info"); + TagType tagType = options.tagType; + if (tagType.inline()) { + sb.append(context); + for (int i = 0; i < options.params.length; i++) { + sb.append(" ").append((Object) options.param(i)); + } + } else { + sb.append(options.fn()); + } + + switch (level) { + case "error": + log.error(sb.toString().trim()); + break; + case "debug": + log.debug(sb.toString().trim()); + break; + case "warn": + log.warn(sb.toString().trim()); + break; + case "trace": + log.trace(sb.toString().trim()); + break; + default: + log.info(sb.toString().trim()); + } + return null; + } + } + + public static class MarkdownHelper implements Helper { + private static final MutableDataSet MD_OPTIONS = new MutableDataSet(); + private static final Parser PARSER = Parser.builder(MD_OPTIONS).build(); + private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder(MD_OPTIONS).build(); + + /** A singleton version of {@link MarkdownHelper}. */ + public static final Helper INSTANCE = new MarkdownHelper(); + + /** The helper's name. */ + public static final String NAME = "markdown"; + + @Override + public Object apply(final Object context, final Options options) throws IOException { + if (options.isFalsy(context)) { + return ""; + } + String markdown = context.toString(); + Node document = PARSER.parse(markdown); + return new Handlebars.SafeString(HTML_RENDERER.render(document)); + } + } +} diff --git a/src/main/java/net/rptools/maptool/util/HandlebarsUtil.java b/src/main/java/net/rptools/maptool/util/HandlebarsUtil.java index 18d1ab83b2..3a24d4d2da 100644 --- a/src/main/java/net/rptools/maptool/util/HandlebarsUtil.java +++ b/src/main/java/net/rptools/maptool/util/HandlebarsUtil.java @@ -14,17 +14,9 @@ */ package net.rptools.maptool.util; -import com.github.jknack.handlebars.Context; -import com.github.jknack.handlebars.Handlebars; -import com.github.jknack.handlebars.Helper; -import com.github.jknack.handlebars.Options; -import com.github.jknack.handlebars.Template; +import com.github.jknack.handlebars.*; +import com.github.jknack.handlebars.cache.HighConcurrencyTemplateCache; import com.github.jknack.handlebars.context.JavaBeanValueResolver; -import com.github.jknack.handlebars.helper.ConditionalHelpers; -import com.github.jknack.handlebars.helper.StringHelpers; -import com.github.jknack.handlebars.helper.ext.AssignHelper; -import com.github.jknack.handlebars.helper.ext.IncludeHelper; -import com.github.jknack.handlebars.helper.ext.NumberHelper; import com.github.jknack.handlebars.io.AbstractTemplateLoader; import com.github.jknack.handlebars.io.ClassPathTemplateLoader; import com.github.jknack.handlebars.io.TemplateLoader; @@ -37,10 +29,9 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Path; -import java.util.Arrays; -import java.util.Base64; +import java.util.concurrent.*; import javax.annotation.Nonnull; -import net.rptools.maptool.model.Token; +import javax.annotation.Nullable; import net.rptools.maptool.model.library.Library; import net.rptools.maptool.model.library.LibraryManager; import org.apache.logging.log4j.LogManager; @@ -52,6 +43,33 @@ * @param The type of the bean to apply the template to. */ public class HandlebarsUtil { + private static final HighConcurrencyTemplateCache HIGH_CONCURRENCY_TEMPLATE_CACHE = + new HighConcurrencyTemplateCache(); + + /** + * Use this to obtain an instance of Handlebars instead of creating one separately. + * + *

Specify a TemplateLoader if the default ClassPathTemplateLoader is not required. + * + *

Using this ensures the instance returned has a consistent setup with all helpers registered. + * + * @param loader The TemplateLoader to use. If null, handlebars defaults to + * ClassPathTemplateLoader. + * @return A HandleBars instance with appropriate settings and registered helpers + */ + static Handlebars getHandlebarsInstance(@Nullable TemplateLoader loader) { + Handlebars handlebars = + new Handlebars() + .with(HIGH_CONCURRENCY_TEMPLATE_CACHE) + .preEvaluatePartialBlocks(false) + .parentScopeResolution(false) + .setCharset(StandardCharsets.UTF_8); + if (loader != null) { + handlebars.with(loader); + } + return HandlebarsHelpers.registerHelpers(handlebars); + } + public static boolean isAssetFileHandlebars(String filename) { if (filename == null) { return false; @@ -63,7 +81,7 @@ public static boolean isAssetFileHandlebars(String filename) { private final Template template; /** Logging class instance. */ - private static final Logger log = LogManager.getLogger(Token.class); + private static final Logger log = LogManager.getLogger(HandlebarsUtil.class); /** Handlebars partial template source that uses Add-On files */ private static record LibraryTemplateSource(@Nonnull Library library, @Nonnull String filename) @@ -124,29 +142,6 @@ public LibraryTemplateSource sourceAt(@Nonnull final String location) { } } - private static enum MapToolHelpers implements Helper { - /** - * Turns the textual form of the value into a base64-encoded string. For example: - * - *
-     * <script type="application/json;base64" id="jsonProperty">
-     *   {{ base64Encode properties[0].value }}
-     * </script>
-     * <script type="application/javascript">
-     * const jsonProperty = JSON.parse(atob(document.getElementById("jsonProperty").innerText));
-     * </script>
-     * 
- */ - base64Encode { - @Override - public Object apply(final Object context, final Options options) { - byte[] message = context.toString().getBytes(StandardCharsets.UTF_8); - - return new Handlebars.SafeString(Base64.getUrlEncoder().encodeToString(message)); - } - } - } - /** * Creates a new instance of the utility class. * @@ -155,16 +150,8 @@ public Object apply(final Object context, final Options options) { * @throws IOException If there is an error compiling the template. */ private HandlebarsUtil(String stringTemplate, TemplateLoader loader) throws IOException { + Handlebars handlebars = getHandlebarsInstance(loader); try { - Handlebars handlebars = new Handlebars(loader); - StringHelpers.register(handlebars); - Arrays.stream(ConditionalHelpers.values()) - .forEach(h -> handlebars.registerHelper(h.name(), h)); - NumberHelper.register(handlebars); - handlebars.registerHelper(AssignHelper.NAME, AssignHelper.INSTANCE); - handlebars.registerHelper(IncludeHelper.NAME, IncludeHelper.INSTANCE); - Arrays.stream(MapToolHelpers.values()).forEach(h -> handlebars.registerHelper(h.name(), h)); - template = handlebars.compileInline(stringTemplate); } catch (IOException e) { log.error("Handlebars Error: {}", e.getMessage()); diff --git a/src/main/resources/net/rptools/maptool/language/i18n.properties b/src/main/resources/net/rptools/maptool/language/i18n.properties index 3f0d4167d9..06fe33bac5 100644 --- a/src/main/resources/net/rptools/maptool/language/i18n.properties +++ b/src/main/resources/net/rptools/maptool/language/i18n.properties @@ -781,13 +781,13 @@ Preferences.combo.tokens.naming.creature = Use "{0}" Preferences.label.tokens.dialog = Show Dialog on New Token Preferences.label.tokens.dialog.tooltip = Determines whether the New Token dialog appears when a token is dropped onto the map. Preferences.label.tokens.statsheet = Stat-Sheet Portrait Size -Preferences.label.tokens.statsheet.tooltip = Size of the image to include on the pop-up stat-sheet. Set to zero to disable portraits. +Preferences.label.tokens.statsheet.tooltip = Size of the image to display on the pop-up stat-sheet. Set to zero to disable portraits. Preferences.label.tokens.portrait.mouse = Show Portrait on Stat-Sheet -Preferences.label.tokens.portrait.mouse.tooltip = Include the portrait on the pop-up stat-sheet. +Preferences.label.tokens.portrait.mouse.tooltip = Whether to show the portrait when the mouse hovers over a Token. Preferences.label.tokens.statsheet.mouse = Show stat-sheet on mouseover Preferences.label.tokens.statsheet.mouse.tooltip = Whether to show the stat-sheet when the mouse hovers over a Token. -Preferences.label.tokens.statsheet.shift = Stat-Sheet requires Shift key -Preferences.label.tokens.statsheet.shift.tooltip = The stat-sheet will only show when the mouse hovers over a Token if the Shift key is also held down. Otherwise, Shift key will hide stat-sheet on mouse hovers. +Preferences.label.tokens.statsheet.shift = Stat sheet requires Shift key +Preferences.label.tokens.statsheet.shift.tooltip = The stat sheet will only show when the mouse hovers over a Token if the Shift key is also held down. Otherwise, Shift key will hide stat sheet on mouse hovers. Preferences.label.tokens.arrow.background = Token Facing Arrow color Preferences.label.tokens.arrow.border = Token Facing Arrow border color Preferences.label.tokens.arrow = Force Token Facing Arrow @@ -2960,7 +2960,7 @@ tool.ovalexpose.tooltip = Expose/Hide an oval on the Fog of War. tool.ovaltopology.instructions = LClick: set initial/final point, Shift+LClick: start erase oval tool.ovaltopology.tooltip = Draw an oval VBL. tool.ovaltopologyhollow.tooltip = Draw a hollow oval VBL. -tool.pointer.instructions = LClick: select, LDrag: move selected, RClick: menu, RDrag: move map, MWheel: zoom, MClick and Spacebar: Toggle waypoint, Shift+MouseOver: no stat-sheet +tool.pointer.instructions = LClick: select, LDrag: move selected, RClick: menu, RDrag: move map, MWheel: zoom, MClick and Spacebar: Toggle waypoint, Shift+MouseOver: no statsheet tool.pointer.tooltip = Pointer tool tool.poly.instructions = LClick: lay initial/final point, RClick (while drawing): set intermediate point, RDrag: move map, Shift+LClick (initial): Erase poly area tool.poly.tooltip = Draw closed polygon. @@ -3152,7 +3152,7 @@ library.error.errorRunningEvent = Continuing after error running event {0} @ {1 library.error.notFound = Library with namespace {0} does not exist. library.error.noEventHandler = Library with namespace {1} does not contains an event handler for {0}. library.error.retrievingEventHandler = Error retrieving event handlers for event {0}. -library.error.addOn.sheet = Error adding stat-sheet {1}, namespace {0} +library.error.addOn.sheet = Error adding stat sheet {1}, namespace {0} library.export.information = This will extract the Lib:Token to the specified directory \ as the format required for an add-on library.
Most functionality will work without modifications \ but you will probably still need to make some modifications. @@ -3186,7 +3186,7 @@ library.dialog.copy.title = The copied CSS is for testing \ purposes only.
Within your add-on use
lib://net.rptools.maptool/css/mt-stat-sheet.css \ or
lib://net.rptools.maptool/css/mt-theme.css library.dialog.copyMTThemeCSS = Copy CSS Theme to clipbaord -library.dialog.copyMTStatSheetTheme = Copy Stat-Sheet Theme to clipbaord +library.dialog.copyMTStatSheetTheme = Copy Stat Sheet Theme to clipbaord # Game Data data.error.cantConvertTo = Can''t convert {0} to {1}. @@ -3213,7 +3213,7 @@ EditTokenDialog.button.movetomap.tooltip=Move or Copy selected blocking layer to Label.label=Label: # StatSheet -token.statSheet.legacyStatSheetDescription = Legacy (pre 1.14) Stat-Sheet +token.statSheet.legacyStatSheetDescription = Legacy (pre 1.14) Stat Sheet token.statSheet.noStatSheetDescription = No Stat-Sheet token.statSheet.useDefault = Default Stat-Sheet for Property Type diff --git a/src/test/java/net/rptools/maptool/client/functions/StrListFunctionsTest.java b/src/test/java/net/rptools/maptool/client/functions/StrListFunctionsTest.java index d1fb6767a9..4e0e6c84cc 100644 --- a/src/test/java/net/rptools/maptool/client/functions/StrListFunctionsTest.java +++ b/src/test/java/net/rptools/maptool/client/functions/StrListFunctionsTest.java @@ -28,7 +28,7 @@ public class StrListFunctionsTest { private String delim = ","; private List toParms(Object... parms) { - ArrayList result = new ArrayList(); + List result = new ArrayList<>(); for (Object arg : parms) { if (arg instanceof Integer) arg = new BigDecimal((Integer) arg); result.add(arg); diff --git a/src/test/java/net/rptools/maptool/util/HandlebarsHelperTest.java b/src/test/java/net/rptools/maptool/util/HandlebarsHelperTest.java new file mode 100644 index 0000000000..33b6464a47 --- /dev/null +++ b/src/test/java/net/rptools/maptool/util/HandlebarsHelperTest.java @@ -0,0 +1,297 @@ +/* + * 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.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.github.jknack.handlebars.Context; +import com.github.jknack.handlebars.Handlebars; +import com.github.jknack.handlebars.Template; +import com.github.jknack.handlebars.io.ClassPathTemplateLoader; +import com.github.jknack.handlebars.io.TemplateLoader; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.function.BiFunction; +import org.junit.jupiter.api.Test; + +public class HandlebarsHelperTest { + private final TemplateLoader templateLoader = + new ClassPathTemplateLoader("/net/rptools/maptool/util/handlebars/"); + private final Handlebars hb = HandlebarsUtil.getHandlebarsInstance(templateLoader); + private final Map mathsContextData = + new HashMap<>() { + { + put("num1", 1); + put("num2", "-1"); + put("arr1", new Object[] {0.5, "1", 2, "3.5"}); + } + }; + private final Context mathsContext = Context.newBuilder(mathsContextData).build(); + + /* Maths function names + abs + add + div + divide + max + min + mod + multiply + pow + sqrt + subtract + */ + + private final BiFunction applyFunction = + (helper, values) -> { + try { + Template template = hb.compileInline(String.format("{{%s %s}}", helper, values)); + return template.apply(mathsContext); + } catch (IOException ex) { + return null; + } + }; + + @Test + public void testMathsConstants() { + assertEquals("3.141592653589793", applyFunction.apply("pi", "")); + assertEquals("6.283185307179586", applyFunction.apply("tau", "")); + } + + @Test + public void testMathsHelpersOneArg() { + assertEquals("1", applyFunction.apply("abs", "1")); + assertEquals("1.1", applyFunction.apply("abs", "-1.1")); + + assertEquals("1", applyFunction.apply("add", "1")); + assertEquals("-1", applyFunction.apply("add", "-1")); + assertEquals("NaN", applyFunction.apply("div", "1")); + assertEquals("1", applyFunction.apply("divide", "1")); + + assertEquals("1", applyFunction.apply("max", "1")); + assertEquals("1", applyFunction.apply("min", "1")); + assertEquals("NaN", applyFunction.apply("mod", "1")); + assertEquals("1", applyFunction.apply("multiply", "1")); + assertEquals("NaN", applyFunction.apply("pow", "1")); + + assertEquals("2", applyFunction.apply("sqrt", "4")); + assertEquals("NaN", applyFunction.apply("sqrt", "-1")); + assertEquals("1", applyFunction.apply("subtract", "1")); + } + + @Test + public void testMathsHelpersContextVariables() { + assertEquals( + new BigDecimal("1").doubleValue(), + new BigDecimal(applyFunction.apply("abs", "num1")).doubleValue()); + assertEquals( + new BigDecimal("1").doubleValue(), + new BigDecimal(applyFunction.apply("abs", "num2")).doubleValue()); + assertEquals("NaN", applyFunction.apply("abs", "arr1")); + assertEquals( + new BigDecimal("1").doubleValue(), + new BigDecimal(applyFunction.apply("add", "num1")).doubleValue()); + assertEquals( + new BigDecimal("-1").doubleValue(), + new BigDecimal(applyFunction.apply("add", "num2")).doubleValue()); + assertEquals( + new BigDecimal("7").doubleValue(), + new BigDecimal(applyFunction.apply("add", "arr1")).doubleValue()); + assertEquals("NaN", applyFunction.apply("div", "num1")); + assertEquals("NaN", applyFunction.apply("div", "num2")); + assertEquals( + new BigDecimal("0").doubleValue(), + new BigDecimal(applyFunction.apply("div", "arr1")).doubleValue()); + assertEquals( + new BigDecimal("1").doubleValue(), + new BigDecimal(applyFunction.apply("divide", "num1")).doubleValue()); + assertEquals( + new BigDecimal("-1").doubleValue(), + new BigDecimal(applyFunction.apply("divide", "num2")).doubleValue()); + assertEquals( + new BigDecimal("0.07142857142857142").doubleValue(), + new BigDecimal(applyFunction.apply("divide", "arr1")).doubleValue()); + assertEquals( + new BigDecimal("1").doubleValue(), + new BigDecimal(applyFunction.apply("max", "num1")).doubleValue()); + assertEquals( + new BigDecimal("-1").doubleValue(), + new BigDecimal(applyFunction.apply("max", "num2")).doubleValue()); + assertEquals( + new BigDecimal("3.5").doubleValue(), + new BigDecimal(applyFunction.apply("max", "arr1")).doubleValue()); + assertEquals( + new BigDecimal("1").doubleValue(), + new BigDecimal(applyFunction.apply("min", "num1")).doubleValue()); + assertEquals( + new BigDecimal("-1").doubleValue(), + new BigDecimal(applyFunction.apply("min", "num2")).doubleValue()); + assertEquals( + new BigDecimal("0.5").doubleValue(), + new BigDecimal(applyFunction.apply("min", "arr1")).doubleValue()); + assertEquals("NaN", applyFunction.apply("mod", "num1")); + assertEquals("NaN", applyFunction.apply("mod", "num2")); + assertEquals( + new BigDecimal("0").doubleValue(), + new BigDecimal(applyFunction.apply("mod", "arr1")).doubleValue()); + assertEquals( + new BigDecimal("1").doubleValue(), + new BigDecimal(applyFunction.apply("multiply", "num1")).doubleValue()); + assertEquals( + new BigDecimal("-1").doubleValue(), + new BigDecimal(applyFunction.apply("multiply", "num2")).doubleValue()); + assertEquals( + new BigDecimal("3.5").doubleValue(), + new BigDecimal(applyFunction.apply("multiply", "arr1")).doubleValue()); + assertEquals("NaN", applyFunction.apply("pow", "num1")); + assertEquals("NaN", applyFunction.apply("pow", "num2")); + assertEquals( + new BigDecimal("1").doubleValue(), + new BigDecimal(applyFunction.apply("pow", "arr1")).doubleValue()); + assertEquals( + new BigDecimal("1").doubleValue(), + new BigDecimal(applyFunction.apply("sqrt", "num1")).doubleValue()); + assertEquals("NaN", applyFunction.apply("sqrt", "num2")); + assertEquals("NaN", applyFunction.apply("sqrt", "arr1")); + assertEquals( + new BigDecimal("1").doubleValue(), + new BigDecimal(applyFunction.apply("subtract", "num1")).doubleValue()); + assertEquals( + new BigDecimal("-1").doubleValue(), + new BigDecimal(applyFunction.apply("subtract", "num2")).doubleValue()); + assertEquals( + new BigDecimal("0").doubleValue(), + new BigDecimal(applyFunction.apply("subtract", "arr1")).doubleValue()); + } + + @Test + public void testMathsHelpersMixedArgs() { + assertEquals( + new BigDecimal("7").doubleValue(), + new BigDecimal(applyFunction.apply("add", "-2 2 \"[3,-3]\" num1 num2 arr1 x=5 y=-5")) + .doubleValue()); + } + + @Test + public void testMathsHelpersNested() { + String complex = + """ + multiply 0.05 + (add + (subtract + (multiply (pi) 2) + (tau) + ) + "0.1e1") + (mod + (add + (div + (divide 9 3 2) + 1) + (pow + (abs + (subtract 0 + (sqrt 16) + ) + ) + 2 + ) + (max arr1) + (min arr1) + ) + 20 + ) + """; + assertEquals( + new BigDecimal("1").doubleValue(), + new BigDecimal(applyFunction.apply(complex, "")).doubleValue()); + } + + private final Map helperContextData = + new HashMap<>() { + { + put( + "id", + new HashMap() { + { + put("firstName", "First"); + put("lastName", "Last"); + } + }); + } + }; + private final Context helperContext = Context.newBuilder(helperContextData).build(); + + @Test + public void embeddedHelperTest() { + String expected = + """ + """; + try { + String templateText = + templateLoader.sourceAt("embeddedHelperTest").content(StandardCharsets.UTF_8); + Template template = hb.compileInline(templateText); + assertEquals(expected, template.apply(helperContext)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Test + public void includeHelperTest() { + String expected = "FirstLast"; + try { + String templateText = + templateLoader.sourceAt("includeHelperTest").content(StandardCharsets.UTF_8); + Template template = hb.compileInline(templateText); + assertEquals(expected, template.apply(helperContext)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Test + public void jsonHelperTest() { + String expected = "{\"id\":{\"firstName\":\"First\",\"lastName\":\"Last\"}}"; + try { + String templateText = + templateLoader.sourceAt("jsonHelperTest").content(StandardCharsets.UTF_8); + Template template = hb.compileInline(templateText); + assertEquals(expected, template.apply(helperContext)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Test + public void partialHelperTest() { + String expected = "FirstLast"; + try { + String templateText = + templateLoader.sourceAt("partialHelperTest").content(StandardCharsets.UTF_8); + Template template = hb.compileInline(templateText); + assertEquals(expected, template.apply(helperContext)); + template = hb.compileInline("{{#*inline \"myPartial\"}}success{{/inline}}{{> myPartial}}"); + assertEquals("success", template.apply(helperContext)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/resources/net/rptools/maptool/util/handlebars/embeddedHelperTest.hbs b/src/test/resources/net/rptools/maptool/util/handlebars/embeddedHelperTest.hbs new file mode 100644 index 0000000000..fa39f7af4f --- /dev/null +++ b/src/test/resources/net/rptools/maptool/util/handlebars/embeddedHelperTest.hbs @@ -0,0 +1 @@ +{{embedded "user" ["id"]}} \ No newline at end of file diff --git a/src/test/resources/net/rptools/maptool/util/handlebars/includeHelperTest.hbs b/src/test/resources/net/rptools/maptool/util/handlebars/includeHelperTest.hbs new file mode 100644 index 0000000000..bd06ce0cdb --- /dev/null +++ b/src/test/resources/net/rptools/maptool/util/handlebars/includeHelperTest.hbs @@ -0,0 +1 @@ +{{#with this.id}}{{include "user"}}{{/with}}{{log "info" "ping"}} \ No newline at end of file diff --git a/src/test/resources/net/rptools/maptool/util/handlebars/jsonHelperTest.hbs b/src/test/resources/net/rptools/maptool/util/handlebars/jsonHelperTest.hbs new file mode 100644 index 0000000000..4a798dce38 --- /dev/null +++ b/src/test/resources/net/rptools/maptool/util/handlebars/jsonHelperTest.hbs @@ -0,0 +1 @@ +{{json this}}{{log "info" "ping"}} \ No newline at end of file diff --git a/src/test/resources/net/rptools/maptool/util/handlebars/partialHelperTest.hbs b/src/test/resources/net/rptools/maptool/util/handlebars/partialHelperTest.hbs new file mode 100644 index 0000000000..6d2c9a61fe --- /dev/null +++ b/src/test/resources/net/rptools/maptool/util/handlebars/partialHelperTest.hbs @@ -0,0 +1 @@ +{{#with this.id}}{{> "user"}}{{/with}}{{log "info" "ping"}} \ No newline at end of file diff --git a/src/test/resources/net/rptools/maptool/util/handlebars/user.hbs b/src/test/resources/net/rptools/maptool/util/handlebars/user.hbs new file mode 100644 index 0000000000..70da4f61ea --- /dev/null +++ b/src/test/resources/net/rptools/maptool/util/handlebars/user.hbs @@ -0,0 +1 @@ +{{firstName}}{{lastName}} \ No newline at end of file