diff --git a/CHANGELOG.md b/CHANGELOG.md index 11cfec05..0c32592e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,14 @@ # easy-i18n Changelog ## [Unreleased] +### Added +- Support for Json5 files ## [3.0.1] -### Changed -- Fresh projects will receive a notification instead of an exception to configure the plugin +### Changed +- Fresh projects will receive a notification instead of an exception to configure the plugin -### Fixed +### Fixed - Exception on json array value mapping ## [3.0.0] diff --git a/README.md b/README.md index 2846afb7..c83ee0ff 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ This plugin can be used for any project based on one of the formats and structur ## Builtin Support ### File Types -**JSON** - **YAML** - **Properties** +**JSON** - **JSON5** - **YAML** - **Properties** ### Folder Structure - Single Directory: All translation files are within one directory @@ -90,7 +90,7 @@ _For more examples, please refer to the [Examples Directory](https://github.com/ ## Roadmap -- [ ] JSON5 Support +- [X] JSON5 Support - [ ] XML Support - [ ] Mark duplicate translation values diff --git a/build.gradle.kts b/build.gradle.kts index 36b33dc8..f7acd510 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,10 @@ repositories { mavenCentral() } +dependencies { + implementation("de.marhali:json5-java:2.0.0") +} + // Configure Gradle IntelliJ Plugin - read more: https://github.com/JetBrains/gradle-intellij-plugin intellij { pluginName.set(properties("pluginName")) diff --git a/gradle.properties b/gradle.properties index cd3efe5d..fdb65ea8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = de.marhali.easyi18n pluginName = easy-i18n # SemVer format -> https://semver.org -pluginVersion = 3.0.1 +pluginVersion = 3.1.0 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. diff --git a/src/main/java/de/marhali/easyi18n/io/parser/ParserStrategyType.java b/src/main/java/de/marhali/easyi18n/io/parser/ParserStrategyType.java index acc25ec9..9f60b8de 100644 --- a/src/main/java/de/marhali/easyi18n/io/parser/ParserStrategyType.java +++ b/src/main/java/de/marhali/easyi18n/io/parser/ParserStrategyType.java @@ -1,6 +1,7 @@ package de.marhali.easyi18n.io.parser; import de.marhali.easyi18n.io.parser.json.JsonParserStrategy; +import de.marhali.easyi18n.io.parser.json5.Json5ParserStrategy; import de.marhali.easyi18n.io.parser.properties.PropertiesParserStrategy; import de.marhali.easyi18n.io.parser.yaml.YamlParserStrategy; @@ -10,6 +11,7 @@ */ public enum ParserStrategyType { JSON(JsonParserStrategy.class), + JSON5(Json5ParserStrategy.class), YAML(YamlParserStrategy.class), YML(YamlParserStrategy.class), PROPERTIES(PropertiesParserStrategy.class), diff --git a/src/main/java/de/marhali/easyi18n/io/parser/json5/Json5ArrayMapper.java b/src/main/java/de/marhali/easyi18n/io/parser/json5/Json5ArrayMapper.java new file mode 100644 index 00000000..c5f545b8 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/io/parser/json5/Json5ArrayMapper.java @@ -0,0 +1,53 @@ +package de.marhali.easyi18n.io.parser.json5; + +import de.marhali.easyi18n.io.parser.ArrayMapper; +import de.marhali.easyi18n.util.StringUtil; +import de.marhali.json5.Json5; +import de.marhali.json5.Json5Array; +import de.marhali.json5.Json5Primitive; + +import org.apache.commons.lang.math.NumberUtils; + +import java.io.IOException; + +/** + * Map json5 array values. + * @author marhali + */ +public class Json5ArrayMapper extends ArrayMapper { + + private static final Json5 JSON5 = Json5.builder(builder -> + builder.allowInvalidSurrogate().quoteSingle().indentFactor(0).build()); + + public static String read(Json5Array array) { + return read(array.iterator(), (jsonElement -> { + try { + return jsonElement.isJson5Array() || jsonElement.isJson5Object() + ? "\\" + JSON5.serialize(jsonElement) + : jsonElement.getAsString(); + } catch (IOException e) { + throw new AssertionError(e.getMessage(), e.getCause()); + } + })); + } + + public static Json5Array write(String concat) { + Json5Array array = new Json5Array(); + + write(concat, (element) -> { + if(element.startsWith("\\")) { + array.add(JSON5.parse(element.replace("\\", ""))); + } else { + if(StringUtil.isHexString(element)) { + array.add(Json5Primitive.of(element, true)); + } else if(NumberUtils.isNumber(element)) { + array.add(Json5Primitive.of(NumberUtils.createNumber(element))); + } else { + array.add(Json5Primitive.of(element)); + } + } + }); + + return array; + } +} diff --git a/src/main/java/de/marhali/easyi18n/io/parser/json5/Json5Mapper.java b/src/main/java/de/marhali/easyi18n/io/parser/json5/Json5Mapper.java new file mode 100644 index 00000000..80b36ae3 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/io/parser/json5/Json5Mapper.java @@ -0,0 +1,73 @@ +package de.marhali.easyi18n.io.parser.json5; + +import de.marhali.easyi18n.model.Translation; +import de.marhali.easyi18n.model.TranslationNode; +import de.marhali.easyi18n.util.StringUtil; + +import de.marhali.json5.Json5Element; +import de.marhali.json5.Json5Object; +import de.marhali.json5.Json5Primitive; +import org.apache.commons.lang.StringEscapeUtils; +import org.apache.commons.lang.math.NumberUtils; + +import java.util.Map; + +/** + * Mapper for mapping json5 objects into translation nodes and backwards. + * @author marhali + */ +public class Json5Mapper { + public static void read(String locale, Json5Object json, TranslationNode node) { + for(Map.Entry entry : json.entrySet()) { + String key = entry.getKey(); + Json5Element value = entry.getValue(); + + TranslationNode childNode = node.getOrCreateChildren(key); + + if(value.isJson5Object()) { + // Nested element - run recursively + read(locale, value.getAsJson5Object(), childNode); + } else { + Translation translation = childNode.getValue(); + + String content = value.isJson5Array() + ? Json5ArrayMapper.read(value.getAsJson5Array()) + : StringUtil.escapeControls(value.getAsString(), true); + + translation.put(locale, content); + childNode.setValue(translation); + } + } + } + + public static void write(String locale, Json5Object json, TranslationNode node) { + for(Map.Entry entry : node.getChildren().entrySet()) { + String key = entry.getKey(); + TranslationNode childNode = entry.getValue(); + + if(!childNode.isLeaf()) { + // Nested node - run recursively + Json5Object childJson = new Json5Object(); + write(locale, childJson, childNode); + if(childJson.size() > 0) { + json.add(key, childJson); + } + + } else { + Translation translation = childNode.getValue(); + String content = translation.get(locale); + if(content != null) { + if(Json5ArrayMapper.isArray(content)) { + json.add(key, Json5ArrayMapper.write(content)); + } else if(StringUtil.isHexString(content)) { + json.add(key, Json5Primitive.of(content, true)); + } else if(NumberUtils.isNumber(content)) { + json.add(key, Json5Primitive.of(NumberUtils.createNumber(content))); + } else { + json.add(key, Json5Primitive.of(StringEscapeUtils.unescapeJava(content))); + } + } + } + } + } +} diff --git a/src/main/java/de/marhali/easyi18n/io/parser/json5/Json5ParserStrategy.java b/src/main/java/de/marhali/easyi18n/io/parser/json5/Json5ParserStrategy.java new file mode 100644 index 00000000..cb9c64b7 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/io/parser/json5/Json5ParserStrategy.java @@ -0,0 +1,58 @@ +package de.marhali.easyi18n.io.parser.json5; + +import com.intellij.openapi.vfs.VirtualFile; + +import de.marhali.easyi18n.io.parser.ParserStrategy; +import de.marhali.easyi18n.model.SettingsState; +import de.marhali.easyi18n.model.TranslationData; +import de.marhali.easyi18n.model.TranslationFile; +import de.marhali.easyi18n.model.TranslationNode; +import de.marhali.json5.Json5; +import de.marhali.json5.Json5Element; +import de.marhali.json5.Json5Object; + +import org.jetbrains.annotations.NotNull; + +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.Objects; + +/** + * Json5 file format parser strategy + * @author marhali + */ +public class Json5ParserStrategy extends ParserStrategy { + + private static final Json5 JSON5 = Json5.builder(builder -> + builder.allowInvalidSurrogate().trailingComma().indentFactor(4).build()); + + public Json5ParserStrategy(@NotNull SettingsState settings) { + super(settings); + } + + @Override + public void read(@NotNull TranslationFile file, @NotNull TranslationData data) throws Exception { + data.addLocale(file.getLocale()); + + VirtualFile vf = file.getVirtualFile(); + TranslationNode targetNode = super.getOrCreateTargetNode(file, data); + + try (Reader reader = new InputStreamReader(vf.getInputStream(), vf.getCharset())) { + Json5Element input = JSON5.parse(reader); + if(input != null && input.isJson5Object()) { + Json5Mapper.read(file.getLocale(), input.getAsJson5Object(), targetNode); + } + } + } + + @Override + public void write(@NotNull TranslationData data, @NotNull TranslationFile file) throws Exception { + TranslationNode targetNode = super.getTargetNode(data, file); + + Json5Object output = new Json5Object(); + Json5Mapper.write(file.getLocale(), output, Objects.requireNonNull(targetNode)); + + VirtualFile vf = file.getVirtualFile(); + vf.setBinaryContent(JSON5.serialize(output).getBytes(vf.getCharset())); + } +} diff --git a/src/main/java/de/marhali/easyi18n/util/StringUtil.java b/src/main/java/de/marhali/easyi18n/util/StringUtil.java index 88c7ab85..6597ddad 100644 --- a/src/main/java/de/marhali/easyi18n/util/StringUtil.java +++ b/src/main/java/de/marhali/easyi18n/util/StringUtil.java @@ -3,6 +3,7 @@ import org.jetbrains.annotations.NotNull; import java.io.StringWriter; +import java.util.regex.Pattern; /** * String utilities @@ -10,6 +11,17 @@ */ public class StringUtil { + /** + * Checks if the provided String represents a hexadecimal number. + * For example: {@code 0x100...}, {@code -0x100...} and {@code +0x100...}. + * @param string String to evaluate + * @return true if hexadecimal string otherwise false + */ + public static boolean isHexString(@NotNull String string) { + final Pattern hexNumberPattern = Pattern.compile("[+-]?0[xX][0-9a-fA-F]+"); + return hexNumberPattern.matcher(string).matches(); + } + /** * Escapes control characters for the given input string. * Inspired by Apache Commons (see {@link org.apache.commons.lang.StringEscapeUtils} diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index e2510851..ed38b922 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -17,7 +17,7 @@ settings.path.text=Locales directory settings.strategy.title=Translation file structure settings.strategy.folder=Single Directory;Modularized: Locale / Namespace;Modularized: Namespace / Locale settings.strategy.folder.tooltip=What is the folder structure of your translation files? -settings.strategy.parser=JSON;YAML;YML;Properties;ARB +settings.strategy.parser=JSON;JSON5;YAML;YML;Properties;ARB settings.strategy.parser.tooltip=Which file parser should be used to process your translation files? settings.strategy.file-pattern.tooltip=Defines a wildcard matcher to filter relevant translation files. For example *.json, *.???. settings.preview=Preview locale diff --git a/src/test/java/de/marhali/easyi18n/mapper/Json5MapperTest.java b/src/test/java/de/marhali/easyi18n/mapper/Json5MapperTest.java new file mode 100644 index 00000000..befdc042 --- /dev/null +++ b/src/test/java/de/marhali/easyi18n/mapper/Json5MapperTest.java @@ -0,0 +1,160 @@ +package de.marhali.easyi18n.mapper; + +import de.marhali.easyi18n.io.parser.json.JsonArrayMapper; +import de.marhali.easyi18n.io.parser.json5.Json5ArrayMapper; +import de.marhali.easyi18n.io.parser.json5.Json5Mapper; +import de.marhali.easyi18n.model.KeyPath; +import de.marhali.easyi18n.model.TranslationData; +import de.marhali.json5.Json5Object; +import de.marhali.json5.Json5Primitive; + +import org.apache.commons.lang.StringEscapeUtils; +import org.junit.Assert; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Unit tests for {@link Json5Mapper}. + * @author marhali + */ +public class Json5MapperTest extends AbstractMapperTest { + + @Override + public void testNonSorting() { + Json5Object input = new Json5Object(); + input.add("zulu", Json5Primitive.of("test")); + input.add("alpha", Json5Primitive.of("test")); + input.add("bravo", Json5Primitive.of("test")); + + TranslationData data = new TranslationData(false); + Json5Mapper.read("en", input, data.getRootNode()); + + Json5Object output = new Json5Object(); + Json5Mapper.write("en", output, data.getRootNode()); + + Set expect = new LinkedHashSet<>(Arrays.asList("zulu", "alpha", "bravo")); + Assert.assertEquals(expect, output.keySet()); + } + + @Override + public void testSorting() { + Json5Object input = new Json5Object(); + input.add("zulu", Json5Primitive.of("test")); + input.add("alpha", Json5Primitive.of("test")); + input.add("bravo", Json5Primitive.of("test")); + + TranslationData data = new TranslationData(false); + Json5Mapper.read("en", input, data.getRootNode()); + + Json5Object output = new Json5Object(); + Json5Mapper.write("en", output, data.getRootNode()); + + Set expect = new LinkedHashSet<>(Arrays.asList("alpha", "bravo", "zulu")); + Assert.assertEquals(expect, output.keySet()); + } + + @Override + public void testArrays() { + TranslationData data = new TranslationData(true); + data.setTranslation(KeyPath.of("simple"), create(arraySimple)); + data.setTranslation(KeyPath.of("escaped"), create(arrayEscaped)); + + Json5Object output = new Json5Object(); + Json5Mapper.write("en", output, data.getRootNode()); + + Assert.assertTrue(output.get("simple").isJson5Array()); + Assert.assertEquals(arraySimple, Json5ArrayMapper.read(output.get("simple").getAsJson5Array())); + Assert.assertTrue(output.get("escaped").isJson5Array()); + Assert.assertEquals(arrayEscaped, StringEscapeUtils.unescapeJava(Json5ArrayMapper.read(output.get("escaped").getAsJson5Array()))); + + TranslationData input = new TranslationData(true); + Json5Mapper.read("en", output, input.getRootNode()); + + Assert.assertTrue(JsonArrayMapper.isArray(input.getTranslation(KeyPath.of("simple")).get("en"))); + Assert.assertTrue(JsonArrayMapper.isArray(input.getTranslation(KeyPath.of("escaped")).get("en"))); + } + + @Override + public void testSpecialCharacters() { + TranslationData data = new TranslationData(true); + data.setTranslation(KeyPath.of("chars"), create(specialCharacters)); + + Json5Object output = new Json5Object(); + Json5Mapper.write("en", output, data.getRootNode()); + + Assert.assertEquals(specialCharacters, output.get("chars").getAsString()); + + TranslationData input = new TranslationData(true); + Json5Mapper.read("en", output, input.getRootNode()); + + Assert.assertEquals(specialCharacters, + StringEscapeUtils.unescapeJava(input.getTranslation(KeyPath.of("chars")).get("en"))); + } + + @Override + public void testNestedKeys() { + TranslationData data = new TranslationData(true); + data.setTranslation(KeyPath.of("nested", "key", "section"), create("test")); + + Json5Object output = new Json5Object(); + Json5Mapper.write("en", output, data.getRootNode()); + + Assert.assertEquals("test", output.getAsJson5Object("nested").getAsJson5Object("key").get("section").getAsString()); + + TranslationData input = new TranslationData(true); + Json5Mapper.read("en", output, input.getRootNode()); + + Assert.assertEquals("test", input.getTranslation(KeyPath.of("nested", "key", "section")).get("en")); + } + + @Override + public void testNonNestedKeys() { + TranslationData data = new TranslationData(true); + data.setTranslation(KeyPath.of("long.key.with.many.sections"), create("test")); + + Json5Object output = new Json5Object(); + Json5Mapper.write("en", output, data.getRootNode()); + + Assert.assertTrue(output.has("long.key.with.many.sections")); + + TranslationData input = new TranslationData(true); + Json5Mapper.read("en", output, input.getRootNode()); + + Assert.assertEquals("test", input.getTranslation(KeyPath.of("long.key.with.many.sections")).get("en")); + } + + @Override + public void testLeadingSpace() { + TranslationData data = new TranslationData(true); + data.setTranslation(KeyPath.of("space"), create(leadingSpace)); + + Json5Object output = new Json5Object(); + Json5Mapper.write("en", output, data.getRootNode()); + + Assert.assertEquals(leadingSpace, output.get("space").getAsString()); + + TranslationData input = new TranslationData(true); + Json5Mapper.read("en", output, input.getRootNode()); + + Assert.assertEquals(leadingSpace, input.getTranslation(KeyPath.of("space")).get("en")); + } + + @Override + public void testNumbers() { + TranslationData data = new TranslationData(true); + data.setTranslation(KeyPath.of("numbered"), create("15000")); + + Json5Object output = new Json5Object(); + Json5Mapper.write("en", output, data.getRootNode()); + + Assert.assertEquals(15000, output.get("numbered").getAsNumber()); + + Json5Object input = new Json5Object(); + input.addProperty("numbered", 143.23); + Json5Mapper.read("en", input, data.getRootNode()); + + Assert.assertEquals("143.23", data.getTranslation(KeyPath.of("numbered")).get("en")); + } +} \ No newline at end of file