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
This commit is contained in:
Thijs Wiefferink committed 2017-01-21 17:05:03 +01:00
1 parent 892b7723dc
commit 16bb949691
22 files changed
+2064 -827

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
/bin
.metadata
.classpath
.project
-26
View File
@@ -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);
}
}
@@ -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<String, Tag> BRACKET_TAG_LIST = new HashMap<String, Tag>();
/** Lookup table for all interactive tags */
private static final HashMap<String, Tag> INTERACTIVE_TAG_LIST = new HashMap<String, Tag>();
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 <T extends Tag> void cacheTags(HashMap<String, Tag> tagList, Class<T> 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<String> inputLines) {
ArrayList<String> lines = cleanInputString(inputLines);
LinkedList<InteractiveMessagePart> 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.
* <p>
* 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.
* <p>
* 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<String> inputLines) {
ArrayList<String> lines = cleanInputString(inputLines);
StringBuilder result = new StringBuilder();
Color currentColor = Color.WHITE;
Set<FormatType> currentFormatting = new HashSet<FormatType>();
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 -------------------------------
// ------------------------------------------------------------------------------------------
/**
* <ul>
* <li>Splits lines at line breaks (creating a new line in the Array).
* <li>Removes empty lines at the beginning.
* <li>Removes lines with properties in front of the first text-line.
* </ul>
*/
private static ArrayList<String> cleanInputString(Iterable<String> inputLines) {
// Split lines at line breaks
// In the end we will have a list with one line per element
ArrayList<String> 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<InteractiveMessagePart> parse(final Iterable<String> inputLines) {
LinkedList<InteractiveMessagePart> message = new LinkedList<InteractiveMessagePart>();
Color currentColor = Color.WHITE;
Set<FormatType> currentFormatting = new HashSet<FormatType>();
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<TextMessagePart>();
messagePart.hoverType = (HoverType) tag;
}
// Add hover content below
}
}
if (isTextLine || isHoverLine) {
// Parse inline tags
Color currentLineColor = currentColor;
Set<FormatType> currentLineFormatting = currentFormatting;
LinkedList<TextMessagePart> targetList = messagePart.content;
boolean parseBreak = true;
if (isHoverLine) {
// Reset - use own
currentLineColor = Color.WHITE;
currentLineFormatting = new HashSet<FormatType>();
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<FormatType>(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<FormatType> formatTypes = new HashSet<FormatType>();
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<TextMessagePart> content = new LinkedList<TextMessagePart>();
// Click
ClickType clickType = null;
String clickContent = "";
// Hover
HoverType hoverType = null;
LinkedList<TextMessagePart> 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";
}
}
}
+6 -2
View File
@@ -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)
+12 -12
View File
@@ -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
+43
View File
@@ -0,0 +1,43 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>me.wiefferink</groupId>
<artifactId>interactivemessenger</artifactId>
<version>1.0</version>
<name>InteractiveMessenger</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.7</maven.compiler.source>
<maven.compiler.target>1.7</maven.compiler.target>
</properties>
<repositories>
<repository>
<id>spigot-repo</id>
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.bukkit</groupId>
<artifactId>bukkit</artifactId>
<version>1.8-R0.1-SNAPSHOT</version>
<type>jar</type>
<scope>provided</scope>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<directory>target</directory>
<finalName>${name}</finalName>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
</plugin>
</plugins>
</build>
</project>
@@ -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<Color, Character> colorCode = new EnumMap<Color, Character>(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<Format, Character> formatCode = new EnumMap<Format, Character>(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.
* <p>
* 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;
}
}
@@ -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<Format, String> formatJsonKey = new EnumMap<Format, String>(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<Click, String> clickJsonKey = new EnumMap<Click, String>(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<Hover, String> hoverJsonKey = new EnumMap<Hover, String>(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<String> generate(InteractiveMessage message) {
List<String> result = new ArrayList<>();
List<InteractiveMessagePart> 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;
}
}
@@ -0,0 +1,9 @@
package me.wiefferink.interactivemessenger.message;
import java.util.LinkedList;
/**
* Interactive message
*/
public class InteractiveMessage extends LinkedList<InteractiveMessagePart> {
}
@@ -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<TextMessagePart> {
public boolean newline = false;
// Click
public Click onClick = null;
public String clickContent = "";
// Hover
public Hover onHover = null;
public LinkedList<TextMessagePart> hoverContent = null;
@Override
public String toString() {
return "InteractiveMessagePart(textMessageParts:"+super.toString()+", onClick:"+onClick+", clickContent:"+clickContent+", onHover:"+onHover+", hoverContent:"+hoverContent+", newline:"+newline+")";
}
}
@@ -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<Format> 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()+")";
}
}
@@ -0,0 +1,7 @@
package me.wiefferink.interactivemessenger.message.enums;
public enum Click {
LINK,
COMMAND,
SUGGEST
}
@@ -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
}
@@ -0,0 +1,6 @@
package me.wiefferink.interactivemessenger.message.enums;
public enum Control {
BREAK,
RESET
}
@@ -0,0 +1,9 @@
package me.wiefferink.interactivemessenger.message.enums;
public enum Format {
BOLD,
ITALIC,
UNDERLINE,
STRIKETHROUGH,
OBFUSCATE
}
@@ -0,0 +1,6 @@
package me.wiefferink.interactivemessenger.message.enums;
// TODO support show_item, show_achievement, show_entity
public enum Hover {
HOVER
}
@@ -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 ([<tag>])
private static final HashMap<String, Object> BRACKET_TAGS = new HashMap<String, Object>() {{
// 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 (<tag>:)
private static final HashMap<String, Object> INTERACTIVE_TAGS = new HashMap<String, Object>() {{
cacheTags(this, Click.class);
cacheTags(this, Hover.class);
}};
// Lookup table for all native enums (&<tag> and §<tag>)
private static final HashMap<String, Object> NATIVE_TAGS = new HashMap<String, Object>() {{
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 <T extends Enum> void cacheTags(HashMap<String, Object> tagList, Class<T> 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<String> 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<String> input, boolean doInteractives) {
InteractiveMessage message = new InteractiveMessage();
Color currentColor = null;
Set<Format> 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<Format> currentLineFormatting = currentFormatting;
LinkedList<TextMessagePart> 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<InteractiveMessagePart> 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<String> message, List<String> 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<String> 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<String> cleanInputString(List<String> inputLines) {
// Split li|nes at line breaks
// In the end we will have a list with one line per element
ArrayList<String> 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<String> 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;
}
}
}
@@ -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<Integer, Pattern> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> outerOriginal;
// Repeat replacements for if language replacements introduced new variables
int fullRounds = 0;
do {
outerOriginal = new ArrayList<>(message);
List<String> 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<String> 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<String> 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, " "));
}
}
}
@@ -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);
}
@@ -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<String, List<String>> currentLanguage, defaultLanguage;
private File languageFolder;
private String jarLanguagePath;
private List<String> 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<String> 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<String> getMessage(String key) {
List<String> 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<? extends ZipEntry> 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<String, List<String>> 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<String, List<String>> loadLanguage(String key, boolean convert) {
Map<String, List<String>> 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;
}
}
@@ -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<String> getMessage(String key);
}
@@ -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, " "));
}
}