implement new translation data holder with optimized tree structure

This commit is contained in:
Marcel Haßlinger 2021-11-02 16:45:39 +01:00
parent 2b36b355e4
commit 594fc82be7
6 changed files with 675 additions and 0 deletions

View File

@ -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<String, String> {
public Translation() {
super();
}
@Override
public String toString() {
return super.toString();
}
}

View File

@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> getFullKeys(String parentPath, TranslationNode node) {
Set<String> keys = new LinkedHashSet<>();
if(node.isLeaf()) { // This node does not lead to child's - just add the key
keys.add(parentPath);
}
for(Map.Entry<String, TranslationNode> 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 +
'}';
}
}

View File

@ -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<String, TranslationNode> 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<String, TranslationNode> 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<String, TranslationNode> 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 +
'}';
}
}

View File

@ -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<String> 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<String> 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 +
'}';
}
}

View File

@ -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;
}
}

View File

@ -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<String> 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<String> 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);
}
}