diff --git a/src/main/java/de/marhali/easyi18n/model/Translation.java b/src/main/java/de/marhali/easyi18n/model/Translation.java new file mode 100644 index 0000000..2870837 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/model/Translation.java @@ -0,0 +1,19 @@ +package de.marhali.easyi18n.model; + +import java.util.HashMap; + +/** + * Represents all translations for an element. The assignment to an element is done in the using class. + * This class contains only the translations for this unspecific element. + * @author marhali + */ +public class Translation extends HashMap { + public Translation() { + super(); + } + + @Override + public String toString() { + return super.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/TranslationData.java b/src/main/java/de/marhali/easyi18n/model/TranslationData.java new file mode 100644 index 0000000..a399ca5 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/model/TranslationData.java @@ -0,0 +1,181 @@ +package de.marhali.easyi18n.model; + +import de.marhali.easyi18n.util.PathUtil; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +/** + * Cached translation data. The data is stored in a tree structure. + * Tree behaviour (sorted, non-sorted) can be specified via constructor. + * For more please see {@link TranslationNode}. Example tree view: + * + * ################################# + * # - user: # + * # - principal: 'Principal' # + * # - username: # + * # - title: 'Username' # + * # - auth: # + * # - logout: 'Logout' # + * # - login: 'Login' # + * ################################# + * + * @author marhali + */ +public class TranslationData { + + private final PathUtil pathUtil; + + @NotNull + private final Set locales; + + @NotNull + private final TranslationNode rootNode; + + /** + * Creates an empty instance. + * @param sort Should the translation keys be sorted alphabetically + */ + public TranslationData(boolean sort, boolean nestKeys) { + this(nestKeys, new HashSet<>(), new TranslationNode(sort ? new TreeMap<>() : new LinkedHashMap<>())); + } + + /** + * @param nestKeys Apply key nesting. See {@link PathUtil} + * @param locales Languages which can be used for translation + * @param rootNode Translation tree structure + */ + public TranslationData(boolean nestKeys, @NotNull Set locales, @NotNull TranslationNode rootNode) { + this.pathUtil = new PathUtil(nestKeys); + this.locales = locales; + this.rootNode = rootNode; + } + + /** + * @return Set of languages which can receive translations + */ + public @NotNull Set getLocales() { + return this.locales; + } + + /** + * @return root node which contains all translations + */ + public @NotNull TranslationNode getRootNode() { + return this.rootNode; + } + + /** + * @param fullPath Absolute translation path + * @return Translation node which leads to translations or nested child's + */ + public @Nullable TranslationNode getNode(@NotNull String fullPath) { + List sections = this.pathUtil.split(fullPath); + TranslationNode node = this.rootNode; + + if(fullPath.isEmpty()) { // Return root node if empty path was supplied + return node; + } + + for(String section : sections) { + if(node == null) { + return null; + } + node = node.getChildren().get(section); + } + + return node; + } + + /** + * @param fullPath Absolute translation key path + * @return Found translation. Can be null if path is empty or is not a leaf element + */ + public @Nullable Translation getTranslation(@NotNull String fullPath) { + TranslationNode node = this.getNode(fullPath); + + if(node == null || !node.isLeaf()) { + return null; + } + + return node.getValue(); + } + + /** + * @param fullPath Absolute translation key path + * @param translation Translation to set. Can be null to delete the corresponding node + */ + public void setTranslation(@NotNull String fullPath, @Nullable Translation translation) throws Exception { + List sections = this.pathUtil.split(fullPath); + String nodeKey = sections.remove(sections.size() - 1); // Edge case last section + TranslationNode node = this.rootNode; + + if(fullPath.isEmpty()) { + throw new IllegalArgumentException("Path cannot be empty"); + } + + for(String section : sections) { // Go to the level of the key (@nodeKey) + TranslationNode childNode = node.getChildren().get(section); + + if(childNode == null) { + if(translation == null) { // Path should not be empty for delete + throw new IllegalArgumentException("Delete action on empty path"); + } + + // Created nested section + childNode = node.addChildren(section); + } + + node = childNode; + } + + if(translation == null) { // Delete + node.removeChildren(nodeKey); + + if(node.getChildren().isEmpty() && !node.isRoot()) { // Parent is empty now. Run delete recursively + this.setTranslation(this.pathUtil.concat(sections), null); + } + + } else { // Create or overwrite + node.addChildren(nodeKey, translation); + } + } + + /** + * @return All translation keys as absolute paths (full-key) + */ + public @NotNull Set getFullKeys() { + return this.getFullKeys("", this.rootNode); // Just use root node + } + + /** + * @param parentPath Parent key path + * @param node Node section to begin with + * @return All translation keys where the path contains the specified @parentPath + */ + public @NotNull Set getFullKeys(String parentPath, TranslationNode node) { + Set keys = new LinkedHashSet<>(); + + if(node.isLeaf()) { // This node does not lead to child's - just add the key + keys.add(parentPath); + } + + for(Map.Entry children : node.getChildren().entrySet()) { + keys.addAll(this.getFullKeys(this.pathUtil.append(parentPath, children.getKey()), children.getValue())); + } + + return keys; + } + + @Override + public String toString() { + return "TranslationData{" + + "mapClass=" + rootNode.getChildren().getClass().getSimpleName() + + ", pathUtil=" + pathUtil + + ", locales=" + locales + + ", rootNode=" + rootNode + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/TranslationNode.java b/src/main/java/de/marhali/easyi18n/model/TranslationNode.java new file mode 100644 index 0000000..4056598 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/model/TranslationNode.java @@ -0,0 +1,101 @@ +package de.marhali.easyi18n.model; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; + +/** + * Translation tree node. Manages child nodes which can be translations or also + * nodes which can lead to another translation or node. + * Navigation inside a node can be upward and downward. To construct the full + * translation key (full-key) every parent needs to be resolved recursively. + * - + * Whether the children nodes should be sorted is determined by the parent node. + * For root nodes (empty parent) the {@link java.util.Map}-Type must be specified + * to determine which sorting should be applied. + * + * @author marhali + */ +public class TranslationNode { + + @Nullable + private TranslationNode parent; + + @NotNull + private Map children; + + @NotNull + private Translation value; + + /** + * Root node initializer. E.g. see {@link java.util.TreeMap} or {@link java.util.HashMap} + * @param children Decide which implementation should be used (sorted, non-sorted) + */ + public TranslationNode(@NotNull Map children) { + this.parent = null; + this.children = children; + this.value = new Translation(); + } + + /** + * @return true if this node is considered as root node + */ + public boolean isRoot() { + return this.parent == null; + } + + /** + * @return true if this node does not lead to other children nodes (just contains {@link Translation} itself). + * The root node is never treated as a leaf node. + */ + public boolean isLeaf() { + return this.children.isEmpty() && !this.isRoot(); + } + + public void setParent(@Nullable TranslationNode parent) { + this.parent = parent; + } + + public @NotNull Translation getValue() { + return value; + } + + public void setValue(@NotNull Translation value) { + this.children.clear(); + this.value = value; + } + + public @NotNull Map getChildren() { + return this.children; + } + + public void addChildren(@NotNull String key, @NotNull TranslationNode node) { + node.setParent(this); // Track parent if adding children's + this.value.clear(); + this.children.put(key, node); + } + + public TranslationNode addChildren(@NotNull String key) throws Exception { + TranslationNode node = new TranslationNode(this.children.getClass().getDeclaredConstructor().newInstance()); + this.addChildren(key, node); + return node; + } + + public void addChildren(@NotNull String key, @NotNull Translation translation) throws Exception { + this.addChildren(key).setValue(translation); + } + + public void removeChildren(@NotNull String key) { + this.children.remove(key); + } + + @Override + public String toString() { + return "TranslationNode{" + + "parent=" + parent + + ", children=" + children.keySet() + + ", value=" + value + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/util/PathUtil.java b/src/main/java/de/marhali/easyi18n/util/PathUtil.java new file mode 100644 index 0000000..64aee8e --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/util/PathUtil.java @@ -0,0 +1,73 @@ +package de.marhali.easyi18n.util; + +import de.marhali.easyi18n.service.SettingsService; + +import com.intellij.openapi.project.Project; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Utility tool for split and merge translation key paths. + * Some i18n implementations require to NOT nest the translation keys. + * This util takes care of this and checks the configured setting for this case. + * @author marhali + */ +public class PathUtil { + + public static final char DELIMITER = '.'; + + private final boolean nestKeys; + + public PathUtil(boolean nestKeys) { + this.nestKeys = nestKeys; + } + + public PathUtil(Project project) { + this.nestKeys = SettingsService.getInstance(project).getState().isNestedKeys(); + } + + public @NotNull List split(@NotNull String path) { + // Does not contain any sections or key nesting is disabled + if(!path.contains(String.valueOf(DELIMITER)) || !nestKeys) { + return new ArrayList<>(Collections.singletonList(path)); + } + + return new ArrayList<>(Arrays.asList(path.split("\\" + DELIMITER))); + } + + public @NotNull String concat(@NotNull List sections) { + StringBuilder builder = new StringBuilder(); + + // For disabled key nesting this should be only one section + for(String section : sections) { + if(builder.length() > 0) { + builder.append(DELIMITER); + } + + builder.append(section); + } + + return builder.toString(); + } + + public @NotNull String append(@NotNull String parentPath, @NotNull String children) { + StringBuilder builder = new StringBuilder(parentPath); + + if(builder.length() > 0) { // Only add delimiter between parent and child if parent is NOT empty + builder.append(DELIMITER); + } + + return builder.append(children).toString(); + } + + @Override + public String toString() { + return "PathUtil{" + + "nestKeys=" + nestKeys + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/util/TranslationBuilder.java b/src/main/java/de/marhali/easyi18n/util/TranslationBuilder.java new file mode 100644 index 0000000..21adf1e --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/util/TranslationBuilder.java @@ -0,0 +1,30 @@ +package de.marhali.easyi18n.util; + +import de.marhali.easyi18n.model.Translation; + +/** + * Translation builder utility. + * @author marhali + */ +public class TranslationBuilder { + + private Translation translation; + + public TranslationBuilder() { + this.translation = new Translation(); + } + + public TranslationBuilder(String locale, String content) { + this(); + this.translation.put(locale, content); + } + + public TranslationBuilder add(String locale, String content) { + this.translation.put(locale, content); + return this; + } + + public Translation build() { + return this.translation; + } +} \ No newline at end of file diff --git a/src/test/java/de/marhali/easyi18n/TranslationDataTest.java b/src/test/java/de/marhali/easyi18n/TranslationDataTest.java new file mode 100644 index 0000000..111a7e8 --- /dev/null +++ b/src/test/java/de/marhali/easyi18n/TranslationDataTest.java @@ -0,0 +1,271 @@ +package de.marhali.easyi18n; + +import de.marhali.easyi18n.model.Translation; +import de.marhali.easyi18n.model.TranslationData; +import de.marhali.easyi18n.model.TranslationNode; +import de.marhali.easyi18n.util.TranslationBuilder; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.*; + +/** + * Unit tests for {@link TranslationData} in combination with {@link TranslationNode} + * @author marhali + */ +public class TranslationDataTest { + + private final int numOfTranslations = 18; + + private void addTranslations(TranslationData data) throws Exception { + data.setTranslation("zulu", new TranslationBuilder("en", "test").build()); + data.setTranslation("gamma", new TranslationBuilder("en", "test").build()); + + data.setTranslation("foxtrot.super.long.key", new TranslationBuilder("en", "test").build()); + + data.setTranslation("bravo.b", new TranslationBuilder("en", "test").build()); + data.setTranslation("bravo.c", new TranslationBuilder("en", "test").build()); + data.setTranslation("bravo.a", new TranslationBuilder("en", "test").build()); + data.setTranslation("bravo.d", new TranslationBuilder("en", "test").build()); + data.setTranslation("bravo.long.bravo", new TranslationBuilder("en", "test").build()); + data.setTranslation("bravo.long.charlie.a", new TranslationBuilder("en", "test").build()); + data.setTranslation("bravo.long.alpha", new TranslationBuilder("en", "test").build()); + + data.setTranslation("alpha.b", new TranslationBuilder("en", "test").build()); + data.setTranslation("alpha.c", new TranslationBuilder("en", "test").build()); + data.setTranslation("alpha.a", new TranslationBuilder("en", "test").build()); + data.setTranslation("alpha.d", new TranslationBuilder("en", "test").build()); + + data.setTranslation("charlie.b", new TranslationBuilder("en", "test").build()); + data.setTranslation("charlie.c", new TranslationBuilder("en", "test").build()); + data.setTranslation("charlie.a", new TranslationBuilder("en", "test").build()); + data.setTranslation("charlie.d", new TranslationBuilder("en", "test").build()); + } + + @Test + public void testKeySorting() throws Exception { + TranslationData data = new TranslationData(true, true); + this.addTranslations(data); + + Set expectation = new LinkedHashSet<>(Arrays.asList( + "alpha.a", "alpha.b", "alpha.c", "alpha.d", + "bravo.a", "bravo.b", "bravo.c", "bravo.d", + "bravo.long.alpha", "bravo.long.bravo", "bravo.long.charlie.a", + "charlie.a", "charlie.b", "charlie.c", "charlie.d", + "foxtrot.super.long.key", + "gamma", + "zulu" + )); + + Assert.assertEquals(data.getFullKeys(), expectation); + } + + @Test + public void testKeyUnordered() throws Exception { + TranslationData data = new TranslationData(false, true); + this.addTranslations(data); + + Set expectation = new LinkedHashSet<>(Arrays.asList( + "zulu", + "gamma", + "foxtrot.super.long.key", + "bravo.b", "bravo.c", "bravo.a", "bravo.d", + "bravo.long.bravo", "bravo.long.charlie.a", "bravo.long.alpha", + "alpha.b", "alpha.c", "alpha.a", "alpha.d", + "charlie.b", "charlie.c", "charlie.a", "charlie.d" + )); + + Assert.assertEquals(data.getFullKeys(), expectation); + } + + @Test + public void testKeyNesting() throws Exception { + TranslationData data = new TranslationData(true, true); + + data.setTranslation("nested.alpha", new TranslationBuilder("en", "test").build()); + data.setTranslation("nested.bravo", new TranslationBuilder("en", "test").build()); + data.setTranslation("other.alpha", new TranslationBuilder("en", "test").build()); + data.setTranslation("other.bravo", new TranslationBuilder("en", "test").build()); + + Assert.assertEquals(data.getRootNode().getChildren().size(), 2); + + for(TranslationNode node : data.getRootNode().getChildren().values()) { + Assert.assertFalse(node.isLeaf()); + } + } + + @Test + public void testKeyNonNested() throws Exception { + TranslationData data = new TranslationData(true, false); + this.addTranslations(data); + + Assert.assertEquals(data.getRootNode().getChildren().size(), this.numOfTranslations); + + for(TranslationNode node : data.getRootNode().getChildren().values()) { + Assert.assertTrue(node.isLeaf()); + } + } + + @Test + public void testDeleteNested() throws Exception { + TranslationData data = new TranslationData(true, true); + + Translation value = new TranslationBuilder("en", "test").build(); + + data.setTranslation("alpha", value); + data.setTranslation("nested.alpha", value); + data.setTranslation("nested.long.bravo", value); + + Assert.assertEquals(data.getFullKeys().size(), 3); + + data.setTranslation("alpha", null); + data.setTranslation("nested.alpha", null); + data.setTranslation("nested.long.bravo", null); + + Assert.assertEquals(data.getFullKeys().size(), 0); + Assert.assertNull(data.getTranslation("alpha")); + Assert.assertNull(data.getTranslation("nested.alpha")); + Assert.assertNull(data.getTranslation("nested.long.bravo")); + } + + @Test + public void testDeleteNonNested() throws Exception { + TranslationData data = new TranslationData(true, false); + + Translation value = new TranslationBuilder("en", "test").build(); + + data.setTranslation("alpha", value); + data.setTranslation("nested.alpha", value); + data.setTranslation("nested.long.bravo", value); + + Assert.assertEquals(data.getFullKeys().size(), 3); + + data.setTranslation("alpha", null); + data.setTranslation("nested.alpha", null); + data.setTranslation("nested.long.bravo", null); + + Assert.assertEquals(data.getFullKeys().size(), 0); + Assert.assertNull(data.getTranslation("alpha")); + Assert.assertNull(data.getTranslation("nested.alpha")); + Assert.assertNull(data.getTranslation("nested.long.bravo")); + } + + @Test + public void testRecurseDeleteNonNested() throws Exception { + TranslationData data = new TranslationData(true, false); + this.addTranslations(data); + + data.setTranslation("foxtrot.super.long.key", null); + + Assert.assertNull(data.getTranslation("foxtrot.super.long.key")); + Assert.assertNull(data.getRootNode().getChildren().get("foxtrot")); + } + + @Test + public void testRecurseDeleteNested() throws Exception { + TranslationData data = new TranslationData(true, true); + this.addTranslations(data); + + data.setTranslation("foxtrot.super.long.key", null); + + Assert.assertNull(data.getTranslation("foxtrot.super.long.key")); + Assert.assertNull(data.getRootNode().getChildren().get("foxtrot")); + } + + @Test + public void testOverwriteNonNested() throws Exception { + TranslationData data = new TranslationData(true, false); + + Translation before = new TranslationBuilder("en", "before").build(); + Translation after = new TranslationBuilder("en", "after").build(); + + data.setTranslation("alpha", before); + data.setTranslation("nested.alpha", before); + data.setTranslation("nested.long.bravo", before); + + Assert.assertEquals(data.getTranslation("alpha"), before); + Assert.assertEquals(data.getTranslation("alpha"), before); + Assert.assertEquals(data.getTranslation("alpha"), before); + + data.setTranslation("alpha", after); + data.setTranslation("nested.alpha", after); + data.setTranslation("nested.long.bravo", after); + + Assert.assertEquals(data.getTranslation("alpha"), after); + Assert.assertEquals(data.getTranslation("alpha"), after); + Assert.assertEquals(data.getTranslation("alpha"), after); + } + + @Test + public void testOverwriteNested() throws Exception { + TranslationData data = new TranslationData(true, true); + + Translation before = new TranslationBuilder("en", "before").build(); + Translation after = new TranslationBuilder("en", "after").build(); + + data.setTranslation("alpha", before); + data.setTranslation("nested.alpha", before); + data.setTranslation("nested.long.bravo", before); + + Assert.assertEquals(data.getTranslation("alpha"), before); + Assert.assertEquals(data.getTranslation("alpha"), before); + Assert.assertEquals(data.getTranslation("alpha"), before); + + data.setTranslation("alpha", after); + data.setTranslation("nested.alpha", after); + data.setTranslation("nested.long.bravo", after); + + Assert.assertEquals(data.getTranslation("alpha"), after); + Assert.assertEquals(data.getTranslation("alpha"), after); + Assert.assertEquals(data.getTranslation("alpha"), after); + } + + @Test + public void testRecurseTransformNested() throws Exception { + TranslationData data = new TranslationData(true, true); + + Translation value = new TranslationBuilder("en", "test").build(); + + data.setTranslation("alpha.nested.key", value); + data.setTranslation("alpha.other", value); + data.setTranslation("bravo", value); + + Assert.assertEquals(data.getFullKeys().size(), 3); + + data.setTranslation("alpha.nested", value); + data.setTranslation("alpha.other.new", value); + data.setTranslation("bravo", null); + + Assert.assertEquals(data.getFullKeys().size(), 2); + Assert.assertNull(data.getTranslation("alpha.nested.key")); + Assert.assertNull(data.getTranslation("alpha.other")); + Assert.assertNull(data.getTranslation("bravo")); + Assert.assertEquals(data.getTranslation("alpha.nested"), value); + Assert.assertEquals(data.getTranslation("alpha.other.new"), value); + } + + @Test + public void testRecurseTransformNonNested() throws Exception { + TranslationData data = new TranslationData(true, false); + + Translation value = new TranslationBuilder("en", "test").build(); + + data.setTranslation("alpha.nested.key", value); + data.setTranslation("alpha.other", value); + data.setTranslation("bravo", value); + + Assert.assertEquals(data.getFullKeys().size(), 3); + + data.setTranslation("alpha.nested", value); + data.setTranslation("alpha.other.new", value); + data.setTranslation("bravo", null); + + Assert.assertEquals(data.getFullKeys().size(), 4); + Assert.assertNull(data.getTranslation("bravo")); + Assert.assertEquals(data.getTranslation("alpha.nested.key"), value); + Assert.assertEquals(data.getTranslation("alpha.other"), value); + Assert.assertEquals(data.getTranslation("alpha.nested"), value); + Assert.assertEquals(data.getTranslation("alpha.other.new"), value); + } +} \ No newline at end of file