From 16bb9496913826ba7097aa6c5c624ed295ec1410 Mon Sep 17 00:00:00 2001 From: Thijs Wiefferink Date: Fri, 20 Jan 2017 17:09:51 +0100 Subject: [PATCH] Add processing, translation and file layers, major refactoring - Added LanguageManager class from AreaShop to manage saving/loading language files - Added Transifex class from AreaShop to convert language files downloaded from Transifex - Added Message class from AreaShop to process messages: - Easily get, modify and send messages by a builder pattern - Insert message variables (allows message composition) - Insert variables based on index of the argument - Insert variables from a given ReplacementProvider - Split the parser, internal representation and generators into seperate classes: - The internal representation (InteractiveMessage) only encodes the structure and formatting of a message, knowing nothing about how it got build and nothing about sending it - The parser takes some input and builds an InteractiveMessage (this way multiple parsers can be implemented) - Generators take care of producing something that can be sent by reading an InteractiveMessage (ConsoleGenerator and TellrawGenerator are available) - Should make it easier to setup unit testing (implement equals() of InteractiveMessage and after that test parsers and generators separately) - Refactor and improve performance --- FancyMessageFormat/.gitignore | 5 - FancyMessageFormat/src/ConverterTest.java | 26 - .../src/FancyMessageFormatConverter.java | 782 ------------------ README.md | 8 +- Specifications & Guidelines.md | 24 +- pom.xml | 43 + .../generators/ConsoleGenerator.java | 101 +++ .../generators/TellrawGenerator.java | 235 ++++++ .../message/InteractiveMessage.java | 9 + .../message/InteractiveMessagePart.java | 27 + .../message/TextMessagePart.java | 29 + .../message/enums/Click.java | 7 + .../message/enums/Color.java | 20 + .../message/enums/Control.java | 6 + .../message/enums/Format.java | 9 + .../message/enums/Hover.java | 6 + .../parsers/YamlParser.java | 435 ++++++++++ .../processing/Message.java | 735 ++++++++++++++++ .../processing/ReplacementProvider.java | 13 + .../source/LanguageManager.java | 208 +++++ .../source/MessageProvider.java | 16 + .../translation/Transifex.java | 147 ++++ 22 files changed, 2064 insertions(+), 827 deletions(-) delete mode 100644 FancyMessageFormat/.gitignore delete mode 100644 FancyMessageFormat/src/ConverterTest.java delete mode 100644 FancyMessageFormat/src/FancyMessageFormatConverter.java create mode 100644 pom.xml create mode 100644 src/main/java/me/wiefferink/interactivemessenger/generators/ConsoleGenerator.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/generators/TellrawGenerator.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/message/InteractiveMessage.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/message/InteractiveMessagePart.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/message/TextMessagePart.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/message/enums/Click.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/message/enums/Color.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/message/enums/Control.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/message/enums/Format.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/message/enums/Hover.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/parsers/YamlParser.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/processing/Message.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/processing/ReplacementProvider.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/source/LanguageManager.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/source/MessageProvider.java create mode 100644 src/main/java/me/wiefferink/interactivemessenger/translation/Transifex.java diff --git a/FancyMessageFormat/.gitignore b/FancyMessageFormat/.gitignore deleted file mode 100644 index 0422a19..0000000 --- a/FancyMessageFormat/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -/bin - -.metadata -.classpath -.project \ No newline at end of file diff --git a/FancyMessageFormat/src/ConverterTest.java b/FancyMessageFormat/src/ConverterTest.java deleted file mode 100644 index 426e3a4..0000000 --- a/FancyMessageFormat/src/ConverterTest.java +++ /dev/null @@ -1,26 +0,0 @@ - -public class ConverterTest { - - public static void main(String[] args) { - String in = ""; - - in += "Hey there![break]" + '\n'; - in += "Welcome to FancyMessageFormat! :)" + '\n'; - - String out = FancyMessageFormatConverter.convertToJSON(in); - System.out.println(out); - - in = ""; - in += "We are [underline]able[/underline][break]" + '\n'; - in += "To do some [red][b]really[/b][white] " + '\n'; - in += "[blue][i]CrAZY[/i] " + '\n'; - in += " hover: Yiiih[yellow]aaaaa!" + '\n'; - in += " hover: [blue]Aiaiaiai[green]aiaiaiai!" + '\n'; - in += "stuff with your text - " + '\n'; - in += "while keeping it quite readable at the same time. [red][b]Awesome,[/b][blue] isn't it?" + '\n'; - - out = FancyMessageFormatConverter.convertToJSON(in); - System.out.println(out); - } - -} diff --git a/FancyMessageFormat/src/FancyMessageFormatConverter.java b/FancyMessageFormat/src/FancyMessageFormatConverter.java deleted file mode 100644 index 2928cfa..0000000 --- a/FancyMessageFormat/src/FancyMessageFormatConverter.java +++ /dev/null @@ -1,782 +0,0 @@ -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.Set; - -/** - * FancyMessageFormat converter, a library that enables to convert - * messages in the FancyMessageFormat to Minecraft's bulky tellraw - * format. - * - * @author NLThijs48 - * @author Tobias aka Phoenix | http://www.phoenix-iv.de - */ -public class FancyMessageFormatConverter { - - private static final char TAG_BEFORE = '['; - private static final char TAG_AFTER = ']'; - private static final char END_TAG_INDICATOR = '/'; - - /** The special character that prefixes all basic chat formatting codes. */ - private static final char SIMPLE_FORMAT_PREFIX_CHAR = '\u00A7'; - - /** Resets all previous chat colors or formats. */ - private static final char SIMPLE_FORMAT_RESET_CHAR = 'r'; - - /** Lookup table for all continuous tags (marked by []) */ - private static final HashMap BRACKET_TAG_LIST = new HashMap(); - - /** Lookup table for all interactive tags */ - private static final HashMap INTERACTIVE_TAG_LIST = new HashMap(); - - static { - // Enlist all possible tags - // (They go into a HashMap for lookup purposes) - cacheTags(BRACKET_TAG_LIST, Color.class); - cacheTags(BRACKET_TAG_LIST, FormatType.class); - cacheTags(BRACKET_TAG_LIST, FormatCloseTag.class); - cacheTags(BRACKET_TAG_LIST, ControlTag.class); - // Interactive tags - cacheTags(INTERACTIVE_TAG_LIST, ClickType.class); - cacheTags(INTERACTIVE_TAG_LIST, HoverType.class); - } - - - /** - * Puts all constants in the given Tag class into the given lookup table. - */ - private static void cacheTags(HashMap tagList, Class tags) { - for (Tag tag : tags.getEnumConstants()) { - for(String key : tag.getTags()) { - tagList.put(key, tag); - } - } - } - - - - - - - - - - - // ------------------------------------------------------------------------------------------ - // ------------------------------- Public / Interface ------------------------------- - // ------------------------------------------------------------------------------------------ - - - /** - * Parses the given FancyMessageFormat message to a JSON array that can be - * used with the tellraw command and the like. - */ - public static String convertToJSON(final String message) { - return convertToJSON(Arrays.asList(message)); - } - - /** - * Parses the given FancyMessageFormat message to a JSON array that can be - * used with the tellraw command and the like. - * - * @param inputLines Input message split at line breaks. - */ - public static String convertToJSON(final Iterable inputLines) { - ArrayList lines = cleanInputString(inputLines); - - LinkedList message = parse(lines); - StringBuilder sb = new StringBuilder(); - if (message.size() == 1) { - sb.append(message.getFirst().toJSON()); - } else { - sb.append("{text=\"\",extra:["); - for (InteractiveMessagePart messagePart : message) { - sb.append(messagePart.toJSON()); - sb.append(','); - } - sb.deleteCharAt(sb.length() - 1); - sb.append("]}"); - } - - - return sb.toString(); - } - - - /** - * Parses the given FancyMessageFormat message to a String containing control characters - * for formatting that can be used for console outputs, but also for normal player - * messages. - *

- * The returned message will only contain colors, bold, italic, underlining and 'magic' - * characters. Hovers and other advanced tellraw tags will be skipped. - * - * @param inputLines Input message split at line breaks. - */ - public static String convertToConsole(final String message) { - return convertToConsole(Arrays.asList(message)); - } - - /** - * Parses the given FancyMessageFormat message to a String containing control characters - * for formatting that can be used for console outputs, but also for normal player - * messages. - *

- * The returned message will only contain colors, bold, italic, underlining and 'magic' - * characters. Hovers and other advanced tellraw tags will be skipped. - */ - public static String convertToConsole(final Iterable inputLines) { - ArrayList lines = cleanInputString(inputLines); - StringBuilder result = new StringBuilder(); - - Color currentColor = Color.WHITE; - Set currentFormatting = new HashSet(); - boolean formatReset = false; - - for (String line : lines) { - if (isTaggedInteractive(line)) continue; // Skip. Ignore advanced formatting. - - while (!line.isEmpty()) { - TaggedContent tagged = getNextTag(line, true); - String text = line; - Tag tag = null; - if (tagged != null) { - text = tagged.precedingContent; - tag = tagged.tag; - line = tagged.subsequentContent; - } else { - line = ""; - } - if (!text.isEmpty() && formatReset) { - // Reset all formatting - result.append(SIMPLE_FORMAT_PREFIX_CHAR); - result.append(SIMPLE_FORMAT_RESET_CHAR); - // Re-apply active formats - if (currentColor != Color.WHITE) { - result.append(SIMPLE_FORMAT_PREFIX_CHAR); - result.append(currentColor.getNativeFormattingCode()); - } - for(FormatType format : currentFormatting) { - result.append(SIMPLE_FORMAT_PREFIX_CHAR); - result.append(format.getNativeFormattingCode()); - } - formatReset = false; - } - result.append(text); - if (tag != null) { - if (tag == ControlTag.BREAK) { - result.append('\n'); - result.append(SIMPLE_FORMAT_PREFIX_CHAR); - result.append(SIMPLE_FORMAT_RESET_CHAR); - currentColor = Color.WHITE; - currentFormatting.clear(); - formatReset = false; - line = ""; - } else if (tag.getClass() == Color.class) { - Color color = (Color) tag; - result.append(SIMPLE_FORMAT_PREFIX_CHAR); - result.append(color.getNativeFormattingCode()); - currentColor = color; - } else if (tag.getClass() == FormatType.class) { - FormatType format = (FormatType) tag; - result.append(SIMPLE_FORMAT_PREFIX_CHAR); - result.append(format.getNativeFormattingCode()); - currentFormatting.add(format); - } else if (tag.getClass() == FormatCloseTag.class) { - FormatCloseTag format = (FormatCloseTag) tag; - currentFormatting.remove(format.closes); - formatReset = true; - } - } - } - } - - return result.toString(); - } - - - - - - // ------------------------------------------------------------------------------------------ - // ------------------------------- Private functions ------------------------------- - // ------------------------------------------------------------------------------------------ - - - /** - *

    - *
  • Splits lines at line breaks (creating a new line in the Array). - *
  • Removes empty lines at the beginning. - *
  • Removes lines with properties in front of the first text-line. - *
- */ - private static ArrayList cleanInputString(Iterable inputLines) { - // Split lines at line breaks - // In the end we will have a list with one line per element - ArrayList lines = new ArrayList<>(); - for (String line : inputLines) { - lines.addAll(Arrays.asList(line.split("\\r?\\n"))); - } - - // Remove any special lines at the start (a real text line should be first) - while (!lines.isEmpty() && isTaggedInteractive(lines.get(0))) { - lines.remove(0); - } - - return lines; - } - - - private static LinkedList parse(final Iterable inputLines) { - LinkedList message = new LinkedList(); - - Color currentColor = Color.WHITE; - Set currentFormatting = new HashSet(); - - lineLoop: for (String line : inputLines) { - InteractiveMessagePart messagePart; - TaggedContent interactiveTag = getInteractiveTag(line); - boolean isTextLine = interactiveTag == null; - boolean isHoverLine = false; - - if (isTextLine) { - messagePart = new InteractiveMessagePart(); - message.add(messagePart); - } - else /* if Interactive formatting */ { - messagePart = message.getLast(); - Tag tag = interactiveTag.tag; - if (tag instanceof ClickType) { - messagePart.clickType = (ClickType) interactiveTag.tag; - messagePart.clickContent = interactiveTag.subsequentContent; - } else if (tag instanceof HoverType) { - line = interactiveTag.subsequentContent; - isHoverLine = true; - if (messagePart.hoverType != tag) { - // Hover type changed - messagePart.hoverContent = new LinkedList(); - messagePart.hoverType = (HoverType) tag; - } - // Add hover content below - } - } - - if (isTextLine || isHoverLine) { - // Parse inline tags - - Color currentLineColor = currentColor; - Set currentLineFormatting = currentFormatting; - LinkedList targetList = messagePart.content; - boolean parseBreak = true; - if (isHoverLine) { - // Reset - use own - currentLineColor = Color.WHITE; - currentLineFormatting = new HashSet(); - targetList = messagePart.hoverContent; - parseBreak = false; - - // Add line break after previous hover line - if (!targetList.isEmpty()) { - targetList.getLast().text += '\n'; - } - } - - // Split into pieces at places where formatting changes - while (!line.isEmpty()) { - String textToAdd = null; - TaggedContent nextTag = getNextTag(line, parseBreak); - boolean tagged = nextTag != null; - - if (!tagged) { - textToAdd = line; - line = ""; - } else { - textToAdd = nextTag.precedingContent; - line = nextTag.subsequentContent; - } - - if (!textToAdd.isEmpty()) { - // Add a text part with the correct formatting - TextMessagePart part = new TextMessagePart(); - part.text = textToAdd; - part.formatTypes = new HashSet(currentLineFormatting); - part.color = currentLineColor; - targetList.add(part); - } - - // Handle the change in formatting if a Tag has been detected (this needs to be after creating the InteractiveMessagePart) - if (tagged) { - // Handle the formatting tag - Tag tag = nextTag.tag; - if (tag instanceof Color) { - currentLineColor = (Color) tag; - } else if (tag instanceof FormatType) { - currentLineFormatting.add((FormatType) tag); - } else if (tag instanceof FormatCloseTag) { - currentLineFormatting.remove(((FormatCloseTag) tag).closes); - } else if (tag == ControlTag.BREAK) { - targetList.getLast().text += '\n'; - continue lineLoop; - } - } - } - - if (!isHoverLine) { - // Adapt global attributes - currentColor = currentLineColor; - } - } - } - - return message; - } - - - /** - * Searches and returns the first continuous tag found in the given String. - * @return The tag (plus its preceding and subsequent content) if found. - * Null if nothing is found. - */ - private static TaggedContent getNextTag(String line, boolean parseBreak) { - for (int startIndex = 0; startIndex < line.length(); startIndex++) { - int start = line.indexOf(TAG_BEFORE, startIndex); - if (start != -1) { - int end = line.indexOf(TAG_AFTER, start); - if (end != -1) { - String inBetween = line.substring(start+1, end).toLowerCase(); - if (BRACKET_TAG_LIST.containsKey(inBetween)) { - Tag tag = BRACKET_TAG_LIST.get(inBetween); - if (tag == ControlTag.ESCAPE) { - // Ignore next char - line = line.substring(0, start) + line.substring(end + 1); - startIndex = start; - } else if (!parseBreak && tag == ControlTag.ESCAPE) { - // Ignore break - startIndex = end + 1; - } else { - String previousContent = line.substring(0, start); - String subsequentContent = line.substring(end + 1); - return new TaggedContent(previousContent, tag, subsequentContent); - } - } else { - startIndex = start; - } - } else { - return null; - } - } else { - return null; - } - } - return null; - } - - - /** - * If the given line defines an interactive property (e.g. "hover: myText") - * the tag / property will be returned. Otherwise null is returned. - */ - private static TaggedContent getInteractiveTag(String line) { - for (int index = 0; index < line.length(); index++) { - char c = line.charAt(index); - if (c == ' ' || c == '\t') { - // Ignore (Skip spacing) - } else { - int end = line.indexOf(": ", index); - if (end != -1) { - String inBetween = line.substring(index, end).toLowerCase(); - if (INTERACTIVE_TAG_LIST.containsKey(inBetween)) { - Tag tag = INTERACTIVE_TAG_LIST.get(inBetween); - String subsequentContent = line.substring(end + 2); - return new TaggedContent(null, tag, subsequentContent); - } - } - return null; - } - } - return null; - } - - - private static boolean isTaggedInteractive(String line) { - return getInteractiveTag(line) != null; - } - - - /** - * Produce a string in double quotes with backslash sequences in all the - * right places. - * @param string A String - * @return A String correctly formatted for insertion in a JSON text. - */ - /* - * Copyright (c) 2002 JSON.org - * Licensed under the Apache License, Version 2.0 - */ - private static String quoteStringJson(String string) { - if (string == null || string.length() == 0) { - return "\"\""; - } - - char c = 0; - int i; - int len = string.length(); - StringBuilder sb = new StringBuilder(len + 4); - String t; - - sb.append('"'); - for (i = 0; i < len; i += 1) { - c = string.charAt(i); - switch (c) { - case '\\': - case '"': - sb.append('\\'); - sb.append(c); - break; - case '/': - sb.append('\\'); - sb.append(c); - break; - case '\b': - sb.append("\\b"); - break; - case '\t': - sb.append("\\t"); - break; - case '\n': - sb.append("\\n"); - break; - case '\f': - sb.append("\\f"); - break; - case '\r': - sb.append("\\r"); - break; - default: - if (c < ' ') { - t = "000" + Integer.toHexString(c); - sb.append("\\u" + t.substring(t.length() - 4)); - } else { - sb.append(c); - } - } - } - sb.append('"'); - return sb.toString(); - } - - - - - - - // ------------------------------------------------------------------------------------------ - // ------------------------------- Helper classes ------------------------------- - // ------------------------------------------------------------------------------------------ - - - private static class TaggedContent { - final String precedingContent; - final Tag tag; - final String subsequentContent; - - public TaggedContent(String pre, Tag tag, String sub) { - this.precedingContent = pre; - this.tag = tag; - this.subsequentContent = sub; - } - } - - - /** - * Holds a string with basic (non-interactive) formatting. - */ - private static class TextMessagePart { - String text = ""; - Color color = Color.WHITE; - Set formatTypes = new HashSet(); - - - String toJSON() { - StringBuilder sb = new StringBuilder(); - sb.append('{'); - sb.append("text:").append(quoteStringJson(text)); - if (color != Color.WHITE) { - sb.append(",color:").append(color.jsonValue); - } - for (FormatType formatting : formatTypes) { - sb.append(','); - sb.append(formatting.jsonKey).append(':'); - sb.append("true"); - } - sb.append('}'); - return sb.toString(); - } - - boolean hasFormatting() { - return !(color == Color.WHITE && formatTypes.isEmpty()); - } - } - - - /** - * Holds a string with interactive formatting. - */ - private static class InteractiveMessagePart { - - LinkedList content = new LinkedList(); - - // Click - ClickType clickType = null; - String clickContent = ""; - - // Hover - HoverType hoverType = null; - LinkedList hoverContent = null; - - - String toJSON() { - StringBuilder sb = new StringBuilder(); - if (content.size() == 1) { - // Add attributes to TextMessagePart object - sb.append(content.getFirst().toJSON()); - sb.deleteCharAt(sb.length() - 1); - } else { - sb.append('{'); - sb.append("text=\"\",extra:["); - for (TextMessagePart textPart : content) { - sb.append(textPart.toJSON()); - sb.append(','); - } - sb.deleteCharAt(sb.length() - 1); - sb.append(']'); - } - if (clickType != null) { - sb.append(','); - sb.append("clickEvent:{"); - sb.append("action:").append(clickType.getJsonKey()).append(','); - sb.append("value:").append(quoteStringJson(clickContent)); - sb.append('}'); - } - if (hoverType != null) { - sb.append(','); - sb.append("hoverEvent:{"); - sb.append("action:").append(hoverType.getJsonKey()).append(','); - sb.append("value:"); - if (hoverContent.size() == 1) { - TextMessagePart hoverPart = hoverContent.getFirst(); - if (hoverPart.hasFormatting()) { - sb.append(hoverPart.toJSON()); - } else { - sb.append(quoteStringJson(hoverPart.text)); - } - } else { - sb.append('['); - for (TextMessagePart hoverPart : hoverContent) { - sb.append(hoverPart.toJSON()); - sb.append(','); - } - sb.deleteCharAt(sb.length() - 1); - sb.append(']'); - } - sb.append('}'); - } - sb.append('}'); - return sb.toString(); - } - } - - - - // --------------------------------------- Tags --------------------------------------- - - - interface Tag { - /** Tag text(s) used in the FancyMessageFormat (The text between '[' and ']') */ - String[] getTags(); - } - - - /** - * Indicates formatting that is applied until explicitly stopped. - * Can also be used in simple Minecraft messages (Non-JSON). - */ - interface ContinuousTag extends Tag { - /** - * The character that defines upcoming formatting in a native (non-JSON) Minecraft message. - */ - char getNativeFormattingCode(); - } - - - /** - * Indicates formatting that allows cursor interaction. Requires the - * Minecraft JSON / tellraw format. - */ - interface InteractiveMessageTag extends Tag { - String getJsonKey(); - } - - - static enum Color implements ContinuousTag { - WHITE('f'), - BLACK('0'), - BLUE('9'), - DARK_BLUE('1'), - GREEN('a'), - DARK_GREEN('2'), - AQUA('b'), - DARK_AQUA('3'), - RED('c'), - DARK_RED('4'), - LIGHT_PURPLE('d'), - DARK_PURPLE('5'), - YELLOW('e'), - GOLD('6'), - GRAY('7'), - DARK_GRAY('8'); - - final char bytecode; - final String jsonValue; - final String[] tags; - - private Color(char bytecode) { - this.bytecode = bytecode; - this.jsonValue = this.name().toLowerCase(); - this.tags = new String[] {this.name().toLowerCase()}; - } - - @Override - public String[] getTags() { - return tags; - } - - @Override - public char getNativeFormattingCode() { - return bytecode; - } - } - - - static enum FormatType implements ContinuousTag { - BOLD('l', "bold", "b", "bold"), - ITALIC('o', "italic", "i", "italic"), - UNDERLINE('n', "underlined", "u", "underline"), - STRIKETHROUGH('s', "strikethrough", "s", "strikethrough"), - OBFUSCATE('k', "obfuscated", "obfuscate"); - - final char bytecode; - final String jsonKey; - final String[] tags; - - FormatType(char bytecode, String jsonKey, String... tags) { - this.bytecode = bytecode; - this.jsonKey = jsonKey; - this.tags = tags; - } - - @Override - public String[] getTags() { - return tags; - } - - @Override - public char getNativeFormattingCode() { - return bytecode; - } - - } - - - static enum FormatCloseTag implements Tag { - BOLD_END(FormatType.BOLD), - ITALIC_END(FormatType.ITALIC), - UNDERLINE_END(FormatType.UNDERLINE), - STRIKETHROUGH_END(FormatType.STRIKETHROUGH), - OBFUSCATE_END(FormatType.OBFUSCATE); - - /** Formatting that is stopped at this point */ - final FormatType closes; - private final String[] tags; - - private FormatCloseTag(FormatType openingTag) { - this.closes = openingTag; - - // Auto-generate close tags - tags = new String[closes.tags.length]; - for (int i = 0; i < tags.length; i++) { - tags[i] = END_TAG_INDICATOR + closes.tags[i]; - } - } - - @Override - public String[] getTags() { - return tags; - } - - } - - - static enum ControlTag implements Tag { - BREAK("break"), - ESCAPE("esc"); - - private final String[] tags; - - private ControlTag(String... tags) { - this.tags = tags; - } - - @Override - public String[] getTags() { - return tags; - } - - } - - - /** - * Types of clicking - */ - static enum ClickType implements InteractiveMessageTag { - LINK("open_url", "link"), - COMMAND("run_command", "command"), - SUGGEST("suggest_command", "suggest"); - - private final String jsonKey; - private final String[] tags; - - private ClickType(String jsonKey, String... tags) { - this.jsonKey = jsonKey; - this.tags = tags; - } - - @Override - public String[] getTags() { - return tags; - } - - @Override - public String getJsonKey() { - return jsonKey; - } - } - - - static enum HoverType implements InteractiveMessageTag { - HOVER; - - @Override - public String[] getTags() { - return new String[] {"hover"}; - } - - @Override - public String getJsonKey() { - return "show_text"; - } - } - -} diff --git a/README.md b/README.md index 598be34..453a11e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ -FancyMessageFormat -================== +InteractiveMessaging +==================== A format for text in config files to make use of the fancy Minecraft chat features like click, hover, suggest, etc. +## Authors +* **Thijs aka NLThijs48:** Initial code structure, refactoring and getting it production ready +* **Tobias aka Phoenix:** Initial code structure and initial parser + ## Requirements * Support for click effects, when a player clicks on text it will execute something * Execute a command (will always be executed by the player itself) diff --git a/Specifications & Guidelines.md b/Specifications & Guidelines.md index 52ce248..5bb2f8a 100644 --- a/Specifications & Guidelines.md +++ b/Specifications & Guidelines.md @@ -2,28 +2,28 @@ #### Colors & Formatting: * Colors and formatting normally go on until changed/removed, but they reset on new lines * Color tags: - * [white], [black], [yellow], [gold], [aqua], [dark_aqua], [blue], [dark_blue], [light_purple], [dark_purple], [red], [dark_red], [green], [dark_green], [gray], [dark_gray] + * `[white]`, `[black]`, `[yellow]`, `[gold]`, `[aqua]`, `[dark_aqua]`, `[blue]`, `[dark_blue]`, `[light_purple]`, `[dark_purple]`, `[red]`, `[dark_red]`, `[green]`, `[dark_green]`, `[gray]`, `[dark_gray]` * When you use a color it will override the last one * Formatting tags: - * [bold], [italic], [underlined], [strikethrough], [obfuscated] + * `[bold]`, `[italic]`, `[underlined]`, `[strikethrough]`, `[obfuscated]` * When you use a formatting tag it will enable this formatting * When you use a close tag of a format it will disable that formatting * The color and formatting inside a hover is seperate (start with nothing, resets each line) * Tags are not case sensitive * The following tags are equivalents: - * [strikethrough] = [s] - * [bold] = [b] - * [italic] = [i] - * [underline] = [u] + * `[strikethrough]` = `[s]` + * `[bold]` = `[b]` + * `[italic]` = `[i]` + * `[underline]` = `[u]` #### Breaks (newlines): -* The break symbol is: [break] -* Using it will cause the text to wrap to the next line in chat, so if [break][break] is used it will leave an empty line +* The break symbol is: `[break]` +* Using it will cause the text to wrap to the next line in chat, so if `[break][break]` is used it will leave an empty line * The end of a list element (string) does NOT automatically result in a break in the chat -* After using [break] there may not be more text in this line, from there only more [break] tags are allowed (more text can go in a new list entry) +* After using `[break]` there may not be more text in this line, from there only more `[break]` tags are allowed (more text can go in a new list entry) #### Special effect tags: -* Tags are for adding special effects to a part of the text, used as the following: " link: http://google.com" +* Tags are for adding special effects to a part of the text, used as the following: ` link: http://google.com` * Supported tags: * Link: If clicked it will cause a “Do you want to visit this website?” popup * Hover: If hovered over with the mouse it will give information in a little box @@ -35,8 +35,8 @@ * Tags are not case sensitive #### Escaping: -* [esc] can be used to escape 1 character, so if you want to have "link: example.com" as text you need a [esc] in front of this. Then the first character will be escaped and since "ink: " is not a valid tag anymore it will be shown as text instead of being used as link on the previous text part. -* Placing "[esc][esc]" in the text will result in the text "[esc]" being printed, the first escape will be handled as such, this will escape [ and therefore the text behind this will be "esc]" and this is just text +* `[esc]` can be used to escape 1 character, so if you want to have `link: example.com` as text you need a `[esc]` in front of this. Then the first character will be escaped and since `ink: ` is not a valid tag anymore it will be shown as text instead of being used as link on the previous text part. +* Placing `[esc][esc]` in the text will result in the text `[esc]` being printed, the first escape will be handled as such, this will escape `[` and therefore the text behind this will be `esc]` and this is just text * Also works for variables ## Guidelines diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..4c9aca2 --- /dev/null +++ b/pom.xml @@ -0,0 +1,43 @@ + + 4.0.0 + me.wiefferink + interactivemessenger + 1.0 + InteractiveMessenger + + + UTF-8 + 1.7 + 1.7 + + + + + spigot-repo + https://hub.spigotmc.org/nexus/content/repositories/snapshots/ + + + + + + org.bukkit + bukkit + 1.8-R0.1-SNAPSHOT + jar + provided + true + + + + + target + ${name} + + + maven-compiler-plugin + 3.5.1 + + + + \ No newline at end of file diff --git a/src/main/java/me/wiefferink/interactivemessenger/generators/ConsoleGenerator.java b/src/main/java/me/wiefferink/interactivemessenger/generators/ConsoleGenerator.java new file mode 100644 index 0000000..c5653e9 --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/generators/ConsoleGenerator.java @@ -0,0 +1,101 @@ +package me.wiefferink.interactivemessenger.generators; + +import me.wiefferink.interactivemessenger.message.InteractiveMessage; +import me.wiefferink.interactivemessenger.message.InteractiveMessagePart; +import me.wiefferink.interactivemessenger.message.TextMessagePart; +import me.wiefferink.interactivemessenger.message.enums.Color; +import me.wiefferink.interactivemessenger.message.enums.Format; +import org.bukkit.ChatColor; + +import java.util.EnumMap; + +public class ConsoleGenerator { + + /** + * Map Color to the native formatting codes used in chat (https://minecraft.gamepedia.com/Formatting_codes) + */ + private static final EnumMap colorCode = new EnumMap(Color.class) {{ + put(Color.BLACK, '0'); + put(Color.DARK_BLUE, '1'); + put(Color.DARK_GREEN, '2'); + put(Color.DARK_AQUA, '3'); + put(Color.DARK_RED, '4'); + put(Color.DARK_PURPLE, '5'); + put(Color.GOLD, '6'); + put(Color.GRAY, '7'); + put(Color.DARK_GRAY, '8'); + put(Color.BLUE, '9'); + put(Color.GREEN, 'a'); + put(Color.AQUA, 'b'); + put(Color.RED, 'c'); + put(Color.LIGHT_PURPLE, 'd'); + put(Color.YELLOW, 'e'); + put(Color.WHITE, 'f'); + }}; + + /** + * Map Format to the native formatting codes used in chat (https://minecraft.gamepedia.com/Formatting_codes) + */ + private static final EnumMap formatCode = new EnumMap(Format.class) {{ + put(Format.BOLD, 'l'); + put(Format.ITALIC, 'o'); + put(Format.UNDERLINE, 'n'); + put(Format.STRIKETHROUGH, 's'); + put(Format.OBFUSCATE, 'k'); + }}; + + + /** + * Parses the given message to a String containing control characters + * for formatting that can be used for console outputs, but also for normal player + * messages. + *

+ * The returned message will only contain colors, bold, italic, underlining and 'magic' + * characters. Hovers and other advanced tellraw enums will be skipped. + * @param message The parsed InteractiveMessage + * @return Plain message that can be send + */ + public static String generate(InteractiveMessage message) { + StringBuilder result = new StringBuilder(); + for(InteractiveMessagePart part : message) { + toSimpleString(part, result); + } + + return result.toString(); + } + + /** + * Append the message content to the StringBuilder + * @param sb The StringBuilder to append the message to + */ + private static StringBuilder toSimpleString(InteractiveMessagePart part, StringBuilder sb) { + for(TextMessagePart textPart : part) { + toSimpleString(textPart, sb); + } + + if(part.newline) { + sb.append("\n"); + } + return sb; + } + + /** + * Get a simple colored/formatted string + * @param sb The StringBuilder to append the result to + * @return StringBuilder with the message appended + */ + private static StringBuilder toSimpleString(TextMessagePart part, StringBuilder sb) { + // Color + if(part.color != null) { + sb.append(ChatColor.COLOR_CHAR).append(colorCode.get(part.color)); + } + // Formatting + for(Format format : part.formatting) { + sb.append(ChatColor.COLOR_CHAR).append(formatCode.get(format)); + } + // Text + sb.append(part.text); + return sb; + } + +} diff --git a/src/main/java/me/wiefferink/interactivemessenger/generators/TellrawGenerator.java b/src/main/java/me/wiefferink/interactivemessenger/generators/TellrawGenerator.java new file mode 100644 index 0000000..1c0e666 --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/generators/TellrawGenerator.java @@ -0,0 +1,235 @@ +package me.wiefferink.interactivemessenger.generators; + +import me.wiefferink.interactivemessenger.message.InteractiveMessage; +import me.wiefferink.interactivemessenger.message.InteractiveMessagePart; +import me.wiefferink.interactivemessenger.message.TextMessagePart; +import me.wiefferink.interactivemessenger.message.enums.Color; +import me.wiefferink.interactivemessenger.message.enums.Format; +import me.wiefferink.interactivemessenger.message.enums.Click; +import me.wiefferink.interactivemessenger.message.enums.Hover; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; + +public class TellrawGenerator { + + /** + * Map Format to the JSON keys used in tellraw (https://minecraft.gamepedia.com/Commands#Raw_JSON_text) + */ + private static final EnumMap formatJsonKey = new EnumMap(Format.class) {{ + put(Format.BOLD, "bold"); + put(Format.ITALIC, "italic"); + put(Format.UNDERLINE, "underlined"); + put(Format.STRIKETHROUGH, "strikethrough"); + put(Format.OBFUSCATE, "obfuscated"); + }}; + + /** + * Map Click to the JSON keys used in tellraw (https://minecraft.gamepedia.com/Commands#Raw_JSON_text) + */ + private static final EnumMap clickJsonKey = new EnumMap(Click.class) {{ + put(Click.COMMAND, "run_command"); + put(Click.LINK, "open_url"); + put(Click.SUGGEST, "suggest_command"); + }}; + + /** + * Map Hover to the JSON keys used in tellraw (https://minecraft.gamepedia.com/Commands#Raw_JSON_text) + */ + private static final EnumMap hoverJsonKey = new EnumMap(Hover.class) {{ + put(Hover.HOVER, "show_text"); + }}; + + + /** + * Parses the given message to a JSON array that can be + * used with the tellraw command and the like. + * @param message The parsed InteractiveMessage. + * @return JSON string that can be send to a player (multiple means line breaks have been used) + */ + public static List generate(InteractiveMessage message) { + List result = new ArrayList<>(); + + List combine = new ArrayList<>(); // Part that are combined to a line + while(!message.isEmpty()) { + InteractiveMessagePart part = message.removeFirst(); + combine.add(part); + if(part.newline || message.isEmpty()) { + if(combine.size() == 1) { + StringBuilder nextLine = new StringBuilder("["); + toJSON(combine.get(0), nextLine); + nextLine.append("]"); + result.add(nextLine.toString()); + } else if(combine.size() > 1) { + StringBuilder nextLine = new StringBuilder("[{\"text\":\"\",\"extra\":["); + for(int i = 0; i < combine.size(); i++) { + // Skip possibly last empty InteractiveMessagePart which has newline set + if(!combine.get(i).isEmpty()) { + if(i != 0) { + nextLine.append(","); + } + toJSON(combine.get(i), nextLine); + } + } + nextLine.append("]}]"); + result.add(nextLine.toString()); + } + combine.clear(); + } + } + return result; + } + + + /** + * Get a JSON component for this message part + * @param sb The StringBuilder to append the result to + * @return The StringBuilder where the JSON has been appended to + */ + private static StringBuilder toJSON(InteractiveMessagePart part, StringBuilder sb) { + // Error case, should never happen, print something as safeguard + if(part.size() == 0) { + sb.append("{\"text\":\"\"}"); + return sb; + } + + if(part.size() == 1) { + // Add attributes to TextMessagePart object + toJSON(part.getFirst(), sb); + sb.deleteCharAt(sb.length()-1); + } else { + sb.append('{'); + sb.append("\"text\":\"\",\"extra\":["); + for(TextMessagePart textPart : part) { + toJSON(textPart, sb); + sb.append(','); + } + sb.deleteCharAt(sb.length()-1); + sb.append(']'); + } + + // Add click action + if(part.onClick != null) { + sb.append(','); + sb.append("\"clickEvent\":{"); + sb.append("\"action\":\"").append(clickJsonKey.get(part.onClick)).append("\","); + sb.append("\"value\":"); + printJsonString(part.clickContent, sb); + sb.append('}'); + } + + // Add hover action + if(part.onHover != null) { + sb.append(','); + sb.append("\"hoverEvent\":{"); + sb.append("\"action\":\"").append(hoverJsonKey.get(part.onHover)).append("\","); + sb.append("\"value\":"); + if(part.hoverContent.size() == 1) { + TextMessagePart hoverPart = part.hoverContent.getFirst(); + if(hoverPart.hasFormatting()) { + toJSON(hoverPart, sb); + } else { + printJsonString(hoverPart.text, sb); + } + } else { + sb.append('['); + for(TextMessagePart hoverPart : part.hoverContent) { + toJSON(hoverPart, sb); + sb.append(','); + } + sb.deleteCharAt(sb.length()-1); + sb.append(']'); + } + sb.append('}'); + } + sb.append('}'); + return sb; + } + + /** + * Get a JSON component for this message part + * @param sb The StringBuilder to append the JSON result to + * @return The StringBuilder where the result has been appended to + */ + private static StringBuilder toJSON(TextMessagePart part, StringBuilder sb) { + sb.append('{'); + + // Text + sb.append("\"text\":"); + printJsonString(part.text, sb); + + // Color + if(part.color != null && part.color != Color.WHITE) { + sb.append(",\"color\":\"").append(part.color.name().toLowerCase()).append("\""); + } + + // Formatting + for(Format formatting : part.formatting) { + sb.append(",\""); + sb.append(formatJsonKey.get(formatting)).append("\":"); + sb.append("true"); + } + + sb.append('}'); + return sb; + } + + /** + * Produce a string in double quotes with backslash sequences in all the right places. + * @param string The string to print + * @param sb The StringBuilder to add the quoted string to + * @return sb + */ + private static StringBuilder printJsonString(String string, StringBuilder sb) { + if(string == null || string.length() == 0) { + sb.append("\"\""); + return sb; + } + + sb.append('"'); + for(int i = 0; i < string.length(); i++) { + char c = string.charAt(i); + switch(c) { + case '\\': + case '"': + sb.append('\\'); + sb.append(c); + break; + case '/': + sb.append('\\'); + sb.append(c); + break; + case '\b': + sb.append("\\b"); + break; + case '\t': + sb.append("\\t"); + break; + case '\n': + sb.append("\\n"); + break; + case '\f': + sb.append("\\f"); + break; + case '\r': + sb.append("\\r"); + break; + default: + if(c <= '\u001F' || (c >= '\u007F' && c <= '\u009F') || (c >= '\u2000' && c <= '\u20FF')) { + String ss = Integer.toHexString(c); + sb.append("\\u"); + for(int k = 0; k < 4-ss.length(); k++) { + sb.append('0'); + } + sb.append(ss.toUpperCase()); + } else { + sb.append(c); + } + } + } + sb.append('"'); + return sb; + } + +} diff --git a/src/main/java/me/wiefferink/interactivemessenger/message/InteractiveMessage.java b/src/main/java/me/wiefferink/interactivemessenger/message/InteractiveMessage.java new file mode 100644 index 0000000..61f6f87 --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/message/InteractiveMessage.java @@ -0,0 +1,9 @@ +package me.wiefferink.interactivemessenger.message; + +import java.util.LinkedList; + +/** + * Interactive message + */ +public class InteractiveMessage extends LinkedList { +} diff --git a/src/main/java/me/wiefferink/interactivemessenger/message/InteractiveMessagePart.java b/src/main/java/me/wiefferink/interactivemessenger/message/InteractiveMessagePart.java new file mode 100644 index 0000000..69b8416 --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/message/InteractiveMessagePart.java @@ -0,0 +1,27 @@ +package me.wiefferink.interactivemessenger.message; + +import me.wiefferink.interactivemessenger.message.enums.Click; +import me.wiefferink.interactivemessenger.message.enums.Hover; + +import java.util.LinkedList; + +/** + * Holds a string with interactive formatting. + */ +public class InteractiveMessagePart extends LinkedList { + + public boolean newline = false; + + // Click + public Click onClick = null; + public String clickContent = ""; + + // Hover + public Hover onHover = null; + public LinkedList hoverContent = null; + + @Override + public String toString() { + return "InteractiveMessagePart(textMessageParts:"+super.toString()+", onClick:"+onClick+", clickContent:"+clickContent+", onHover:"+onHover+", hoverContent:"+hoverContent+", newline:"+newline+")"; + } +} \ No newline at end of file diff --git a/src/main/java/me/wiefferink/interactivemessenger/message/TextMessagePart.java b/src/main/java/me/wiefferink/interactivemessenger/message/TextMessagePart.java new file mode 100644 index 0000000..c8f0c47 --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/message/TextMessagePart.java @@ -0,0 +1,29 @@ +package me.wiefferink.interactivemessenger.message; + +import me.wiefferink.interactivemessenger.message.enums.Color; +import me.wiefferink.interactivemessenger.message.enums.Format; + +import java.util.HashSet; +import java.util.Set; + +/** + * Holds a string with basic (non-interactive) formatting. + */ +public class TextMessagePart { + public String text = ""; + public Color color = null; + public Set formatting = new HashSet<>(); + + /** + * Check if this part has formatting + * @return true if this part has formatting, otherwise false + */ + public boolean hasFormatting() { + return !(color == Color.WHITE && formatting.isEmpty()); + } + + @Override + public String toString() { + return "TextMessagePart(text:"+text+", color:"+color+", formatting:"+formatting.toString()+")"; + } +} diff --git a/src/main/java/me/wiefferink/interactivemessenger/message/enums/Click.java b/src/main/java/me/wiefferink/interactivemessenger/message/enums/Click.java new file mode 100644 index 0000000..5a058ea --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/message/enums/Click.java @@ -0,0 +1,7 @@ +package me.wiefferink.interactivemessenger.message.enums; + +public enum Click { + LINK, + COMMAND, + SUGGEST +} diff --git a/src/main/java/me/wiefferink/interactivemessenger/message/enums/Color.java b/src/main/java/me/wiefferink/interactivemessenger/message/enums/Color.java new file mode 100644 index 0000000..73c7e42 --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/message/enums/Color.java @@ -0,0 +1,20 @@ +package me.wiefferink.interactivemessenger.message.enums; + +public enum Color { + WHITE, + BLACK, + BLUE, + DARK_BLUE, + GREEN, + DARK_GREEN, + AQUA, + DARK_AQUA, + RED, + DARK_RED, + LIGHT_PURPLE, + DARK_PURPLE, + YELLOW, + GOLD, + GRAY, + DARK_GRAY +} diff --git a/src/main/java/me/wiefferink/interactivemessenger/message/enums/Control.java b/src/main/java/me/wiefferink/interactivemessenger/message/enums/Control.java new file mode 100644 index 0000000..99326ef --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/message/enums/Control.java @@ -0,0 +1,6 @@ +package me.wiefferink.interactivemessenger.message.enums; + +public enum Control { + BREAK, + RESET +} diff --git a/src/main/java/me/wiefferink/interactivemessenger/message/enums/Format.java b/src/main/java/me/wiefferink/interactivemessenger/message/enums/Format.java new file mode 100644 index 0000000..47c0170 --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/message/enums/Format.java @@ -0,0 +1,9 @@ +package me.wiefferink.interactivemessenger.message.enums; + +public enum Format { + BOLD, + ITALIC, + UNDERLINE, + STRIKETHROUGH, + OBFUSCATE +} \ No newline at end of file diff --git a/src/main/java/me/wiefferink/interactivemessenger/message/enums/Hover.java b/src/main/java/me/wiefferink/interactivemessenger/message/enums/Hover.java new file mode 100644 index 0000000..5884035 --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/message/enums/Hover.java @@ -0,0 +1,6 @@ +package me.wiefferink.interactivemessenger.message.enums; + +// TODO support show_item, show_achievement, show_entity +public enum Hover { + HOVER +} \ No newline at end of file diff --git a/src/main/java/me/wiefferink/interactivemessenger/parsers/YamlParser.java b/src/main/java/me/wiefferink/interactivemessenger/parsers/YamlParser.java new file mode 100644 index 0000000..4b4c085 --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/parsers/YamlParser.java @@ -0,0 +1,435 @@ +package me.wiefferink.interactivemessenger.parsers; + +import me.wiefferink.interactivemessenger.generators.ConsoleGenerator; +import me.wiefferink.interactivemessenger.message.InteractiveMessage; +import me.wiefferink.interactivemessenger.message.InteractiveMessagePart; +import me.wiefferink.interactivemessenger.message.TextMessagePart; +import me.wiefferink.interactivemessenger.message.enums.Control; +import me.wiefferink.interactivemessenger.message.enums.Color; +import me.wiefferink.interactivemessenger.message.enums.Format; +import me.wiefferink.interactivemessenger.message.enums.Click; +import me.wiefferink.interactivemessenger.message.enums.Hover; +import org.bukkit.ChatColor; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class YamlParser { + + public static final char ESCAPE_CHAR = '\\'; + public static final char SIMPLE_FORMAT_RESET_CHAR = 'r'; + public static final Pattern tagPattern = Pattern.compile("\\[[/a-zA-Z1-9]+\\]|[&"+ChatColor.COLOR_CHAR+"][1-9a-zA-Z]"); + + // Lookup table for all continuous enums ([]) + private static final HashMap BRACKET_TAGS = new HashMap() {{ + // Colors + cacheTags(this, Color.class); + put("grey", Color.GRAY); + put("darkgrey", Color.DARK_GRAY); + put("dark_grey", Color.DARK_GRAY); + + // Formatting + cacheTags(this, Format.class); + for(Format tag : Format.values()) { + put((tag.name().charAt(0)+"").toLowerCase(), tag); + } + put("strike", Format.STRIKETHROUGH); + + // Control + cacheTags(this, Control.class); + }}; + + // Lookup table for all interactive enums (:) + private static final HashMap INTERACTIVE_TAGS = new HashMap() {{ + cacheTags(this, Click.class); + cacheTags(this, Hover.class); + }}; + + // Lookup table for all native enums (& and §) + private static final HashMap NATIVE_TAGS = new HashMap() {{ + put("0", Color.BLACK); + put("1", Color.DARK_BLUE); + put("2", Color.DARK_GREEN); + put("3", Color.DARK_AQUA); + put("4", Color.DARK_RED); + put("5", Color.DARK_PURPLE); + put("6", Color.GOLD); + put("7", Color.GRAY); + put("8", Color.DARK_GRAY); + put("9", Color.BLUE); + put("a", Color.GREEN); + put("b", Color.AQUA); + put("c", Color.RED); + put("d", Color.LIGHT_PURPLE); + put("e", Color.YELLOW); + put("f", Color.WHITE); + + put("l", Format.BOLD); + put("m", Format.STRIKETHROUGH); + put("o", Format.ITALIC); + put("n", Format.UNDERLINE); + put("k", Format.OBFUSCATE); + + put("r", Control.RESET); + }}; + + /** + * Puts all values of the given Enum into the given lookup table. + */ + private static void cacheTags(HashMap tagList, Class tags) { + for(Enum tag : tags.getEnumConstants()) { + tagList.put(tag.name().toLowerCase(), tag); + tagList.put(tag.name().toLowerCase().replace("_", ""), tag); + } + } + + /** + * Parse input lines into an InteractiveMessage + * @param input The input to parse + * @return InteractiveMessage representing the parsed input + */ + public static InteractiveMessage parse(List input) { + return parse(input, true); + } + + /** + * Parse input lines into an InteractiveMessage + * @param input The input to parse + * @param doInteractives true to parse interactive enums (hover, click, etc.), false to skip them + * @return InteractiveMessage representing the parsed input + */ + public static InteractiveMessage parse(List input, boolean doInteractives) { + InteractiveMessage message = new InteractiveMessage(); + + Color currentColor = null; + Set currentFormatting = new HashSet<>(); + + lineLoop: + for(String line : input) { + InteractiveMessagePart messagePart; + TaggedContent interactiveTag = getInteractiveTag(line); + boolean isTextLine = interactiveTag == null; + if(!doInteractives && !isTextLine) { + continue; + } + boolean isHoverLine = false; + + if(isTextLine) { + messagePart = new InteractiveMessagePart(); + message.add(messagePart); + } else /* if Interactive formatting */ { + if(message.isEmpty()) { + continue; + } + messagePart = message.getLast(); + Object tag = interactiveTag.tag; + if(tag instanceof Click) { + messagePart.onClick = (Click)interactiveTag.tag; + messagePart.clickContent = interactiveTag.subsequentContent; + } else if(tag instanceof Hover) { + line = interactiveTag.subsequentContent; + isHoverLine = true; + if(messagePart.onHover != tag) { + // Hover type changed + messagePart.hoverContent = new LinkedList<>(); + messagePart.onHover = (Hover)tag; + } + // Add hover content below + } + } + + if(isTextLine || isHoverLine) { + // Parse inline enums + Color currentLineColor = currentColor; + Set currentLineFormatting = currentFormatting; + LinkedList targetList = messagePart; + boolean parseBreak = true; + if(isHoverLine) { + // Reset - use own + currentLineColor = null; + currentLineFormatting = new HashSet<>(); + targetList = messagePart.hoverContent; + parseBreak = false; + + // Add line break after previous hover line + if(!targetList.isEmpty()) { + targetList.getLast().text += '\n'; + } + } + + // Split into pieces at places where formatting changes + while(!line.isEmpty()) { + String textToAdd; + TaggedContent nextTag = getNextTag(line, parseBreak); + boolean tagged = nextTag != null; + + if(!tagged) { + textToAdd = line; + line = ""; + } else { + textToAdd = nextTag.precedingContent; + line = nextTag.subsequentContent; + } + + // Add a text part with the correct formatting + if(!textToAdd.isEmpty()) { + TextMessagePart part = new TextMessagePart(); + part.text = unescape(textToAdd); + part.formatting = new HashSet<>(currentLineFormatting); + part.color = currentLineColor; + targetList.add(part); + } + + // Handle the change in formatting if a Tag has been detected (this needs to be after creating the InteractiveMessagePart) + if(tagged) { + // Handle the formatting tag + Object tag = nextTag.tag; + if(tag instanceof Color) { + currentLineColor = (Color)tag; + } else if(tag instanceof Format) { + if(nextTag.closing) { + currentLineFormatting.remove(tag); + } else { + currentLineFormatting.add((Format)tag); + } + } else if(tag == Control.BREAK) { + if(isHoverLine && !targetList.isEmpty()) { + targetList.getLast().text += "\n"; + } else { + messagePart.newline = true; + currentLineFormatting.clear(); + continue lineLoop; + } + } else if(tag == Control.RESET) { + currentLineFormatting.clear(); + currentLineColor = Color.WHITE; + } + } + } + + if(!isHoverLine) { + // Adapt global attributes + currentColor = currentLineColor; + } + } + } + + // Cleanup empty parts (some parts only affected the formatting of next parts, but do not have actual content) + Iterator it = message.iterator(); + while(it.hasNext()) { + InteractiveMessagePart check = it.next(); + if(check.size() == 0 && !check.newline) { + it.remove(); + } + } + return message; + } + + /** + * Insert a message at the specified position + * @param message The current message + * @param insert The message to insert + * @param line The line number to insert at + * @param start The start of the variable to replace + * @param end The end of the variable to replace + */ + public static void insertMessage(List message, List insert, int line, int start, int end) { + if(insert == null || line < 0 || line >= message.size() || start < 0 || end < 0) { + return; + } + String lineContent = message.remove(line); + if(start > lineContent.length() || end > lineContent.length()) { + message.add(line, lineContent); // Repair damage + return; + } + if(isTaggedInteractive(lineContent)) { + lineContent = lineContent.replace("", ""); + // TODO check we can get rid of the dependency on ConsoleGenerator + message.add(line, lineContent.substring(0, start) + ConsoleGenerator.generate(YamlParser.parse(insert)) + lineContent.substring(end)); + return; + } + + // Find interactive lines meant for this message + List interactives = new ArrayList<>(); + while(line < message.size() && isTaggedInteractive(message.get(line))) { + interactives.add(message.remove(line)); + } + + // Split the line and add the parts + int at = line; + if(start > 0) { + message.add(line, lineContent.substring(0, start)); + at++; + message.addAll(at, interactives); + at += interactives.size(); + } + message.addAll(at, insert); + at += insert.size(); + message.addAll(at, interactives); + at += interactives.size(); + if(end < lineContent.length()) { + message.add(at, lineContent.substring(end)); + at++; + message.addAll(at, interactives); + } + } + + /** + * Searches and returns the first continuous tag found in the given String. + * @return The tag (plus its preceding and subsequent content) if found. + * Null if nothing is found. + */ + private static TaggedContent getNextTag(String line, boolean parseBreak) { + // TODO do something with parseBreak or remove + // Tags containing letters and numbers and native format (captures too much, but will continue search) + Matcher matcher = tagPattern.matcher(line); + while(matcher.find()) { + boolean closing = false; + Object tag; + if(matcher.group().startsWith("&") || matcher.group().startsWith(ChatColor.COLOR_CHAR+"")) { + // Skip escaped color enums + if(matcher.start() > 0 && line.charAt(matcher.start()-1) == ESCAPE_CHAR) { + continue; + } + tag = NATIVE_TAGS.get(matcher.group().charAt(1)+"".toLowerCase()); + } else { + String content = matcher.group().substring(1, matcher.group().length()-1).toLowerCase(); + tag = BRACKET_TAGS.get(content); + + // Try closing tag + if(tag == null && content.length() > 0 && content.charAt(0) == '/') { + tag = BRACKET_TAGS.get(content.substring(1)); + closing = true; + // Control enums cannot be closed + if(tag instanceof Control) { + continue; + } + } + } + + // Continue search if we found something like [abc] or &w that is not a tag + if(tag != null) { + return new TaggedContent(line.substring(0, matcher.start()), tag, line.substring(matcher.end()), closing); + } + } + return null; + } + + /** + * If the given line defines an interactive property (e.g. "hover: myText") + * the tag / property will be returned. Otherwise null is returned. + * @param line The line to try and get the interactive tag for + * @return TaggedContent object with the interactive tag type and split or null if not found + */ + private static TaggedContent getInteractiveTag(String line) { + for(int index = 0; index < line.length(); index++) { + char c = line.charAt(index); + if(c == ' ' || c == '\t') { + // Ignore (Skip spacing) + } else { + int end = line.indexOf(": ", index); + if(end != -1) { + String inBetween = line.substring(index, end).toLowerCase(); + if(INTERACTIVE_TAGS.containsKey(inBetween)) { + Object tag = INTERACTIVE_TAGS.get(inBetween); + String subsequentContent = line.substring(end+2); + return new TaggedContent(null, tag, subsequentContent, false); + } + } + return null; + } + } + return null; + } + + /** + * Check if a line is an advanced declaration like hover or command + * @param line The line to check + * @return true if the line is interactive, false when it is a text line + */ + private static boolean isTaggedInteractive(String line) { + return getInteractiveTag(line) != null; + } + + /** + * - Splits lines at line breaks (creating a new line in the Array). + * - Removes empty lines at the beginning. + * - Removes lines with properties in front of the first text-line. + * @param inputLines The lines to clean + * @return cleaned lines + */ + private static ArrayList cleanInputString(List inputLines) { + // Split li|nes at line breaks + // In the end we will have a list with one line per element + ArrayList lines = new ArrayList<>(); + for(String line : inputLines) { + lines.addAll(Arrays.asList(line.split("\\r?\\n"))); + } + + // Remove any special lines at the start (a real text line should be first) + while(!lines.isEmpty() && isTaggedInteractive(lines.get(0))) { + lines.remove(0); + } + + return lines; + } + + // Characters that should be escaped + private static final List toEscape = Arrays.asList( + ESCAPE_CHAR+"", // Used for escapes, must be first + "%", // Variables + "]", // Formatting and control enums + "&", // Traditional color formatting + ChatColor.COLOR_CHAR+"" // Legacy formatting enums + ); + + /** + * Escape a (user provided) string for including it in a message + * @param message The message part to escape + * @return The escaped message part + */ + public static String escape(String message) { + for(String escape : toEscape) { + message = message.replace(escape, ESCAPE_CHAR+escape); + } + return message; + } + + /** + * Reverse the escaping of control characters in a (user provided) string + * @param message The message part to escape + * @return The escaped message part + */ + public static String unescape(String message) { + for(String escape : toEscape) { + message = message.replace(ESCAPE_CHAR+escape, escape); + } + return message; + } + + + /** + * Represents a tag that has been found in a line + */ + private static class TaggedContent { + final String precedingContent; + final Object tag; + final boolean closing; + final String subsequentContent; + + /** + * Constructor + * @param pre Content that appeared before the tag + * @param tag The Tag that has been found (formatting or control) + * @param sub Content found after the tag + */ + public TaggedContent(String pre, Object tag, String sub, boolean closing) { + this.precedingContent = pre; + this.tag = tag; + this.subsequentContent = sub; + this.closing = closing; + } + } + +} diff --git a/src/main/java/me/wiefferink/interactivemessenger/processing/Message.java b/src/main/java/me/wiefferink/interactivemessenger/processing/Message.java new file mode 100644 index 0000000..515f4d3 --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/processing/Message.java @@ -0,0 +1,735 @@ +package me.wiefferink.interactivemessenger.processing; + +import me.wiefferink.interactivemessenger.generators.ConsoleGenerator; +import me.wiefferink.interactivemessenger.generators.TellrawGenerator; +import me.wiefferink.interactivemessenger.parsers.YamlParser; +import me.wiefferink.interactivemessenger.source.MessageProvider; +import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang.exception.ExceptionUtils; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class Message { + + // CONFIGURATION + private static boolean useInteractiveMessages = true; + private static boolean useColorsInConsole = false; + private static Logger logger = null; + private static MessageProvider messageProvider = null; + + // Define the symbols used for variables + public static final String VARIABLESTART = "%"; + public static final String VARIABLEEND = "%"; + public static final String LANGUAGEVARIABLE = "lang:"; + public static final Pattern variablePattern = Pattern.compile(Pattern.quote(VARIABLESTART)+"[a-zA-Z]+"+Pattern.quote(VARIABLEEND)); + public static final Pattern variables = Pattern.compile( + Pattern.quote(VARIABLESTART)+ + Pattern.quote(LANGUAGEVARIABLE)+"[a-zA-Z-]+"+ // Language key + "(\\|(.*?\\|)+)?"+ // Optional message arguments + Pattern.quote(VARIABLEEND) + ); + + private static HashMap indexPatterns = new HashMap<>(); + + /** + * Get a cached variable index tagPattern + * @param index The index of the variable tagPattern + * @return The tagPattern for the given index + */ + private static Pattern getIndexPattern(int index) { + Pattern result = indexPatterns.get(index); + if(result == null) { + result = Pattern.compile(Pattern.quote(VARIABLESTART)+index+Pattern.quote(VARIABLEEND)); + indexPatterns.put(index, result); + } + return result; + } + // Language variable used to insert a prefix + public static final String CHATLANGUAGEVARIABLE = "prefix"; + // Maximum number of replacement rounds (the replaced value can have variables again) + public static final int REPLACEMENTLIMIT = 100; + // Limit of the client is 32767 for the complete message + public static final int MAXIMUMJSONLENGTH = 30000; + // If sending a fancy message does not work we disable it for this run of the server + private static boolean fancyWorks = true; + + // INSTANCE VARIABLES + private List message; + private Object[] replacements; + private String key = null; + private boolean doLanguageReplacements = true; + private boolean inline = false; + + /** + * Initialize the Message class + * @param provider The provider to use for getting messages based on keys + * @param theLogger The logger to use for logging warning and error messages + */ + public static void init(MessageProvider provider, Logger theLogger) { + messageProvider = provider; + logger = theLogger; + } + + /** + * Internal use only + */ + private Message() { + message = new ArrayList<>(); + } + + /** + * Empty message object + * @return this + */ + public static Message empty() { + return new Message(); + } + + /** + * Construct a message from a language key + * Requires a MessageProvider to be set + * @param key The key of the message to use + * @return this + */ + public static Message fromKey(String key) { + return new Message().setMessageFromKey(key); + } + + /** + * Construct a message from a string + * @param message The message to use + * @return this + */ + public static Message fromString(String message) { + return new Message().setMessage(message); + } + + /** + * Construct a message from a string list + * @param message The message to use + * @return this + */ + public static Message fromList(List message) { + return new Message().setMessage(message); + } + + /** + * Enable or disable the use of fancy messages + * @param enabled true to enable, false to disable + */ + public static void useFancyMessages(boolean enabled) { + useInteractiveMessages = enabled; + } + + /** + * Enable or disable the use of colors when sending a message to a target that is not a Player (console, log, etcetera) + * @param enabled true to enable, false to disable + */ + public static void useColorsInConsole(boolean enabled) { + useColorsInConsole = enabled; + } + + /** + * Get the message with all replacements done + * @return Message as a list + */ + public List get() { + doReplacements(); + return message; + } + + /** + * Get the message with all replacements done + * @param limit the limit to hold to + * @return Message as a list + */ + private List get(Limit limit) { + if(limit.reached()) { + return new ArrayList<>(); + } + doReplacements(limit); + return message; + } + + /** + * Get the message with all replacements done + * @return Message as a string + */ + public String getSingle() { + doReplacements(); + return StringUtils.join(message, ""); + } + + /** + * Get the raw message without replacing anything + * @return The message + */ + public List getRaw() { + return message; + } + + /** + * Get raw message as string + * @return The raw message + */ + public String getSingleRaw() { + return StringUtils.join(message, ""); + } + + /** + * Get a plain string for the message (for example for using in the console) + * @return The message as simple string + */ + public String getPlain() { + doReplacements(); + return ConsoleGenerator.generate(YamlParser.parse(message)); + } + + /** + * Add the default prefix to the message + * @param doIt true if the prefix should be added, otherwise false + * @return this + */ + public Message prefix(boolean doIt) { + if(doIt) { + message.add(0, VARIABLESTART+LANGUAGEVARIABLE+CHATLANGUAGEVARIABLE+VARIABLEEND); + } + return this; + } + + /** + * Add the default prefix to the message + * @return this + */ + public Message prefix() { + return prefix(true); + } + + /** + * Set the replacements to apply to the message + * @param replacements The replacements to apply + * - GeneralRegion: All region replacements are applied + * - Message: Message is inserted + * - other: index tag is replaced, like %0% + * @return this + */ + public Message replacements(Object... replacements) { + this.replacements = replacements; + return this; + } + + /** + * Append lines to the message + * @param lines The lines to append + * @return this + */ + public Message append(List lines) { + message.addAll(lines); + return this; + } + + /** + * Append a message to this message + * @param message The message to append + * @return this + */ + public Message append(Message message) { + return append(message.get()); + } + + /** + * Append lines to the message + * @param line The line to append + * @return this + */ + public Message append(String line) { + message.add(line); + return this; + } + + /** + * Turn off language replacements for this message + * @return this + */ + public Message noLanguageReplacements() { + doLanguageReplacements = false; + return this; + } + + /** + * Mark this message as inline, used for insertion into other messages + * @return this + */ + public Message inline() { + inline = true; + return this; + } + + /** + * Send the message to a target + * @param target The target to send the message to (Player, CommandSender, Logger) + * @return this + */ + public Message send(Object target) { + if(message == null || message.size() == 0 || (message.size() == 1 && message.get(0).length() == 0) || target == null) { + return this; + } + doReplacements(); + if(target instanceof Player) { + boolean sendPlain = true; + if(useInteractiveMessages && fancyWorks) { + try { + boolean result = true; + List jsonMessages = TellrawGenerator.generate(YamlParser.parse(message)); + for(String jsonMessage : jsonMessages) { + if(jsonMessage.length() > MAXIMUMJSONLENGTH) { + error("Message with key", key, "could not be send, results in a JSON string that is too big to send to the client, start of the message:", getMessageStart(this, 200)); + return this; + } + result &= Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "tellraw "+((Player)target).getName()+" "+jsonMessage); + } + sendPlain = !result; + fancyWorks = result; + } catch(Exception e) { + fancyWorks = false; + error("Sending fancy message did not work, falling back to plain messages. Message key:", key, ", error:", ExceptionUtils.getStackTrace(e)); + } + } + if(sendPlain) { // Fancy messages disabled or broken + ((Player)target).sendMessage(ConsoleGenerator.generate(YamlParser.parse(message))); + } + } else { + String plainMessage = ConsoleGenerator.generate(YamlParser.parse(message)); + // Strip colors if disabled + if(!useColorsInConsole) { + plainMessage = ChatColor.stripColor(plainMessage); + } + + // Send to the target + if(target instanceof CommandSender) { + ((CommandSender)target).sendMessage(plainMessage); + } else if(target instanceof Logger) { + ((Logger)target).info(plainMessage); + } else { + warn("Could not send message (key: "+key+") because the target ("+target.getClass().getName()+") is not recognized, message: "+plainMessage); + } + } + return this; + } + + + // INTERNAL METHODS + + /** + * Set the internal message + * @param message The message to set + * @return this + */ + private Message setMessage(List message) { + this.message = message; + if(this.message == null) { + this.message = new ArrayList<>(); + } + return this; + } + + /** + * Set the internal message with a key + * @param key The message key to get the message for + * @return this + */ + private Message setMessageFromKey(String key) { + this.key = key; + if(messageProvider == null) { + error("Tried to get message with key", key+", but there is no MessageProvider!"); + } else { + this.setMessage(messageProvider.getMessage(key)); + } + return this; + } + + /** + * Set the internal message with a string + * @param message The message to set + * @return this + */ + private Message setMessage(String message) { + List list = new ArrayList<>(); + list.add(message); + return this.setMessage(list); + } + + /** + * Apply all replacements to the message + * @return this + */ + public Message doReplacements() { + Limit limit = new Limit(REPLACEMENTLIMIT, this); + doReplacements(limit); + //depthPrint(limit, "Replacing took", System.currentTimeMillis()-limit.started, "milliseconds", this); + return this; + } + + private Message doReplacements(Limit limit) { + if(limit.reached()) { + return this; + } + + limit.depth++; + + //depthPrint(limit, ">>> doReplacements:", message, limit); + // Replace variables until they are all gone, or when the limit is reached + try { + List outerOriginal; + // Repeat replacements for if language replacements introduced new variables + int fullRounds = 0; + do { + outerOriginal = new ArrayList<>(message); + List innerOriginal; + limit.left--; + if(limit.reached()) { + break; + } + + // Do argument replacements + do { + innerOriginal = new ArrayList<>(message); + if(limit.reached()) { + break; + } + replaceArgumentVariables(limit); + } while(!message.equals(innerOriginal)); + + // Do language replacements + if(doLanguageReplacements) { + do { + innerOriginal = new ArrayList<>(message); + if(limit.reached()) { + break; + } + replaceLanguageVariables(limit); + } while(!message.equals(innerOriginal)); + } + + fullRounds++; + } while(!message.equals(outerOriginal)); + + // Increase limit by one to compensate for the last round where no replacements have been done + if(!limit.reached() && fullRounds >= 1) { + limit.left++; + } + } catch(StackOverflowError e) { + limit.left = 0; + limit.notified = true; + error("Too many recursive replacements for message with key: "+limit.message.key+" (probably includes itself as replacement), start of the message: "+getMessageStart(limit.message, 200)); + } + limit.depth--; + return this; + } + + /** + * Replace argument variables in a message + * The arguments to apply as replacements: + * - If it is a GeneralRegion the replacements of the region will be applied + * - Else the parameter will replace its number surrounded with VARIABLESTART and VARIABLEEND + */ + private void replaceArgumentVariables(Limit limit) { + limit.depth++; + //depthPrint(limit, ">>> replaceArgumentVariables:", message, limit); + if(message == null || message.size() == 0 || replacements == null || limit.reached()) { + //depthPrint(limit, "quick return"); + limit.depth--; + return; + } + + for(int i = 0; i < message.size(); i++) { + int number = 0; + for(Object param : replacements) { + String line = message.get(i); + if(param != null) { + if(param instanceof ReplacementProvider) { + // Find the first non-escaped named variable + Matcher matcher = variablePattern.matcher(line); + int startAt = 0; + while(matcher.find()) { + // Check for escaping + int beforeAt = matcher.start()-1; + if(beforeAt >= 0 && line.charAt(beforeAt) == YamlParser.ESCAPE_CHAR) { + //depthPrint(limit, "skipping named variable:", matcher.group(), limit); + continue; + } + //depthPrint(limit, "replacing named variable:", matcher.group()); + + // Insert replacement provided by the ReplacementProvider + Object replacement = ((ReplacementProvider)param).provideReplacement(matcher.group().substring(1, matcher.group().length()-1)); + if(replacement != null) { + String result = ""; + // Prefix + if(matcher.start() > 0) { + result += line.substring(0, matcher.start()); + } + // Replacement + String add = replacement.toString(); + result += add; + // Suffix + if(matcher.end() < line.length()) { + result += line.substring(matcher.end()); + } + + message.set(i, result); + line = result; + int matcherStart = matcher.start(); + matcher = variablePattern.matcher(line); + matcher.region(matcherStart+add.length(), line.length()); + } + } + } else { + // Find first non-escaped numbered variable + Matcher matcher = getIndexPattern(number).matcher(line); + while(matcher.find()) { + // Check for escaping + int beforeAt = matcher.start()-1; + if(beforeAt >= 0 && line.charAt(beforeAt) == YamlParser.ESCAPE_CHAR) { + //depthPrint(limit, "skipping indexed variable:", matcher.group(), limit); + continue; + } + //depthPrint(limit, "replacing indexed variable:", matcher.group()); + + // Insert another Message + if(param instanceof Message) { + int startDiff = message.size()-i; + //depthPrint(limit, "insert message raw:", ((Message)param).message); + Message mParam = (Message)param; + + // Insert inline + if(mParam.inline) { + message.set(i, insert(line, mParam.getSingle(), matcher.start(), matcher.end())); + } + + // Insert as message + else { + List insertMessage = ((Message)param).get(limit); + //depthPrint(limit, "insert message resolved:", ((Message)param).message); + if(limit.reached()) { + limit.depth--; + return; + } + YamlParser.insertMessage(message, insertMessage, i, matcher.start(), matcher.end()); + // Skip to end of insert + i = message.size()-startDiff; + } + } + + // Insert a simple string + else { + // Insert it inline, assuming this might be user input, therefore escaping it + //depthPrint(limit, "insert string:", param.toString()); + message.set(i, insert(line, YamlParser.escape(param.toString()), matcher.start(), matcher.end())); + } + break; // Maximum of one replacement + } + number++; + } + } + } + } + limit.depth--; + } + + /** + * Insert a string into another one, replacing a part of the base + * @param base The base string to insert into + * @param insert The string to insert + * @param start The start of the region to replace + * @param end The end of the region to replace + * @return The formatted string + */ + private String insert(String base, String insert, int start, int end) { + String newMessage = ""; + if(start > 0) { + newMessage += base.substring(0, start); + } + newMessage += insert; + if(end < base.length()) { + newMessage += base.substring(end); + } + return newMessage; + } + + /** + * Replace all language variables in a message + */ + private void replaceLanguageVariables(Limit limit) { + limit.depth++; + //depthPrint(limit, ">>> replaceLanguageVariables:", message, limit); + if(message == null || message.size() == 0 || limit.reached()) { + //depthPrint(limit, "quick return"); + limit.depth--; + return; + } + + for(int i = 0; i < message.size(); i++) { + Matcher matcher = variables.matcher(message.get(i)); + while(matcher.find()) { + // Check for escaping + int beforeAt = matcher.start()-1; + if(beforeAt >= 0 && message.get(i).charAt(beforeAt) == YamlParser.ESCAPE_CHAR) { + //depthPrint(limit, "skipping variable:", matcher.group(), limit); + continue; + } + //depthPrint(limit, "replacing variable:", matcher.group()); + + // Parse arguments + String variable = matcher.group(); + String key; + Object[] arguments = null; + if(variable.contains("|")) { + key = variable.substring(VARIABLESTART.length()+LANGUAGEVARIABLE.length(), variable.indexOf("|")); + String[] stringArguments = variable.substring(variable.indexOf("|")+1, variable.length()-VARIABLEEND.length()).split("\\|"); + // Wrap arguments in Message object to prevent escaping + arguments = new Message[stringArguments.length]; + for(int argumentIndex = 0; argumentIndex < stringArguments.length; argumentIndex++) { + // Marks as inline to prevent spreading the language variable onto multiple lines + arguments[argumentIndex] = Message.fromString(stringArguments[argumentIndex]).inline(); + } + } else { + key = variable.substring(VARIABLESTART.length()+LANGUAGEVARIABLE.length(), variable.length()-VARIABLEEND.length()); + } + Message insert = Message.fromKey(key); + if(arguments != null) { + insert.replacements(arguments); + } + + // Insert message + int startDiff = message.size()-i; + List insertMessage = insert.get(limit); + if(limit.reached()) { + limit.depth--; + return; + } + YamlParser.insertMessage(message, insertMessage, i, matcher.start(), matcher.end()); + // Skip to end of insert + i = message.size()-startDiff; + break; // Maximum of one replacement + } + } + limit.depth--; + } + + @Override + public String toString() { + return "Message(key:"+key+", message:"+message+")"; + } + + + /** + * Class to store a limit + */ + private class Limit { + public int left; + public int depth; + public boolean notified = false; + public Message message; + public long started; + + /** + * Set the initial limit + * @param count The limit to use + * @param message The message this limit is started for + */ + public Limit(int count, Message message) { + this.left = count; + this.depth = 0; + this.message = message; + this.started = System.currentTimeMillis(); + } + + /** + * Check if the limit is reached + * @return true if the limit is reached, otherwise false + */ + public boolean reached() { + boolean reached = left <= 0; + if(reached && !notified) { + notified = true; + error("Reached replacement limit, probably has replacements loops, problematic message key: "+message.key+", first characters of the message: "+getMessageStart(message, 200)); + } + return reached; + } + + @Override + public String toString() { + return "Limit(left: "+left+", notified: "+notified+", depth: "+depth+", message.key: "+message.key+")"; + } + } + + + /** + * Get a start of the message with a maximum length + * @param message The message + * @param maximumLength The maximum length to return + * @return The start of the message with at most maximumLength characters + */ + private static String getMessageStart(Message message, int maximumLength) { + String messageStart = ""; + for(int i = 0; i < message.getRaw().size() && messageStart.length() < maximumLength; i++) { + messageStart += message.getRaw().get(i).substring(0, Math.min(maximumLength, message.getRaw().get(i).length())); + } + return messageStart.substring(0, Math.min(maximumLength, messageStart.length())); + } + + /** + * Debug method to print indented messages + * @param limit The limit to use for the depth + * @param message The message to print indented + */ + private static void depthPrint(Limit limit, Object... message) { + String indent = ""; + for(int i = 0; i < limit.depth; i++) { + indent += " "; + } + Object[] iMessage = new Object[message.length+1]; + iMessage[0] = indent; + System.arraycopy(message, 0, iMessage, 1, message.length); + info(iMessage); + } + + /** + * Print information to the console + * @param message The message to print + */ + public static void info(Object... message) { + if(logger != null) { + logger.info(StringUtils.join(message, " ")); + } + } + + /** + * Print a warning to the console + * @param message The message to print + */ + public static void warn(Object... message) { + if(logger != null) { + logger.warning(StringUtils.join(message, " ")); + } + } + + /** + * Print an error to the console + * @param message The messagfe to print + */ + public static void error(Object... message) { + if(logger != null) { + logger.severe(StringUtils.join(message, " ")); + } + } +} diff --git a/src/main/java/me/wiefferink/interactivemessenger/processing/ReplacementProvider.java b/src/main/java/me/wiefferink/interactivemessenger/processing/ReplacementProvider.java new file mode 100644 index 0000000..2d15082 --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/processing/ReplacementProvider.java @@ -0,0 +1,13 @@ +package me.wiefferink.interactivemessenger.processing; + +/** + * Provide replacement for a class to insert into messages + */ +public interface ReplacementProvider { + /** + * Get the replacement for a variable + * @param variable The variable to replace + * @return The replacement for the variable, or null if empty + */ + Object provideReplacement(String variable); +} diff --git a/src/main/java/me/wiefferink/interactivemessenger/source/LanguageManager.java b/src/main/java/me/wiefferink/interactivemessenger/source/LanguageManager.java new file mode 100644 index 0000000..5e763ff --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/source/LanguageManager.java @@ -0,0 +1,208 @@ +package me.wiefferink.interactivemessenger.source; + +import com.google.common.base.Charsets; +import me.wiefferink.interactivemessenger.processing.Message; +import me.wiefferink.interactivemessenger.translation.Transifex; +import org.apache.commons.lang.exception.ExceptionUtils; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.*; +import java.net.URISyntaxException; +import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public class LanguageManager implements MessageProvider { + private JavaPlugin plugin; + private Map> currentLanguage, defaultLanguage; + private File languageFolder; + private String jarLanguagePath; + private List chatPrefix; + + /** + * Constructor + * @param plugin The plugin creating this LanguageManager (used for logging and finding the language files in the jar) + * @param jarLanguagePath The path in the jar to the folder with the language files + * @param currentLanguageName The name of the language that should be active (without '.yml') + * @param defaultLanguageName The name of the language that + */ + public LanguageManager(JavaPlugin plugin, String jarLanguagePath, String currentLanguageName, String defaultLanguageName, List chatPrefix) { + this.plugin = plugin; + this.jarLanguagePath = jarLanguagePath; + this.chatPrefix = chatPrefix; + this.languageFolder = new File(plugin.getDataFolder() + File.separator + jarLanguagePath); + + Message.init(this, plugin.getLogger()); + saveDefaults(); + currentLanguage = loadLanguage(currentLanguageName); + if(defaultLanguageName.equals(currentLanguageName)) { + defaultLanguage = currentLanguage; + } else { + defaultLanguage = loadLanguage(defaultLanguageName); + } + } + + /** + * Get the message for a certain key (result can be modified) + * @param key The key of the message to get + * @return The message as a list of strings + */ + @Override + public List getMessage(String key) { + List message; + if(key.equalsIgnoreCase(Message.CHATLANGUAGEVARIABLE)) { + message = chatPrefix; + } else if(currentLanguage.containsKey(key)) { + message = currentLanguage.get(key); + } else { + message = defaultLanguage.get(key); + } + if(message == null) { + return new ArrayList<>(); + } + return new ArrayList<>(message); + } + + /** + * Saves the default language files if not already present + */ + private void saveDefaults() { + // Create the language folder if it not exists + File langFolder; + if(!languageFolder.exists()) { + if(!languageFolder.mkdirs()) { + Message.warn("Could not create language directory: "+languageFolder.getAbsolutePath()); + return; + } + } + + try { + // Read jar as ZIP file + File jarPath = new File(plugin.getClass().getProtectionDomain().getCodeSource().getLocation().toURI()); + ZipFile jar = new ZipFile(jarPath); + Enumeration entries = jar.entries(); + + // Each entry is a file or directory + while(entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + + // Filter to YAML files in the language directory + if(!entry.isDirectory() && entry.getName().startsWith(jarLanguagePath+"/") && entry.getName().endsWith(".yml")) { + // Save the file to disk + File targetFile = new File(languageFolder.getAbsolutePath() + File.separator + entry.getName().substring(entry.getName().lastIndexOf("/"))); + try( + InputStream input = jar.getInputStream(entry); + OutputStream output = new FileOutputStream(targetFile) + ) { + int read; + byte[] bytes = new byte[1024]; + while((read = input.read(bytes)) != -1) { + output.write(bytes, 0, read); + } + input.close(); + output.close(); + } catch(IOException e) { + Message.warn("Something went wrong saving a default language file: "+targetFile.getAbsolutePath()); + } + } + } + } catch(URISyntaxException e) { + Message.error("Failed to find location of jar file:", ExceptionUtils.getStackTrace(e)); + } catch(IOException e) { + Message.error("Failed to read zip file:", ExceptionUtils.getStackTrace(e)); + } + } + + /** + * Loads the specified language + * @param key The language to load + * @return Map with the messages loaded from the file + */ + private Map> loadLanguage(String key) { + return loadLanguage(key, true); + } + + /** + * Loads the specified language + * @param key The language to load + * @param convert try conversion or not (infinite recursion prevention) + * @return Map with the messages loaded from the file + */ + private Map> loadLanguage(String key, boolean convert) { + Map> result = new HashMap<>(); + + // Load the language file + boolean convertFromTransifex = false; + File file = new File(languageFolder.getAbsolutePath()+File.separator+key+".yml"); + try( + InputStreamReader reader = new InputStreamReader(new FileInputStream(file), Charsets.UTF_8) + ) { + // Detect empty language files, happens when the YAML parsers prints an exception (it does return an empty YamlConfiguration though) + YamlConfiguration ymlFile = YamlConfiguration.loadConfiguration(reader); + if(ymlFile.getKeys(false).isEmpty()) { + Message.warn("Language file "+key+".yml has zero messages."); + return result; + } + + // Retrieve the messages from the YAML file and create the result + if(!convert || !Transifex.needsConversion(ymlFile)) { + for(String messageKey : ymlFile.getKeys(false)) { + if(ymlFile.isList(messageKey)) { + result.put(messageKey, new ArrayList<>(ymlFile.getStringList(messageKey))); + } else { + result.put(messageKey, new ArrayList<>(Collections.singletonList(ymlFile.getString(messageKey)))); + } + } + } else { + convertFromTransifex = true; + } + } catch(IOException e) { + Message.warn("Could not load language file: "+file.getAbsolutePath()); + } + + // Do conversion (after block above closed the reader) + if(convertFromTransifex) { + if(!Transifex.convertFrom(file)) { + Message.warn("Failed to convert "+file.getName()+" from the Transifex layout to the AreaShop layout, check the errors above"); + } + return loadLanguage(key, false); + } + + return result; + } + +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/me/wiefferink/interactivemessenger/source/MessageProvider.java b/src/main/java/me/wiefferink/interactivemessenger/source/MessageProvider.java new file mode 100644 index 0000000..e763b6d --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/source/MessageProvider.java @@ -0,0 +1,16 @@ +package me.wiefferink.interactivemessenger.source; + +import java.util.List; + +/** + * Provide messages based on keys, for example from a language file + */ +public interface MessageProvider { + + /** + * Get the message that is linked to the specified key + * @param key The key of the message to get + * @return A list with the lines of the message + */ + List getMessage(String key); +} diff --git a/src/main/java/me/wiefferink/interactivemessenger/translation/Transifex.java b/src/main/java/me/wiefferink/interactivemessenger/translation/Transifex.java new file mode 100644 index 0000000..71341bb --- /dev/null +++ b/src/main/java/me/wiefferink/interactivemessenger/translation/Transifex.java @@ -0,0 +1,147 @@ +package me.wiefferink.interactivemessenger.translation; + +import me.wiefferink.interactivemessenger.processing.Message; +import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang.exception.ExceptionUtils; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.*; + +/** + * Convert to and from a format suitable for Transifex (online collaborative translation platform) + */ +public class Transifex { + + /** + * Test if a YamlConfiguration is downloaded from Transifex and needs to be converted + * @param configuration The configuration to check + * @return true if the file needs to be converted, otherwise false + */ + public static boolean needsConversion(YamlConfiguration configuration) { + // Detect language files downloaded from Transifex and convert them + if(configuration.getKeys(false).size() == 1) { + for(String languageKey : configuration.getKeys(false)) { + if(configuration.isConfigurationSection(languageKey)) { + return true; + } + } + } + return false; + } + + /** + * Convert a language file downloaded from Transifex into a proper format + * @param inputFile The input file to + * @return true if the conversion was successful, otherwise false + */ + public static boolean convertFrom(File inputFile) { + if(inputFile == null || !inputFile.exists() || !inputFile.isFile()) { + warn("Could not convert Transifix file, it does not exist"); + return false; + } + Message.info("Transifex conversion of", inputFile.getAbsolutePath()); + + File newFile = new File(inputFile.getAbsolutePath()+".new"); + try( + BufferedReader reader = new BufferedReader(new FileReader(inputFile)); + BufferedWriter writer = new BufferedWriter(new FileWriter(newFile)) + ) { + // Create target file + newFile.createNewFile(); + + // Add translation strings + String line = reader.readLine(); + boolean first = true; + String lastLineStart = null; + while(line != null) { + // Skip line with the language on it + if(first) { + first = false; + line = reader.readLine(); + continue; + } + // Skip comment lines (normally there are none) + if(line.startsWith("#")) { + line = reader.readLine(); + continue; + } + // Strip whitespace at the front + if(line.length() > 2) { + line = line.substring(2); + } + // Add empty lines between messages of a different type (the part before - decides the type) + if(lastLineStart != null && !line.trim().isEmpty() && !line.startsWith(lastLineStart) && !line.trim().startsWith("-")) { + writer.newLine(); + } + // Write line to the new file + if(!line.trim().isEmpty()) { + // Detect special fancyformat lines + if(line.contains("\\n")) { + String[] specials = line.split("\\\\n"); + //print("Contains newline: "+line+", split into "+specials.length+" parts"); + // Write identifier + writer.write(specials[0].substring(0, specials[0].indexOf(":")+1)); + writer.newLine(); + // Strip identifier from first part + specials[0] = specials[0].substring(specials[0].indexOf(":")+3); + // Write special lines + for(String special : specials) { + writer.write(" - \""); + if(special.startsWith("\\t")) { + special = special.substring(2); + writer.write(" "); + } + writer.write(special); + if(!special.endsWith("\"")) { + writer.write("\""); + } + writer.newLine(); + } + } else { + // Simply copy the line + writer.write(line); + writer.newLine(); + } + } + // Update last line start + String[] splittedLine = line.split("-"); + if(splittedLine.length > 0 && !line.trim().isEmpty()) { + lastLineStart = splittedLine[0]; + } + line = reader.readLine(); + } + } catch(IOException e) { + warn("Could not convert Transifix file:", ExceptionUtils.getStackTrace(e)); + newFile.delete(); + return false; + } + + // Move current file to .old, move new file to original location + File oldFile = new File(inputFile.getAbsolutePath()+".transifex"); + if(oldFile.exists() && oldFile.isFile()) { + if(!oldFile.delete()) { + warn("Could not remove existing old file:", oldFile.getAbsolutePath()); + return false; + } + } + if(!inputFile.renameTo(oldFile)) { + warn("Could not move input file to .old, from", inputFile.getAbsolutePath(), "to", oldFile.getAbsolutePath()); + return false; + } + if(!newFile.renameTo(inputFile)) { + warn("Could not move new file to original file location, from", newFile.getAbsolutePath(), "to", inputFile.getAbsolutePath()); + oldFile.renameTo(inputFile); // Try to recover original file + return false; + } + Message.info("Converted "+inputFile.getName()+" from the Transifex layout to the AreaShop layout, original saved in "+oldFile.getName()); + return true; + } + + /** + * Print a warning to the console + * @param message The message to print + */ + private static void warn(String... message) { + Message.warn("[Transifex language file conversion]", StringUtils.join(message, " ")); + } +}