Add NLS support for IntelliJ

- Add .nls file editor
- Add nls keys completion contributor for Java, JavaScript & HTML
- Add documentation provider for nls keys
- Add quick fix for missing translation inspection
diff --git a/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/BindingArrayTypeWithEcj.java b/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/BindingArrayTypeWithEcj.java
index 689d103..a00476e 100644
--- a/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/BindingArrayTypeWithEcj.java
+++ b/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/BindingArrayTypeWithEcj.java
@@ -12,6 +12,7 @@
 
 import static java.util.Collections.emptyList;
 import static java.util.Collections.singletonList;
+import static org.eclipse.scout.sdk.core.util.Strings.repeat;
 
 import java.util.List;
 
@@ -31,7 +32,6 @@
 import org.eclipse.scout.sdk.core.model.spi.TypeSpi;
 import org.eclipse.scout.sdk.core.util.Ensure;
 import org.eclipse.scout.sdk.core.util.FinalValue;
-import org.eclipse.scout.sdk.core.util.Strings;
 
 /**
  * <h3>{@link BindingArrayTypeWithEcj}</h3>
@@ -110,7 +110,7 @@
       String componentTypeName = getLeafComponentType().getName();
       StringBuilder b = new StringBuilder(componentTypeName.length() + (2 * m_arrayDimension));
       b.append(componentTypeName);
-      b.append(Strings.repeat("[]", m_arrayDimension));
+      b.append(repeat("[]", m_arrayDimension));
       return b.toString();
     });
   }
diff --git a/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/JavaEnvironmentWithEcjBuilder.java b/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/JavaEnvironmentWithEcjBuilder.java
index 91cd760..70edf30 100644
--- a/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/JavaEnvironmentWithEcjBuilder.java
+++ b/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/JavaEnvironmentWithEcjBuilder.java
@@ -338,8 +338,7 @@
       return false;
     }
 
-    //noinspection resource
-    String s = Strings.replace(f.toString(), f.getFileSystem().getSeparator(), "/");
+    CharSequence s = f.toString().replace(File.separatorChar, '/');
     for (Pattern p : exclusions) {
       if (p.matcher(s).matches()) {
         return true;
diff --git a/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/JreInfo.java b/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/JreInfo.java
index 51767af..28942ba 100644
--- a/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/JreInfo.java
+++ b/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/JreInfo.java
@@ -13,6 +13,7 @@
 import static java.util.Collections.emptyList;
 import static java.util.Collections.unmodifiableList;
 import static java.util.stream.Collectors.toList;
+import static org.eclipse.scout.sdk.core.util.Strings.withoutQuotes;
 
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
@@ -141,15 +142,9 @@
       if (Strings.isBlank(line)) {
         continue;
       }
-
       if (line.toUpperCase(Locale.ENGLISH).startsWith(prefix)) {
-        String value = line.substring(prefix.length()).trim();
+        String value = withoutQuotes(line.substring(prefix.length()).trim()).toString();
         if (value.length() > 0) {
-          // strip quotes
-          if (value.length() > 2 && value.charAt(0) == '"' && value.charAt(value.length() - 1) == '"') {
-            value = value.substring(1, value.length() - 1);
-          }
-
           return parseVersion(value);
         }
       }
diff --git a/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/SourcePositionComparators.java b/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/SourcePositionComparators.java
index 22d5e70..e472f8c 100644
--- a/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/SourcePositionComparators.java
+++ b/org.eclipse.scout.sdk.core.ecj/src/main/java/org/eclipse/scout/sdk/core/model/ecj/SourcePositionComparators.java
@@ -73,10 +73,6 @@
 
     static int getSourcePosition(MethodBinding mb) {
       MethodBinding methodBinding = SpiWithEcjUtils.nvl(mb.original(), mb);
-      if (methodBinding == null) {
-        return UNKNOWN_SOURCE_POS;
-      }
-
       AbstractMethodDeclaration decl = SpiWithEcjUtils.sourceMethodOf(methodBinding);
       if (decl == null) {
         return UNKNOWN_SOURCE_POS;
@@ -102,10 +98,6 @@
 
     static int getSourcePosition(FieldBinding fb) {
       FieldBinding fieldBinding = SpiWithEcjUtils.nvl(fb.original(), fb);
-      if (fieldBinding == null) {
-        return UNKNOWN_SOURCE_POS;
-      }
-
       FieldDeclaration decl = fieldBinding.sourceField();
       if (decl == null) {
         return UNKNOWN_SOURCE_POS;
diff --git a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/dto/FormDataOrderTest.java b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/dto/FormDataOrderTest.java
index eb58484..8bbd5ec 100644
--- a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/dto/FormDataOrderTest.java
+++ b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/dto/FormDataOrderTest.java
@@ -23,10 +23,10 @@
 
   @Test
   public void testSortOrderStabilityOfFormData() {
-    createFormDataAssertNoCompileErrors(SimpleForm.class.getName(), this::compareWithRefFile);
+    createFormDataAssertNoCompileErrors(SimpleForm.class.getName(), FormDataOrderTest::compareWithRefFile);
   }
 
-  protected void compareWithRefFile(IType createdFormData) {
-    assertEqualsRefFile(REF_FILE_FOLDER + "FormDataOrder.txt", createdFormData.requireCompilationUnit().source().get().asCharSequence().toString());
+  protected static void compareWithRefFile(IType createdFormData) {
+    assertEqualsRefFile(REF_FILE_FOLDER + "FormDataOrder.txt", createdFormData.requireCompilationUnit().source().get().asCharSequence());
   }
 }
diff --git a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/jaxws/JaxWsModuleNewHelperTest.java b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/jaxws/JaxWsModuleNewHelperTest.java
index bb41e03..8770495 100644
--- a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/jaxws/JaxWsModuleNewHelperTest.java
+++ b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/jaxws/JaxWsModuleNewHelperTest.java
@@ -12,6 +12,7 @@
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
+import java.io.File;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
@@ -21,7 +22,6 @@
 import org.eclipse.scout.sdk.core.s.util.maven.IMavenConstants;
 import org.eclipse.scout.sdk.core.util.CoreUtils;
 import org.eclipse.scout.sdk.core.util.SdkException;
-import org.eclipse.scout.sdk.core.util.Strings;
 import org.junit.jupiter.api.Test;
 
 /**
@@ -58,7 +58,7 @@
       }
 
       Path relPath = dir.relativize(result);
-      assertEquals(expectedPath, Strings.replace(relPath.toString(), relPath.getFileSystem().getSeparator(), "/"));
+      assertEquals(expectedPath, relPath.toString().replace(File.separatorChar, '/'));
     }
     finally {
       CoreUtils.deleteDirectory(dir);
diff --git a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/TranslationStoreStackTest.java b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/TranslationStoreStackTest.java
index 2361942..eb3b9c3 100644
--- a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/TranslationStoreStackTest.java
+++ b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/TranslationStoreStackTest.java
@@ -10,6 +10,11 @@
  */
 package org.eclipse.scout.sdk.core.s.nls;
 
+import static java.util.Arrays.asList;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.emptyMap;
+import static java.util.Collections.singleton;
+import static java.util.Collections.singletonList;
 import static org.eclipse.scout.sdk.core.s.nls.TranslationStoreSupplierExtension.testingStack;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -17,12 +22,12 @@
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
@@ -101,7 +106,7 @@
   @Test
   public void testAddNewLanguage(TestingEnvironment env) {
     TranslationStoreStack stack = testingStack(env);
-    ITranslationStore newStore = createMockStore();
+    ITranslationStore newStore = createSimpleStoreMock();
     Language deCh = Language.parseThrowingOnError("de_CH");
     stack.addNewLanguage(deCh, stack.primaryEditableStore().get());
     assertEquals(4, stack.allEditableLanguages().count());
@@ -113,6 +118,50 @@
   }
 
   @Test
+  public void testEventsOnKeyChange(TestingEnvironment env) {
+    String initialKey = "startKey";
+    String changedKey = TranslationStoreSupplierExtension.TRANSLATION_KEY_1;
+
+    TranslationStoreStack stack = testingStack(env);
+    stack.removeTranslations(Stream.of(changedKey));
+    Translation t = new Translation(initialKey);
+    t.putText(Language.LANGUAGE_DEFAULT, "whatever");
+    stack.addNewTranslation(t);
+
+    List<TranslationStoreStackEvent> eventProtocol = new ArrayList<>();
+    stack.addListener(events -> events.forEach(eventProtocol::add));
+
+    stack.changeKey(initialKey, changedKey);
+    stack.changeKey(changedKey, initialKey);
+
+    assertEquals(4, eventProtocol.size());
+    assertEquals(TranslationStoreStackEvent.TYPE_KEY_CHANGED, eventProtocol.get(0).type()); // for the key change
+    assertEquals(TranslationStoreStackEvent.TYPE_REMOVE_TRANSLATION, eventProtocol.get(1).type()); // for the one that is now overridden
+    assertEquals(TranslationStoreStackEvent.TYPE_KEY_CHANGED, eventProtocol.get(2).type()); // for the key change
+    assertEquals(TranslationStoreStackEvent.TYPE_NEW_TRANSLATION, eventProtocol.get(3).type()); // for the one that is no longer overridden and is therefore visible again
+  }
+
+  @Test
+  public void testEventsOnDeleteAndCreateTranslationOverridingExisting(TestingEnvironment env) {
+    TranslationStoreStack stack = testingStack(env);
+    List<TranslationStoreStackEvent> eventProtocol = new ArrayList<>();
+    stack.addListener(events -> events.forEach(eventProtocol::add));
+
+    stack.removeTranslations(Stream.of(TranslationStoreSupplierExtension.TRANSLATION_KEY_1));
+
+    String newValue = "newVal";
+    Translation t = new Translation(TranslationStoreSupplierExtension.TRANSLATION_KEY_1);
+    t.putText(Language.LANGUAGE_DEFAULT, newValue);
+    stack.addNewTranslation(t);
+
+    assertEquals(4, eventProtocol.size());
+    assertEquals(TranslationStoreStackEvent.TYPE_NEW_TRANSLATION, eventProtocol.get(0).type()); // for the one that becomes visible after deleting the overriding one
+    assertEquals(TranslationStoreStackEvent.TYPE_REMOVE_TRANSLATION, eventProtocol.get(1).type()); // for the removal
+    assertEquals(TranslationStoreStackEvent.TYPE_REMOVE_TRANSLATION, eventProtocol.get(2).type()); // for the one that is no longer visible after creating the overriding one
+    assertEquals(TranslationStoreStackEvent.TYPE_NEW_TRANSLATION, eventProtocol.get(3).type()); // for the new one
+  }
+
+  @Test
   public void testAddNewTranslation(TestingEnvironment env) {
     TranslationStoreStack stack = testingStack(env);
     assertFalse(stack.isDirty());
@@ -127,7 +176,7 @@
     ITranslation existing = new Translation("key2");
     t2.putText(Language.LANGUAGE_DEFAULT, "def2");
 
-    ITranslationStore newStore = createMockStore();
+    ITranslationStore newStore = createSimpleStoreMock();
 
     assertThrows(IllegalArgumentException.class, () -> stack.addNewTranslation(null));
     assertThrows(IllegalArgumentException.class, () -> stack.addNewTranslation(new Translation("key")));
@@ -160,12 +209,17 @@
   @Test
   public void testChangeKey(TestingEnvironment env) {
     TranslationStoreStack stack = testingStack(env);
-    stack.changeKey("key1", "newKey1");
-    assertEquals("1_def", stack.translation("newKey1").get().text(Language.LANGUAGE_DEFAULT).get());
+    String newKey = "newKey1";
+    stack.changeKey(TranslationStoreSupplierExtension.TRANSLATION_KEY_1, newKey);
+    assertEquals(TranslationStoreSupplierExtension.KEY_1_VAL_DEFAULT, stack.translation(newKey).get().text(Language.LANGUAGE_DEFAULT).get());
+
+    // change to itself
+    stack.changeKey(newKey, newKey);
+    assertEquals(TranslationStoreSupplierExtension.KEY_1_VAL_DEFAULT, stack.translation(newKey).get().text(Language.LANGUAGE_DEFAULT).get());
 
     assertThrows(IllegalArgumentException.class, () -> stack.changeKey(null, "aaa"));
     assertThrows(IllegalArgumentException.class, () -> stack.changeKey("aa", null));
-    assertThrows(IllegalArgumentException.class, () -> stack.changeKey("aa", "key1"));
+    assertThrows(IllegalArgumentException.class, () -> stack.changeKey(TranslationStoreSupplierExtension.TRANSLATION_KEY_2, TranslationStoreSupplierExtension.TRANSLATION_KEY_3));
   }
 
   @Test
@@ -218,7 +272,199 @@
     assertEquals(2, stack.allEntries().count());
     assertEquals("text02", stack.translation(key).get().texts().get(Language.LANGUAGE_DEFAULT));
     assertEquals("text01", stack.translation(newKey).get().texts().get(Language.LANGUAGE_DEFAULT));
-    assertEquals(Arrays.asList(TranslationStoreStackEvent.TYPE_KEY_CHANGED, TranslationStoreStackEvent.TYPE_NEW_TRANSLATION), eventTypes);
+    assertEquals(asList(TranslationStoreStackEvent.TYPE_KEY_CHANGED, TranslationStoreStackEvent.TYPE_NEW_TRANSLATION), eventTypes);
+  }
+
+  @Test
+  public void testImportNoData() {
+    ITranslationStore firstStore = createStoreMock(100.00d, emptyMap());
+    TranslationStoreStack stack = new TranslationStoreStack(Stream.of(firstStore));
+    String keyColumnName = "Key";
+
+    // only header row
+    List<List<String>> data1 = singletonList(asList(Language.LANGUAGE_DEFAULT.displayName(), "it", keyColumnName));
+    ITranslationImportInfo info1 = stack.importTranslations(data1, keyColumnName, null);
+    assertEquals(ITranslationImportInfo.NO_DATA, info1.result());
+
+    // no rows
+    ITranslationImportInfo info2 = stack.importTranslations(emptyList(), keyColumnName, null);
+    assertEquals(ITranslationImportInfo.NO_DATA, info2.result());
+
+    // header row with empty columns
+    String emptyHeaderCell = "  ";
+    List<List<String>> data3 = asList(
+        asList(Language.LANGUAGE_DEFAULT.displayName(), emptyHeaderCell, keyColumnName),
+        asList("def", "esp", "xx"));
+
+    ITranslationImportInfo info3 = stack.importTranslations(data3, keyColumnName, null);
+    Map<Integer, String> expectedIgnoreColumns = new HashMap<>();
+    expectedIgnoreColumns.put(1, emptyHeaderCell);
+    assertEquals(expectedIgnoreColumns, info3.ignoredColumns());
+
+    // no valid rows
+    List<List<String>> data4 = asList(
+        asList(Language.LANGUAGE_DEFAULT.displayName(), "es", keyColumnName),
+        asList("", "esp", "xx"));
+    ITranslationImportInfo info4 = stack.importTranslations(data4, keyColumnName, null);
+    assertEquals(ITranslationImportInfo.NO_DATA, info4.result());
+  }
+
+  @Test
+  public void testImportIgnoringEmptyCells() {
+    ITranslationStore store = createStoreMock(100.00d, emptyMap());
+    TranslationStoreStack stack = new TranslationStoreStack(Stream.of(store));
+    String keyColumnName = "Key";
+    String key1 = "k1";
+    String key2 = "k2";
+    String key3 = "k3";
+    List<List<String>> data = asList(
+        asList(Language.LANGUAGE_DEFAULT.displayName(), "es", keyColumnName),
+        asList("def1", "", key1),
+        asList("def2", null, key2),
+        asList("def3", "content", key3));
+    ITranslationImportInfo info = stack.importTranslations(data, keyColumnName, null);
+    assertEquals(3, info.result());
+    Language es = Language.parseThrowingOnError("es");
+    assertFalse(store.get(key1, es).isPresent());
+    assertFalse(store.get(key2, es).isPresent());
+    assertTrue(store.get(key3, es).isPresent());
+  }
+
+  @Test
+  public void testImportWithIncompleteData() {
+    ITranslationStore store = createStoreMock(100.00d, emptyMap());
+    TranslationStoreStack stack = new TranslationStoreStack(Stream.of(store));
+    String keyColumnName = "Key";
+    String validRowKey = "key02";
+    List<List<String>> data = asList(
+        asList("de", keyColumnName, Language.LANGUAGE_DEFAULT.displayName(), "fr"),
+        emptyList(), // no columns at all
+        singletonList("d1"), // no column for key
+        asList("d2", "key01"), // no column for default lang
+        asList("d3", validRowKey, "def")); // no column for language fr
+    ITranslationImportInfo info = stack.importTranslations(data, keyColumnName, null);
+
+    assertEquals(1, info.result());
+    assertEquals(1, info.importedTranslations().size());
+    assertTrue(info.importedTranslations().containsKey(validRowKey));
+    assertEquals(asList(2, 3), info.invalidRowIndices());
+  }
+
+  @Test
+  public void testImportEmptyColumn() {
+    ITranslationStore store = createStoreMock(100.00d, emptyMap());
+    TranslationStoreStack stack = new TranslationStoreStack(Stream.of(store));
+    String keyColumnName = "Key";
+    List<List<String>> data = asList(
+        asList(keyColumnName, " ", "default", "en"),
+        emptyList(), // empty row
+        asList("k0", "\t", "en0"),
+        asList("k1", "", "en1"),
+        asList("k2", " ", "en2")); // no column for language fr
+    ITranslationImportInfo info = stack.importTranslations(data, keyColumnName, null);
+    assertEquals(3, info.result());
+    assertEquals(3, info.importedTranslations().size());
+    assertTrue(info.ignoredColumns().isEmpty());
+    assertTrue(info.invalidRowIndices().isEmpty());
+    assertTrue(info.duplicateKeys().isEmpty());
+  }
+
+  @Test
+  public void testImport() {
+    ITranslationStore firstStore = createStoreMock(100.00d, emptyMap());
+    ITranslationStore secondStore = createStoreMock(200.00d, emptyMap());
+    TranslationStoreStack stack = new TranslationStoreStack(Stream.of(firstStore, secondStore));
+    String keyColumnName = "asdf";
+    String key01 = "key01";
+    String key02 = "key02???**";
+    String key03 = "key03";
+    String key04 = "key04";
+
+    List<List<String>> data1 = asList(
+        asList(Language.LANGUAGE_DEFAULT.displayName(), "es", keyColumnName),
+        asList("def", "esp", key01));
+    ITranslationImportInfo info1 = stack.importTranslations(data1, keyColumnName, null);
+    assertEquals(1, info1.result());
+    assertEquals(1, info1.importedTranslations().size());
+    assertTrue(info1.importedTranslations().containsKey(key01));
+    assertEquals(0, info1.defaultLanguageColumnIndex());
+    assertEquals(2, info1.keyColumnIndex());
+    assertTrue(info1.ignoredColumns().isEmpty());
+    assertTrue(info1.duplicateKeys().isEmpty());
+    assertTrue(info1.invalidRowIndices().isEmpty());
+    assertEquals(1, firstStore.keys().count());
+    assertTrue(firstStore.containsKey(key01));
+    assertEquals(2, firstStore.languages().count());
+
+    String invalidColumnName = "_invalid_lang_";
+    String newKey01DefaultText = "def1";
+    String newKey04DefaultText = "def4_4";
+    List<List<String>> data2 = asList(
+        asList(Language.LANGUAGE_DEFAULT.displayName(), "fr", keyColumnName, invalidColumnName),
+        asList(newKey01DefaultText, "fr1", key01, "xx"), // update existing
+        asList("def2", "fr1", key02, "xx"), // invalid row by invalid key
+        asList("def0", "fr1", "", "xx"), // invalid row by missing key
+        asList("", "fr1", key03, "xx"), // invalid row by missing default lang
+        asList("def4", "fr2", key04, "yy"), // new row (ignored duplicate)
+        asList(newKey04DefaultText, "fr2_2", key04, "yy")); // new row (winning duplicate)
+    ITranslationImportInfo info2 = stack.importTranslations(data2, keyColumnName, secondStore);
+    assertEquals(2, info2.result());
+    assertEquals(2, info2.importedTranslations().size());
+    assertTrue(info2.importedTranslations().containsKey(key01));
+    assertTrue(info2.importedTranslations().containsKey(key04));
+    assertEquals(0, info2.defaultLanguageColumnIndex());
+    assertEquals(2, info2.keyColumnIndex());
+    Map<Integer, String> expectedIgnoreColumns = new HashMap<>();
+    expectedIgnoreColumns.put(3, invalidColumnName);
+    assertEquals(expectedIgnoreColumns, info2.ignoredColumns());
+    assertEquals(singleton(key04), info2.duplicateKeys());
+    assertEquals(asList(2, 3, 4), info2.invalidRowIndices());
+    assertEquals(1, firstStore.keys().count());
+    assertTrue(firstStore.containsKey(key01));
+    assertEquals(newKey01DefaultText, firstStore.get(key01, Language.LANGUAGE_DEFAULT).get());
+    assertEquals(3, firstStore.languages().count());
+    assertEquals(1, secondStore.keys().count());
+    assertTrue(secondStore.containsKey(key04));
+    assertEquals(newKey04DefaultText, secondStore.get(key04, Language.LANGUAGE_DEFAULT).get());
+    assertEquals(2, secondStore.languages().count());
+  }
+
+  @Test
+  public void testImportNoDefaultLangColumn() {
+    ITranslationStore firstStore = createStoreMock(100.00d, emptyMap());
+    ITranslationStore secondStore = createStoreMock(200.00d, emptyMap());
+    TranslationStoreStack stack = new TranslationStoreStack(Stream.of(firstStore, secondStore));
+    String keyColumnName = "key";
+    List<List<String>> data = asList(
+        asList("xx", "es", keyColumnName),
+        asList("def", "esp", "yy"));
+    ITranslationImportInfo info1 = stack.importTranslations(data, keyColumnName, null);
+    assertEquals(ITranslationImportInfo.NO_KEY_OR_DEFAULT_LANG_COLUMN, info1.result());
+    assertEquals(0, info1.importedTranslations().size());
+    assertEquals(-1, info1.defaultLanguageColumnIndex());
+    assertEquals(-1, info1.keyColumnIndex());
+    assertTrue(info1.ignoredColumns().isEmpty());
+    assertTrue(info1.duplicateKeys().isEmpty());
+    assertTrue(info1.invalidRowIndices().isEmpty());
+  }
+
+  @Test
+  public void testImportNoKeyColumn() {
+    ITranslationStore firstStore = createStoreMock(100.00d, emptyMap());
+    ITranslationStore secondStore = createStoreMock(200.00d, emptyMap());
+    TranslationStoreStack stack = new TranslationStoreStack(Stream.of(firstStore, secondStore));
+
+    List<List<String>> data = asList(
+        asList("xx", "es", Language.LANGUAGE_DEFAULT.displayName()),
+        asList("def", "esp", "yy"));
+    ITranslationImportInfo info1 = stack.importTranslations(data, "key", null);
+    assertEquals(ITranslationImportInfo.NO_KEY_OR_DEFAULT_LANG_COLUMN, info1.result());
+    assertEquals(0, info1.importedTranslations().size());
+    assertEquals(-1, info1.defaultLanguageColumnIndex());
+    assertEquals(-1, info1.keyColumnIndex());
+    assertTrue(info1.ignoredColumns().isEmpty());
+    assertTrue(info1.duplicateKeys().isEmpty());
+    assertTrue(info1.invalidRowIndices().isEmpty());
   }
 
   /**
@@ -321,13 +567,33 @@
       }
       return Optional.empty();
     });
+    when(mock.get(anyString(), any())).then(invocation -> {
+      String key = invocation.getArgument(0);
+      Language lang = invocation.getArgument(1);
+      return allEntries.stream().filter(e -> e.key().equals(key)).findFirst().flatMap(e -> e.text(lang));
+    });
     when(mock.containsKey(anyString())).then(invocation -> mock.get(invocation.<String> getArgument(0)).isPresent());
+    when(mock.keys()).thenAnswer(invocation -> allEntries.stream().map(ITranslation::key));
+    when(mock.languages()).thenAnswer(invocation -> allEntries.stream().flatMap(e -> e.texts().keySet().stream()).distinct());
     when(mock.service()).thenReturn(svc);
     when(mock.entries()).then(invocation -> allEntries.stream());
+    when(mock.addNewTranslation(any())).then(invocation -> {
+      ITranslation add = invocation.getArgument(0);
+      ITranslationEntry newEntry = new TranslationEntry(add, mock);
+      allEntries.add(newEntry);
+      return newEntry;
+    });
+    when(mock.updateTranslation(any())).then(invocation -> {
+      ITranslation update = invocation.getArgument(0);
+      ITranslationEntry newEntry = new TranslationEntry(update, mock);
+      allEntries.removeIf(entry -> entry.key().equals(update.key()));
+      allEntries.add(newEntry);
+      return newEntry;
+    });
     return mock;
   }
 
-  private static ITranslationStore createMockStore() {
+  private static ITranslationStore createSimpleStoreMock() {
     IType type = mock(IType.class);
     when(type.name()).thenReturn("test.type");
 
diff --git a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/TranslationStoreSupplierExtension.java b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/TranslationStoreSupplierExtension.java
index 64db70b..f563bc7 100644
--- a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/TranslationStoreSupplierExtension.java
+++ b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/TranslationStoreSupplierExtension.java
@@ -15,6 +15,7 @@
 import static org.eclipse.scout.sdk.core.s.nls.TranslationStores.registerUiTextContributor;
 import static org.eclipse.scout.sdk.core.s.nls.TranslationStores.removeStoreSupplier;
 import static org.eclipse.scout.sdk.core.s.nls.TranslationStores.removeUiTextContributor;
+import static org.eclipse.scout.sdk.core.s.nls.properties.AbstractTranslationPropertiesFile.parseLanguageFromFileName;
 import static org.eclipse.scout.sdk.core.util.Ensure.newFail;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.spy;
@@ -34,6 +35,7 @@
 import java.util.Properties;
 import java.util.stream.Stream;
 
+import org.eclipse.scout.rt.security.ScoutSecurityTextProviderService;
 import org.eclipse.scout.rt.shared.services.common.text.ScoutTextProviderService;
 import org.eclipse.scout.sdk.core.log.SdkLog;
 import org.eclipse.scout.sdk.core.model.api.IJavaEnvironment;
@@ -41,6 +43,7 @@
 import org.eclipse.scout.sdk.core.s.environment.IEnvironment;
 import org.eclipse.scout.sdk.core.s.environment.IProgress;
 import org.eclipse.scout.sdk.core.s.environment.NullProgress;
+import org.eclipse.scout.sdk.core.s.nls.properties.AbstractTranslationPropertiesFile;
 import org.eclipse.scout.sdk.core.s.nls.properties.EditableTranslationFile;
 import org.eclipse.scout.sdk.core.s.nls.properties.ITranslationPropertiesFile;
 import org.eclipse.scout.sdk.core.s.nls.properties.PropertiesTextProviderService;
@@ -59,6 +62,7 @@
  */
 public class TranslationStoreSupplierExtension implements BeforeEachCallback, AfterEachCallback {
 
+  public static final String PROPERTIES_FILE_NAME_PREFIX = "Prefix";
   public static final String TEST_DEPENDENCY_NAME = "@bsi-sdk/testdependency";
   public static final String TEST_UI_CONTRIBUTOR_FQN = "nls.TestUiTextContributor";
 
@@ -110,7 +114,7 @@
   }
 
   public static PropertiesTranslationStore createReadOnlyStore(IEnvironment env) {
-    return createTestingStore(env, true, null);
+    return createTestingStore(env, true, ScoutTextProviderService.class.getName(), null);
   }
 
   public static PropertiesTranslationStore createEmptyStore(IJavaEnvironment env) {
@@ -121,8 +125,8 @@
     return newFail("No translation store supplier available. Ensure the '{}' extension is registered in the unit test.", TranslationStoreSupplierExtension.class.getName());
   }
 
-  protected static PropertiesTranslationStore createTestingStore(IEnvironment env, boolean readOnly, Path dir) {
-    IType txtSvcType = ((TestingEnvironment) env).primaryEnvironment().requireType(ScoutTextProviderService.class.getName());
+  protected static PropertiesTranslationStore createTestingStore(IEnvironment env, boolean readOnly, String svcFqn, Path dir) {
+    IType txtSvcType = ((TestingEnvironment) env).primaryEnvironment().requireType(svcFqn);
     return createTestingStore(txtSvcType, readOnly, dir);
   }
 
@@ -152,9 +156,9 @@
         when(txtSvc.order()).thenReturn(10000.0);
       }
       else {
-        translationFiles.add(createTranslationFile(directory.resolve("Prefix.properties"), def));
-        translationFiles.add(createTranslationFile(directory.resolve("Prefix_en_US.properties"), en));
-        translationFiles.add(createTranslationFile(directory.resolve("Prefix_es.properties"), null));
+        translationFiles.add(createTranslationFile(directory.resolve(PROPERTIES_FILE_NAME_PREFIX + AbstractTranslationPropertiesFile.FILE_SUFFIX), def));
+        translationFiles.add(createTranslationFile(directory.resolve(PROPERTIES_FILE_NAME_PREFIX + "_en_US" + AbstractTranslationPropertiesFile.FILE_SUFFIX), en));
+        translationFiles.add(createTranslationFile(directory.resolve(PROPERTIES_FILE_NAME_PREFIX + "_es" + AbstractTranslationPropertiesFile.FILE_SUFFIX), null));
       }
     }
     catch (IOException e) {
@@ -172,7 +176,8 @@
       }
     }
 
-    EditableTranslationFile props = new EditableTranslationFile(file);
+    Language language = parseLanguageFromFileName(file.getFileName().toString(), PROPERTIES_FILE_NAME_PREFIX).get();
+    EditableTranslationFile props = new EditableTranslationFile(file, language);
     assertTrue(props.load(new NullProgress()));
     return props;
   }
@@ -188,8 +193,8 @@
     @Override
     public Stream<ITranslationStore> all(Path modulePath, IEnvironment env, IProgress progress) {
       return Stream.of(
-          createTestingStore(env, false, m_dir),
-          createTestingStore(env, true, m_dir));
+          createTestingStore(env, false, ScoutTextProviderService.class.getName(), m_dir),
+          createTestingStore(env, true, ScoutSecurityTextProviderService.class.getName(), m_dir));
     }
 
     @Override
diff --git a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/properties/PropertiesTextProviderServiceTest.java b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/properties/PropertiesTextProviderServiceTest.java
index 09f0e13..2174705 100644
--- a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/properties/PropertiesTextProviderServiceTest.java
+++ b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/properties/PropertiesTextProviderServiceTest.java
@@ -10,7 +10,6 @@
  */
 package org.eclipse.scout.sdk.core.s.nls.properties;
 
-import static org.eclipse.scout.sdk.core.s.nls.properties.PropertiesTextProviderService.resourceMatchesPrefix;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
@@ -52,22 +51,6 @@
   }
 
   @Test
-  @SuppressWarnings("ConstantConditions")
-  public void testResourceMatchesPrefix() {
-    assertFalse(resourceMatchesPrefix("MyName.properties", "Text"));
-    assertFalse(resourceMatchesPrefix("MyName.properties", null));
-    assertFalse(resourceMatchesPrefix(null, "Text"));
-
-    assertTrue(resourceMatchesPrefix("Text.properties", "Text"));
-    assertTrue(resourceMatchesPrefix("Text_en.properties", "Text"));
-    assertTrue(resourceMatchesPrefix("Text_en_GB.properties", "Text"));
-    assertTrue(resourceMatchesPrefix("Text_en_GB_ll.properties", "Text"));
-    assertFalse(resourceMatchesPrefix("Text_en_GB_ll_dd.properties", "Text"));
-
-    assertFalse(resourceMatchesPrefix("Text_en_GB_ll.js", "Text"));
-  }
-
-  @Test
   public void testResourceBundleGetterRegex() {
     assertTrue(PropertiesTextProviderService.REGEX_RESOURCE_BUNDLE_GETTER.matcher("return \"abc.def\";").matches());
     assertTrue(PropertiesTextProviderService.REGEX_RESOURCE_BUNDLE_GETTER.matcher("return\t\"abc.def\"\t;").matches());
diff --git a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/properties/TranslationPropertiesFileTest.java b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/properties/TranslationPropertiesFileTest.java
index 94474f0..0e0e73c 100644
--- a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/properties/TranslationPropertiesFileTest.java
+++ b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/nls/properties/TranslationPropertiesFileTest.java
@@ -10,14 +10,20 @@
  */
 package org.eclipse.scout.sdk.core.s.nls.properties;
 
+import static java.util.stream.Collectors.toList;
 import static org.eclipse.scout.sdk.core.s.nls.TranslationStoreSupplierExtension.testingStore;
+import static org.eclipse.scout.sdk.core.s.nls.properties.AbstractTranslationPropertiesFile.getPropertiesFileName;
+import static org.eclipse.scout.sdk.core.s.nls.properties.AbstractTranslationPropertiesFile.parseLanguageFromFileName;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.util.List;
 import java.util.Locale;
+import java.util.stream.Stream;
 
 import org.eclipse.scout.sdk.core.s.environment.NullProgress;
 import org.eclipse.scout.sdk.core.s.nls.Language;
@@ -27,6 +33,7 @@
 import org.eclipse.scout.sdk.core.s.testing.context.TestingEnvironment;
 import org.eclipse.scout.sdk.core.s.testing.context.TestingEnvironmentExtension;
 import org.eclipse.scout.sdk.core.testing.context.ExtendWithJavaEnvironmentFactory;
+import org.eclipse.scout.sdk.core.util.Strings;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 
@@ -70,12 +77,38 @@
   }
 
   @Test
+  public void testParseWithAllLocales() {
+    String prefix = "prefix";
+    String[] availableLocaleNames = Stream.of(Locale.getAvailableLocales())
+        .map(Locale::toString)
+        .toArray(String[]::new);
+    List<Language> languages = Stream.of(availableLocaleNames)
+        .map(locale -> Strings.notBlank(locale).orElseGet(() -> Language.LANGUAGE_DEFAULT.locale().toString()))
+        .map(Language::parseThrowingOnError)
+        .collect(toList());
+
+    List<Language> parsedLanguages = languages.stream()
+        .map(lang -> getPropertiesFileName(prefix, lang))
+        .map(fileName -> parseLanguageFromFileName(fileName, prefix).get())
+        .collect(toList());
+    String[] parsedLocaleNames = parsedLanguages.stream()
+        .map(lang -> lang == Language.LANGUAGE_DEFAULT ? Locale.ROOT : lang.locale())
+        .map(Locale::toString)
+        .toArray(String[]::new);
+
+    assertEquals(languages, parsedLanguages);
+    assertArrayEquals(availableLocaleNames, parsedLocaleNames);
+  }
+
+  @Test
   public void testParse() {
-    assertSame(Language.LANGUAGE_DEFAULT, AbstractTranslationPropertiesFile.parseFromFileNameOrThrow("prefix.properties"));
-    assertEquals(new Language(new Locale("test")), AbstractTranslationPropertiesFile.parseFromFileNameOrThrow("prefix_test.properties"));
-    assertEquals(new Language(new Locale("de", "FR", "xx")), AbstractTranslationPropertiesFile.parseFromFileNameOrThrow("prefix_de_FR_xx.properties"));
-    assertEquals(new Language(new Locale("de", "FR")), AbstractTranslationPropertiesFile.parseFromFileNameOrThrow("prefix_de_FR.properties"));
-    assertEquals(new Language(new Locale("de")), AbstractTranslationPropertiesFile.parseFromFileNameOrThrow("prefix_de.properties"));
-    assertThrows(IllegalArgumentException.class, () -> AbstractTranslationPropertiesFile.parseFromFileNameOrThrow("abc"));
+    assertSame(Language.LANGUAGE_DEFAULT, parseLanguageFromFileName("prefix.properties", "prefix").get());
+    assertEquals(new Language(new Locale("test")), parseLanguageFromFileName("prefix_test.properties", "prefix").get());
+    assertEquals(new Language(new Locale("de", "FR", "xx")), parseLanguageFromFileName("prefix_de_FR_xx.properties", "prefix").get());
+    assertEquals(new Language(new Locale("de", "FR")), parseLanguageFromFileName("prefix_de_FR.properties", "prefix").get());
+    assertEquals(new Language(new Locale("de")), parseLanguageFromFileName("prefix_de.properties", "prefix").get());
+    assertFalse(parseLanguageFromFileName("abc", "abc").isPresent());
+    assertFalse(parseLanguageFromFileName(null, "").isPresent());
+    assertFalse(parseLanguageFromFileName("prefix_test.properties", "text").isPresent());
   }
 }
diff --git a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/page/PageGeneratorTest.java b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/page/PageGeneratorTest.java
index a58a080..aed20b3 100644
--- a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/page/PageGeneratorTest.java
+++ b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/page/PageGeneratorTest.java
@@ -60,7 +60,7 @@
       IType createdSvcIfc = assertNoCompileErrors(env, svcIfcGenerator);
 
       // page
-      PageGenerator<?> pageBuilder = new PageGenerator<>()
+      PageGenerator<?> pageGenerator = new PageGenerator<>()
           .withPackageName("org.eclipse.scout.sdk.core.s.test")
           .withElementName("MyTablePage" + i)
           .withClassIdValue("whatever")
@@ -72,8 +72,8 @@
           .withNlsMethod(i == 1)
           .withPageServiceInterface(createdSvcIfc.name());
 
-      assertEqualsRefFile(env, REF_FILE_FOLDER + "PageTest" + (i + 1) + ".txt", pageBuilder);
-      assertNoCompileErrors(env, pageBuilder);
+      assertEqualsRefFile(env, REF_FILE_FOLDER + "PageTest" + (i + 1) + ".txt", pageGenerator);
+      assertNoCompileErrors(env, pageGenerator);
     }
   }
 
@@ -81,14 +81,14 @@
   public void testPageWithNodes(IJavaEnvironment env) {
     for (int i = 0; i < 2; i++) {
       // page
-      PageGenerator<?> pageBuilder = new PageGenerator<>()
+      PageGenerator<?> pageGenerator = new PageGenerator<>()
           .withElementName("MyNodePage" + i)
           .withClassIdValue("whatever")
           .asPageWithTable(false)
           .withFlags(i == 1 ? Flags.AccAbstract : Flags.AccPublic);
 
-      assertEqualsRefFile(env, REF_FILE_FOLDER + "NodePageTest" + (i + 1) + ".txt", pageBuilder);
-      assertNoCompileErrors(env, pageBuilder);
+      assertEqualsRefFile(env, REF_FILE_FOLDER + "NodePageTest" + (i + 1) + ".txt", pageGenerator);
+      assertNoCompileErrors(env, pageGenerator);
     }
   }
 }
diff --git a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/project/ScoutProjectNewTest.java b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/project/ScoutProjectNewTest.java
index 93a1ed0..6e9f09c 100644
--- a/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/project/ScoutProjectNewTest.java
+++ b/org.eclipse.scout.sdk.core.s.test/src/test/java/org/eclipse/scout/sdk/core/s/project/ScoutProjectNewTest.java
@@ -16,11 +16,9 @@
 import java.nio.file.Path;
 
 import org.eclipse.scout.sdk.core.s.testing.CoreScoutTestingUtils;
-import org.eclipse.scout.sdk.core.s.testing.IntegrationTest;
 import org.eclipse.scout.sdk.core.util.CoreUtils;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
-import org.junit.experimental.categories.Category;
 
 /**
  * <h3>{@link ScoutProjectNewTest}</h3>
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/builder/java/body/IScoutMethodBodyBuilder.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/builder/java/body/IScoutMethodBodyBuilder.java
index 589eda5..926b884 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/builder/java/body/IScoutMethodBodyBuilder.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/builder/java/body/IScoutMethodBodyBuilder.java
@@ -31,7 +31,7 @@
 
   TYPE appendGetPropertyByClass(CharSequence propName);
 
-  TYPE appendTextsGet(String textKey);
+  TYPE appendTextsGet(CharSequence textKey);
 
   TYPE appendPermissionCheck(CharSequence permission);
 
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/builder/java/body/ScoutMethodBodyBuilder.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/builder/java/body/ScoutMethodBodyBuilder.java
index 21fb2f2..525dc81 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/builder/java/body/ScoutMethodBodyBuilder.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/builder/java/body/ScoutMethodBodyBuilder.java
@@ -51,7 +51,7 @@
   }
 
   @Override
-  public TYPE appendTextsGet(String textKey) {
+  public TYPE appendTextsGet(CharSequence textKey) {
     return ref(IScoutRuntimeTypes.TEXTS).dot().append("get").parenthesisOpen().stringLiteral(textKey).parenthesisClose();
   }
 
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/dto/AbstractDtoGenerator.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/dto/AbstractDtoGenerator.java
index 2d92952..466e24f 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/dto/AbstractDtoGenerator.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/dto/AbstractDtoGenerator.java
@@ -432,7 +432,7 @@
   @SuppressWarnings("squid:UnusedPrivateMethod") // used as method-reference
   private void addPropertyDto(PropertyBean desc) {
     String lowerCaseBeanName = Introspector.decapitalize(desc.name());
-    String upperCaseBeanName = Strings.ensureStartWithUpperCase(desc.name());
+    CharSequence upperCaseBeanName = Strings.ensureStartWithUpperCase(desc.name());
 
     String propName = upperCaseBeanName + ISdkProperties.SUFFIX_DTO_PROPERTY;
     String propDataType = desc.type().reference();
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/dto/CompositeFormDataGenerator.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/dto/CompositeFormDataGenerator.java
index a68bb72..509e457 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/dto/CompositeFormDataGenerator.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/dto/CompositeFormDataGenerator.java
@@ -106,7 +106,7 @@
 
       // Scout RT requires the first char to be upper-case for a getter.
       // See org.eclipse.scout.rt.platform.reflect.FastBeanUtility.BEAN_METHOD_PAT.
-      String methodName = Strings.ensureStartWithUpperCase(formDataTypeName);
+      CharSequence methodName = Strings.ensureStartWithUpperCase(formDataTypeName);
       this
           .withType(dtoGenerator.withElementName(formDataTypeName), DtoMemberSortObjectFactory.forTypeFormDataFormField(formDataTypeName))
           .withMethod(ScoutMethodGenerator.create()
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/dto/DtoMemberSortObjectFactory.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/dto/DtoMemberSortObjectFactory.java
index c2fa625..829e3c2 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/dto/DtoMemberSortObjectFactory.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/dto/DtoMemberSortObjectFactory.java
@@ -28,7 +28,7 @@
    *          The name of the property. E.g. "PersonNrProperty".
    * @return The sort order for the FormData Property getter
    */
-  public static Object[] forMethodFormDataProperty(String propertyName) {
+  public static Object[] forMethodFormDataProperty(CharSequence propertyName) {
     return SortedMemberEntry.createDefaultMethodPos(propertyName, 20);
   }
 
@@ -40,7 +40,7 @@
    *          The name of the property. E.g. "PersonNr".
    * @return The sort order for the FormData legacy Property getter and setter
    */
-  public static Object[] forMethodFormDataPropertyLegacy(String propertyName) {
+  public static Object[] forMethodFormDataPropertyLegacy(CharSequence propertyName) {
     return forMethodFormDataFormField(propertyName);
   }
 
@@ -52,7 +52,7 @@
    *          The name of the FormField. E.g. "LastName" without getter or setter prefix and without "Field" Suffix.
    * @return The sort order for the FormField getter
    */
-  public static Object[] forMethodFormDataFormField(String name) {
+  public static Object[] forMethodFormDataFormField(CharSequence name) {
     return SortedMemberEntry.createDefaultMethodPos(name, 10);
   }
 
@@ -63,7 +63,7 @@
    * @param name The name of the method (e.g. "getRowType").
    * @return The sort order for the TableFieldBeanData method
    */
-  public static Object[] forMethodTableData(String name) {
+  public static Object[] forMethodTableData(CharSequence name) {
     return forMethodFormDataFormField(name);
   }
 
@@ -75,7 +75,7 @@
    *          The name of the property. E.g. "PersonNrProperty".
    * @return The sort order for the FormData Property type
    */
-  public static Object[] forTypeFormDataProperty(String propertyName) {
+  public static Object[] forTypeFormDataProperty(CharSequence propertyName) {
     return forTypeFormDataFormField(propertyName);
   }
 
@@ -87,7 +87,7 @@
    *          The name of the FormField. E.g. "LastName" without "Field" Suffix.
    * @return The sort order for the FormField type
    */
-  public static Object[] forTypeFormDataFormField(String name) {
+  public static Object[] forTypeFormDataFormField(CharSequence name) {
     return SortedMemberEntry.createDefaultTypePos(name);
   }
 
@@ -96,7 +96,7 @@
    * @param name The name of the TableRowData type
    * @return The sort order for the RowData type.
    */
-  public static Object[] forTypeTableRowData(String name) {
+  public static Object[] forTypeTableRowData(CharSequence name) {
     return forTypeFormDataFormField(name);
   }
 }
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/environment/IEnvironment.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/environment/IEnvironment.java
index c436d00..25fb12e 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/environment/IEnvironment.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/environment/IEnvironment.java
@@ -100,8 +100,8 @@
    *          {@link IClasspathEntry#isSourceFolder()} must be {@code true}.
    * @param progress
    *          The {@link IProgress} monitor. Typically a {@link IProgress#newChild(int)} should be passed to this
-   *          method. The write operation will call {@link IProgress#init(int, String, Object...)} on the argument. May
-   *          be {@code null} if no progress indication is required.
+   *          method. The write operation will call {@link IProgress#init(int, CharSequence, Object...)} on the
+   *          argument. May be {@code null} if no progress indication is required.
    * @return The created {@link IType} within the specified {@link ICompilationUnitGenerator} that has the same
    *         {@link IJavaElement#elementName()} as the generator.
    * @throws RuntimeException
@@ -134,8 +134,8 @@
    *          {@link IClasspathEntry#isSourceFolder()} must be {@code true}.
    * @param progress
    *          The {@link IProgress} monitor. Typically a {@link IProgress#newChild(int)} should be passed to this
-   *          method. The write operation will call {@link IProgress#init(int, String, Object...)} on the argument. May
-   *          be {@code null} if no progress indication is required.
+   *          method. The write operation will call {@link IProgress#init(int, CharSequence, Object...)} on the
+   *          argument. May be {@code null} if no progress indication is required.
    * @return An {@link IFuture} that can be used to access the created {@link IType} within the specified
    *         {@link ICompilationUnitGenerator} that has the same {@link IJavaElement#elementName()} as the generator. If
    *         there was an exception writing the Java file, this exception will be thrown on result access of this
@@ -172,8 +172,8 @@
    *          The absolute path to the file to write. Must not be {@code null}.
    * @param progress
    *          The {@link IProgress} monitor. Typically a {@link IProgress#newChild(int)} should be passed to this
-   *          method. The write operation will call {@link IProgress#init(int, String, Object...)} on the argument. May
-   *          be {@code null} if no progress indication is required.
+   *          method. The write operation will call {@link IProgress#init(int, CharSequence, Object...)} on the
+   *          argument. May be {@code null} if no progress indication is required.
    * @throws RuntimeException
    *           if there is a problem writing the resource.
    */
@@ -189,8 +189,8 @@
    *          The absolute path to the file to write. Must not be {@code null}.
    * @param progress
    *          The {@link IProgress} monitor. Typically a {@link IProgress#newChild(int)} should be passed to this
-   *          method. The write operation will call {@link IProgress#init(int, String, Object...)} on the argument. May
-   *          be {@code null} if no progress indication is required.
+   *          method. The write operation will call {@link IProgress#init(int, CharSequence, Object...)} on the
+   *          argument. May be {@code null} if no progress indication is required.
    * @throws RuntimeException
    *           if there is a problem writing the {@link ISourceGenerator}.
    */
@@ -220,8 +220,8 @@
    *          The absolute path to the file to write. Must not be {@code null}.
    * @param progress
    *          The {@link IProgress} monitor. Typically a {@link IProgress#newChild(int)} should be passed to this
-   *          method. The write operation will call {@link IProgress#init(int, String, Object...)} on the argument. May
-   *          be {@code null} if no progress indication is required.
+   *          method. The write operation will call {@link IProgress#init(int, CharSequence, Object...)} on the
+   *          argument. May be {@code null} if no progress indication is required.
    * @return An {@link IFuture} that can be used to wait until the file has been written. If there was an exception
    *         writing the resource, this exception will be thrown on result access of this {@link IFuture}.
    * @throws RuntimeException
@@ -251,8 +251,8 @@
    *          The absolute path to the file to write. Must not be {@code null}.
    * @param progress
    *          The {@link IProgress} monitor. Typically a {@link IProgress#newChild(int)} should be passed to this
-   *          method. The write operation will call {@link IProgress#init(int, String, Object...)} on the argument. May
-   *          be {@code null} if no progress indication is required.
+   *          method. The write operation will call {@link IProgress#init(int, CharSequence, Object...)} on the
+   *          argument. May be {@code null} if no progress indication is required.
    * @return An {@link IFuture} that can be used to wait until the file has been written. If there was an exception
    *         writing the resource, this exception will be thrown on result access of this {@link IFuture}.
    */
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/form/FormGenerator.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/form/FormGenerator.java
index 46d0fdc..7979201 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/form/FormGenerator.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/form/FormGenerator.java
@@ -82,7 +82,7 @@
   protected IMethodGenerator<?, ? extends IMethodBodyBuilder<?>> createGetConfiguredTitle() {
     String nlsKeyName = elementName().orElseThrow(() -> newFail("Form has no name."));
     if (nlsKeyName.endsWith(ISdkProperties.SUFFIX_FORM)) {
-      nlsKeyName = Strings.ensureStartWithUpperCase(nlsKeyName.substring(0, nlsKeyName.length() - ISdkProperties.SUFFIX_FORM.length()));
+      nlsKeyName = Strings.ensureStartWithUpperCase(nlsKeyName.substring(0, nlsKeyName.length() - ISdkProperties.SUFFIX_FORM.length())).toString();
     }
     return ScoutMethodGenerator.createNlsMethod("getConfiguredTitle", nlsKeyName);
   }
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/generator/annotation/ScoutAnnotationGenerator.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/generator/annotation/ScoutAnnotationGenerator.java
index 410cd33..3313462 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/generator/annotation/ScoutAnnotationGenerator.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/generator/annotation/ScoutAnnotationGenerator.java
@@ -55,7 +55,7 @@
    *          the class id value to use
    * @return the created source builder
    */
-  public static IAnnotationGenerator<?> createClassId(String classIdValue) {
+  public static IAnnotationGenerator<?> createClassId(CharSequence classIdValue) {
     return create()
         .withElementName(IScoutRuntimeTypes.ClassId)
         .withElement("value", b -> b.stringLiteral(classIdValue));
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/generator/method/ScoutMethodGenerator.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/generator/method/ScoutMethodGenerator.java
index d5cc44b..ff0b480 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/generator/method/ScoutMethodGenerator.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/generator/method/ScoutMethodGenerator.java
@@ -61,7 +61,7 @@
         .withBody(b -> b.returnClause().appendGetFieldByClass(fieldFqn).semicolon());
   }
 
-  public static IScoutMethodGenerator<?, ?> createNlsMethod(String methodName, String nlsKeyName) {
+  public static IScoutMethodGenerator<?, ?> createNlsMethod(String methodName, CharSequence nlsKeyName) {
     return create()
         .withAnnotation(AnnotationGenerator.createOverride())
         .asProtected()
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/lookupcall/LookupCallGenerator.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/lookupcall/LookupCallGenerator.java
index 2a7c6de..8519745 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/lookupcall/LookupCallGenerator.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/lookupcall/LookupCallGenerator.java
@@ -31,7 +31,6 @@
 
   @Override
   protected void fillMainType(ITypeGenerator<? extends ITypeGenerator<?>> mainType) {
-
     mainType
         .withAnnotation(classIdValue()
             .map(ScoutAnnotationGenerator::createClassId)
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/FilteredTranslationStore.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/FilteredTranslationStore.java
index 945c51c..01ff971 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/FilteredTranslationStore.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/FilteredTranslationStore.java
@@ -88,11 +88,21 @@
   }
 
   @Override
+  public long size() {
+    return keys().count();
+  }
+
+  @Override
   public boolean containsKey(String key) {
     return keysFilter().contains(key) && m_store.containsKey(key);
   }
 
   @Override
+  public boolean containsLanguage(Language language) {
+    return languages().anyMatch(lang -> Objects.equals(lang, language));
+  }
+
+  @Override
   public Stream<? extends ITranslationEntry> entries() {
     return m_store.entries()
         .filter(e -> keysFilter().contains(e.key()));
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/ITranslation.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/ITranslation.java
index 97be050..5090f94 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/ITranslation.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/ITranslation.java
@@ -10,6 +10,7 @@
  */
 package org.eclipse.scout.sdk.core.s.nls;
 
+import java.util.Comparator;
 import java.util.Map;
 import java.util.Optional;
 import java.util.regex.Pattern;
@@ -22,7 +23,7 @@
  *
  * @since 7.0.0
  */
-public interface ITranslation {
+public interface ITranslation extends Comparable<ITranslation> {
 
   /**
    * Regular expression matching translation keys (see {@link #key()}).
@@ -30,11 +31,30 @@
   Pattern KEY_REGEX = Pattern.compile("[A-Za-z][a-zA-Z0-9_.\\-]{0,200}");
 
   /**
+   * The default comparator for {@link ITranslation}s comparing by key, then by text for the default language and
+   * finally by all other texts.
+   */
+  Comparator<ITranslation> TRANSLATION_COMPARATOR = Comparator.comparing(ITranslation::key)
+      .thenComparing(t -> t.text(Language.LANGUAGE_DEFAULT).orElse(null))
+      .thenComparing(t -> String.join("", t.texts().values()));
+
+  /**
    * @return The key as {@link String}. A key is unique within an {@link ITranslationStore}.
    */
   String key();
 
   /**
+   * Creates a new {@link ITranslation} with the texts from this instance and the given one merged. The texts from the
+   * provided {@link ITranslation} take precedence over the ones from this instance.
+   * <p>
+   * This instance remains untouched.
+   *
+   * @param translation
+   *          The {@link ITranslation} whose texts should be merged with this one.
+   */
+  ITranslation merged(ITranslation translation);
+
+  /**
    * Gets the translation text for the specified {@link Language}.
    *
    * @param language
@@ -48,4 +68,9 @@
    * @return An unmodifiable view on all language-text mappings of this {@link ITranslation}.
    */
   Map<Language, String> texts();
+
+  @Override
+  default int compareTo(ITranslation o) {
+    return TRANSLATION_COMPARATOR.compare(this, o);
+  }
 }
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/ITranslationImportInfo.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/ITranslationImportInfo.java
new file mode 100644
index 0000000..78de4e8
--- /dev/null
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/ITranslationImportInfo.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.core.s.nls;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Represents the result of a {@link ITranslation} import on a {@link TranslationStoreStack}.
+ * 
+ * @see TranslationStoreStack#importTranslations(List, String, ITranslationStore)
+ */
+public interface ITranslationImportInfo {
+  /**
+   * Result constant indicating that no data was available to import.
+   */
+  int NO_DATA = 0;
+
+  /**
+   * Result constant indicating that no key column and/or no default language column could be found in the import table
+   * data.
+   */
+  int NO_KEY_OR_DEFAULT_LANG_COLUMN = -1;
+
+  /**
+   * The result of the import operation.
+   * 
+   * @return A number > 0 indicates the number of translations that have been imported successfully. If the number is <=
+   *         0 it is one of the result constants: {@link #NO_DATA} or {@link #NO_KEY_OR_DEFAULT_LANG_COLUMN} indicating
+   *         that the import failed.
+   */
+  int result();
+
+  /**
+   * @return An unmodifiable {@link Map} holding all imported (updated or newly created) {@link ITranslationEntry
+   *         entries} grouped by key.
+   */
+  Map<String, ITranslationEntry> importedTranslations();
+
+  /**
+   * @return All columns of the header row that could not be mapped to a language or the key column. These columns have
+   *         been ignored in the import. It contains column-index to cell content mappings.
+   */
+  Map<Integer, String> ignoredColumns();
+
+  /**
+   * @return All keys that existed multiple times in the import data. The import only uses the last occurrence of a key
+   *         within the data set.
+   */
+  Set<String> duplicateKeys();
+
+  /**
+   * @return Row indices which do not contain any valid {@link ITranslation translations}. These are rows not having a
+   *         valid key or not having a text for the default language. These rows are skipped in the import.
+   */
+  List<Integer> invalidRowIndices();
+
+  /**
+   * @return The zero based column index that contains the default language.
+   */
+  int defaultLanguageColumnIndex();
+
+  /**
+   * @return The zero based column index that contains the key.
+   */
+  int keyColumnIndex();
+}
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/ITranslationStore.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/ITranslationStore.java
index 1cc2793..ba16962 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/ITranslationStore.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/ITranslationStore.java
@@ -37,11 +37,23 @@
   boolean isEditable();
 
   /**
+   * @param language
+   *          The {@link Language} to search.
+   * @return {@code true} if this store contains the specified {@link Language}. {@code false} otherwise.
+   */
+  boolean containsLanguage(Language language);
+
+  /**
    * @return A {@link Stream} providing all keys that exist in this store.
    */
   Stream<String> keys();
 
   /**
+   * @return The number of entries in this store.
+   */
+  long size();
+
+  /**
    * @param key
    *          The key to search.
    * @return {@code true} if this store contains the specified key. {@code false} otherwise.
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/Language.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/Language.java
index 11185a8..9bb2d03 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/Language.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/Language.java
@@ -29,7 +29,7 @@
   /**
    * The regular expression of a valid {@link String} representation of a {@link Language}.
    */
-  public static final String LANGUAGE_REGEX = "([^_]+)(?:_([\\w]{2}))?(?:_([\\w]{2}))?";
+  public static final String LANGUAGE_REGEX = "([^_]+)(?:_([^_]+))?(?:_(.+))?";
 
   /**
    * Represents the default language used if no other language matches.
@@ -99,8 +99,7 @@
    *           if the specified name is not valid according to {@link #LANGUAGE_REGEX}.
    */
   public static Language parseThrowingOnError(CharSequence name) {
-    return parse(name)
-        .orElseThrow(() -> newFail("Invalid language name '{}'. Language cannot be parsed.", name));
+    return parse(name).orElseThrow(() -> newFail("Invalid language name '{}'. Language cannot be parsed.", name));
   }
 
   /**
@@ -114,8 +113,7 @@
   public static Optional<Language> parse(CharSequence name) {
     Matcher matcher = LANGUAGE_PATTERN.matcher(name);
     if (matcher.matches()) {
-      if (matcher.group(2) == null
-          && LANGUAGE_DEFAULT.locale().toString().equals(matcher.group(1))) {
+      if (matcher.group(2) == null && LANGUAGE_DEFAULT.locale().toString().equals(matcher.group(1))) {
         return Optional.of(LANGUAGE_DEFAULT);
       }
 
@@ -128,6 +126,9 @@
       if (variantIso == null) {
         variantIso = "";
       }
+      else if (variantIso.startsWith("_")) {
+        variantIso = variantIso.substring(1);
+      }
       return Optional.of(new Language(new Locale(languageIso, countryIso, variantIso)));
     }
     return Optional.empty();
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/NlsFile.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/NlsFile.java
new file mode 100644
index 0000000..1c5c9d8
--- /dev/null
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/NlsFile.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.core.s.nls;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.StringJoiner;
+
+import org.eclipse.scout.sdk.core.util.Ensure;
+import org.eclipse.scout.sdk.core.util.FinalValue;
+import org.eclipse.scout.sdk.core.util.SdkException;
+import org.eclipse.scout.sdk.core.util.Strings;
+
+/**
+ * Represents an .nls file.
+ */
+public class NlsFile {
+
+  /**
+   * The NLS class key name. The value of this key is the fully qualified name of the primary text provider service the
+   * nls file points to.
+   */
+  public static final String NLS_CLASS_KEY_NAME = "Nls-Class";
+
+  private final Path m_file;
+  private final FinalValue<Optional<String>> m_nlsClass = new FinalValue<>();
+
+  public NlsFile(Path file) {
+    m_file = Ensure.notNull(file);
+  }
+
+  /**
+   * @return The fully qualified class name of the text provider service the nls file refers or an empty
+   *         {@link Optional} if the property does not exist in the file.
+   */
+  public Optional<String> nlsClassFqn() {
+    return m_nlsClass.computeIfAbsentAndGet(this::parseNlsClass);
+  }
+
+  protected Optional<String> parseNlsClass() {
+    Properties properties = new Properties();
+    try (InputStream in = new BufferedInputStream(Files.newInputStream(path()))) {
+      properties.load(in);
+    }
+    catch (IOException e) {
+      throw new SdkException("Unable to read nls file '{}'.", path(), e);
+    }
+    String nlsClass = properties.getProperty(NLS_CLASS_KEY_NAME);
+    if (nlsClass != null) {
+      nlsClass = nlsClass.trim();
+    }
+    return Strings.notBlank(nlsClass);
+  }
+
+  /**
+   * @return The {@link Path} of the file.
+   */
+  public Path path() {
+    return m_file;
+  }
+
+  /**
+   * @param stack
+   *          The stack in which the store should be searched.
+   * @return The {@link ITranslationStore} within the given {@link TranslationStoreStack stack} whose service matches
+   *         the one of this nls file.
+   */
+  public Optional<ITranslationStore> findMatchingStoreIn(TranslationStoreStack stack) {
+    return nlsClassFqn()
+        .flatMap(fqn -> Optional.ofNullable(stack)
+            .map(TranslationStoreStack::allStores)
+            .flatMap(stores -> stores
+                .filter(store -> store.service().type().name().equals(fqn))
+                .findAny()));
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    NlsFile nlsFile = (NlsFile) o;
+    return m_file.equals(nlsFile.m_file);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(m_file);
+  }
+
+  @Override
+  public String toString() {
+    return new StringJoiner(", ", NlsFile.class.getSimpleName() + " [", "]").add("file=" + m_file).toString();
+  }
+}
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/Translation.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/Translation.java
index bea0fc4..7d638d8 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/Translation.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/Translation.java
@@ -61,6 +61,15 @@
     }
   }
 
+  @Override
+  public Translation merged(ITranslation translation) {
+    Translation merged = new Translation(this);
+    if (translation != null) {
+      translation.texts().forEach(merged::putText);
+    }
+    return merged;
+  }
+
   /**
    * Replaces all translations with the specified ones.
    *
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationImporter.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationImporter.java
new file mode 100644
index 0000000..044bd95
--- /dev/null
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationImporter.java
@@ -0,0 +1,283 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.core.s.nls;
+
+import static java.util.Collections.unmodifiableList;
+import static java.util.Collections.unmodifiableMap;
+import static java.util.Collections.unmodifiableSet;
+import static java.util.stream.Collectors.toMap;
+import static java.util.stream.Collectors.toSet;
+import static org.eclipse.scout.sdk.core.util.Ensure.failOnDuplicates;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.IntStream;
+
+import org.eclipse.scout.sdk.core.util.Ensure;
+import org.eclipse.scout.sdk.core.util.Strings;
+
+public class TranslationImporter implements ITranslationImportInfo {
+
+  // input
+  private final TranslationStoreStack m_stack;
+  private final List<List<String>> m_rawTableData;
+  private final ITranslationStore m_storeForNewTranslations;
+  private final String m_keyColumnName;
+
+  // column mappings
+  private final Map<Integer/* column index */, Language> m_columnMapping;
+  private int m_keyColumnIndex;
+  private int m_defaultLanguageColumnIndex;
+
+  // result
+  private final Map<Integer, String> m_unmappedColumns;
+  private final Set<String> m_duplicateKeys;
+  private final List<Integer> m_invalidRows;
+  private final Map<String, ITranslationEntry> m_importedTranslations;
+  private int m_result;
+
+  protected TranslationImporter(TranslationStoreStack stackToImportTo, List<List<String>> rawTableData, String keyColumnName, ITranslationStore storeForNewTranslations) {
+    m_stack = Ensure.notNull(stackToImportTo);
+    m_rawTableData = Ensure.notNull(rawTableData);
+    m_storeForNewTranslations = storeForNewTranslations;
+    m_keyColumnName = Ensure.notBlank(keyColumnName);
+
+    m_columnMapping = new HashMap<>();
+    m_keyColumnIndex = -1;
+    m_defaultLanguageColumnIndex = -1;
+
+    m_unmappedColumns = new LinkedHashMap<>();
+    m_duplicateKeys = new HashSet<>();
+    m_invalidRows = new ArrayList<>();
+    m_importedTranslations = new HashMap<>();
+  }
+
+  public void tryImport() {
+    m_result = doImport();
+  }
+
+  protected int doImport() {
+    List<List<String>> rawData = rawTableData();
+    if (rawData.size() < 2) {
+      return NO_DATA; // no data to import
+    }
+
+    Map<String, ITranslation> toImport = new HashMap<>(rawData.size() - 1);
+    m_duplicateKeys.addAll(IntStream.range(0, rawData.size())
+        .mapToObj(row -> parseRow(rawData.get(row), row))
+        .filter(Objects::nonNull)
+        .map(translation -> toImport.put(translation.key(), translation))
+        .filter(Objects::nonNull)
+        .map(ITranslation::key)
+        .collect(toSet()));
+
+    if (!isValidHeader(keyColumnIndex(), defaultLanguageColumnIndex())) {
+      return NO_KEY_OR_DEFAULT_LANG_COLUMN;
+    }
+    if (toImport.isEmpty()) {
+      return NO_DATA;
+    }
+
+    TranslationStoreStack stack = stack();
+    ITranslationStore targetForNewTranslations = storeForNewTranslations();
+    stack.setChanging(true);
+    try {
+      m_importedTranslations.putAll(toImport.values().stream()
+          .map(translation -> stack.mergeTranslation(translation, targetForNewTranslations))
+          .collect(toMap(ITranslation::key, Function.identity(), failOnDuplicates())));
+    }
+    finally {
+      stack.setChanging(false);
+    }
+    return importedTranslations().size();
+  }
+
+  protected ITranslation parseRow(List<String> row, int rowIndex) {
+    if (!columnMappingDone()) {
+      // parse header first
+      parseHeader(row);
+      return null;
+    }
+
+    // skip empty rows
+    if (isEmptyRow(row)) {
+      return null;
+    }
+
+    // validate key column
+    if (row.size() <= keyColumnIndex()) {
+      m_invalidRows.add(rowIndex);
+      return null;
+    }
+    String key = row.get(keyColumnIndex());
+    if (Strings.isBlank(key) || !ITranslation.KEY_REGEX.matcher(key).matches()) {
+      m_invalidRows.add(rowIndex);
+      return null;
+    }
+
+    // validate default language column
+    if (row.size() <= defaultLanguageColumnIndex()) {
+      m_invalidRows.add(rowIndex);
+      return null;
+    }
+    String defaultLangText = row.get(defaultLanguageColumnIndex());
+    if (Strings.isBlank(defaultLangText)) {
+      m_invalidRows.add(rowIndex);
+      return null;
+    }
+
+    // create translation for row
+    Translation t = new Translation(key);
+    t.putText(Language.LANGUAGE_DEFAULT, defaultLangText);
+    m_columnMapping.forEach((index, language) -> appendText(t, language, row, index));
+    return t;
+  }
+
+  protected static boolean isEmptyRow(Collection<String> row) {
+    return row.stream().noneMatch(Strings::hasText);
+  }
+
+  protected static void appendText(Translation toFill, Language lang, List<String> row, int index) {
+    if (row.size() <= index) {
+      return;
+    }
+    String text = row.get(index);
+    if (Strings.isEmpty(text)) {
+      return;
+    }
+    toFill.putText(lang, text);
+  }
+
+  protected boolean isEmptyColumn(int columnIndex) {
+    return rawTableData().stream().allMatch(row -> {
+      if (columnIndex >= row.size()) {
+        return true;
+      }
+      return Strings.isBlank(row.get(columnIndex));
+    });
+  }
+
+  protected void parseHeader(List<String> headerRow) {
+    Map<Integer, Language> columnMapping = new HashMap<>(headerRow.size() - 1);
+    Map<Integer, String> unmappedColumns = new LinkedHashMap<>();
+    int keyColumnIndex = -1;
+    int defaultLanguageColumnIndex = -1;
+
+    for (int i = 0; i < headerRow.size(); i++) {
+      String cell = headerRow.get(i);
+      if (Strings.isBlank(cell)) {
+        if (!isEmptyColumn(i)) {
+          unmappedColumns.put(i, cell);
+        }
+        continue;
+      }
+
+      if (keyColumnName().equalsIgnoreCase(cell)) {
+        keyColumnIndex = i;
+      }
+      else if (Language.LANGUAGE_DEFAULT.locale().toString().equalsIgnoreCase(cell)) {
+        defaultLanguageColumnIndex = i;
+      }
+      else {
+        Optional<Language> language = Language.parse(cell);
+        if (language.isPresent()) {
+          columnMapping.put(i, language.get());
+        }
+        else {
+          unmappedColumns.put(i, cell);
+        }
+      }
+    }
+    if (isValidHeader(keyColumnIndex, defaultLanguageColumnIndex)) {
+      // valid header row: at least a key column and default language could be found: accept it as header
+      m_columnMapping.putAll(columnMapping);
+      m_unmappedColumns.putAll(unmappedColumns);
+      m_keyColumnIndex = keyColumnIndex;
+      m_defaultLanguageColumnIndex = defaultLanguageColumnIndex;
+    }
+  }
+
+  @Override
+  public Map<String, ITranslationEntry> importedTranslations() {
+    return unmodifiableMap(m_importedTranslations);
+  }
+
+  @Override
+  public int result() {
+    return m_result;
+  }
+
+  @Override
+  public int defaultLanguageColumnIndex() {
+    return m_defaultLanguageColumnIndex;
+  }
+
+  @Override
+  public int keyColumnIndex() {
+    return m_keyColumnIndex;
+  }
+
+  @Override
+  public Map<Integer, String> ignoredColumns() {
+    return unmodifiableMap(m_unmappedColumns);
+  }
+
+  @Override
+  public Set<String> duplicateKeys() {
+    return unmodifiableSet(m_duplicateKeys);
+  }
+
+  @Override
+  public List<Integer> invalidRowIndices() {
+    return unmodifiableList(m_invalidRows);
+  }
+
+  protected boolean columnMappingDone() {
+    return isValidHeader(keyColumnIndex(), defaultLanguageColumnIndex());
+  }
+
+  public String keyColumnName() {
+    return m_keyColumnName;
+  }
+
+  public TranslationStoreStack stack() {
+    return m_stack;
+  }
+
+  public List<List<String>> rawTableData() {
+    return m_rawTableData;
+  }
+
+  public ITranslationStore storeForNewTranslations() {
+    return m_storeForNewTranslations;
+  }
+
+  protected static boolean isValidHeader(int keyColumnIndex, int defaultLanguageColumnIndex) {
+    return isValidKeyColumnIndex(keyColumnIndex) && isValidDefaultLanguageColumnIndex(defaultLanguageColumnIndex);
+  }
+
+  protected static boolean isValidDefaultLanguageColumnIndex(int defaultLanguageColumnIndex) {
+    return defaultLanguageColumnIndex >= 0;
+  }
+
+  protected static boolean isValidKeyColumnIndex(int keyColumnIndex) {
+    return keyColumnIndex >= 0;
+  }
+}
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationStoreStack.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationStoreStack.java
index 4df9eb0..0b7a252 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationStoreStack.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationStoreStack.java
@@ -19,6 +19,9 @@
 import static org.eclipse.scout.sdk.core.s.nls.TranslationStoreStackEvent.createReloadEvent;
 import static org.eclipse.scout.sdk.core.s.nls.TranslationStoreStackEvent.createRemoveTranslationEvent;
 import static org.eclipse.scout.sdk.core.s.nls.TranslationStoreStackEvent.createUpdateTranslationEvent;
+import static org.eclipse.scout.sdk.core.s.nls.TranslationValidator.isForbidden;
+import static org.eclipse.scout.sdk.core.s.nls.TranslationValidator.validateKey;
+import static org.eclipse.scout.sdk.core.s.nls.TranslationValidator.validateTranslation;
 import static org.eclipse.scout.sdk.core.util.Ensure.newFail;
 
 import java.nio.file.Path;
@@ -28,13 +31,13 @@
 import java.util.Collection;
 import java.util.Comparator;
 import java.util.Deque;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.TreeMap;
 import java.util.function.Supplier;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
@@ -191,54 +194,102 @@
    * @param target
    *          The target {@link ITranslationStore}. May be {@code null}. In that case the
    *          {@link #primaryEditableStore()} is used.
+   * @return The created {@link ITranslationEntry}.
    * @throws IllegalArgumentException
    *           if one of the mentioned condition is not fulfilled.
    */
-  public synchronized void addNewTranslation(ITranslation newTranslation, ITranslationStore target) {
+  public synchronized ITranslationEntry addNewTranslation(ITranslation newTranslation, ITranslationStore target) {
     IEditableTranslationStore editableStore = toEditableStore(
         Optional.ofNullable(target)
             .orElseGet(() -> primaryEditableStore()
                 .orElseThrow(() -> newFail("Cannot create new entries. All translation stores are read-only."))));
 
     // validate input
-    validateTranslation(newTranslation);
+    Ensure.isTrue(validateTranslation(newTranslation) == TranslationValidator.OK);
     Ensure.isFalse(editableStore.containsKey(newTranslation.key()), "Key '{}' already exists in store {}", newTranslation.key(), editableStore);
     Ensure.isTrue(m_stores.contains(editableStore), "Store of wrong stack.");
 
-    // create new text
-    runAndFireChanged(() -> createAddTranslationEvent(this, editableStore.addNewTranslation(newTranslation)));
+    return addNewTranslationInternal(newTranslation, editableStore);
+  }
+
+  protected ITranslationEntry addNewTranslationInternal(ITranslation newTranslation, IEditableTranslationStore target) {
+    Optional<? extends ITranslationEntry> existingEntryWithSameKey = translation(newTranslation.key());
+    ITranslationEntry createdTranslation = target.addNewTranslation(newTranslation);
+
+    setChanging(true);
+    try {
+      if (existingEntryWithSameKey.isPresent()) {
+        if (existingEntryWithSameKey.get().store().service().order() < target.service().order()) {
+          // there is already an entry with the same key that overrides the just created.
+          // the just created will not be visible -> fire no events
+          return createdTranslation;
+        }
+
+        // the just created entry overrides an existing: remove the existing as it will not be visible anymore
+        fireStackChanged(createRemoveTranslationEvent(this, existingEntryWithSameKey.get()));
+      }
+      fireStackChanged(createAddTranslationEvent(this, createdTranslation));
+      return createdTranslation;
+    }
+    finally {
+      setChanging(false);
+    }
   }
 
   /**
    * Merges the {@link ITranslation} specified into this stack.
    * <p>
-   * If an entry with the same key as the {@link ITranslation} provided already exists in this stack, this entry is
-   * updated within the same {@link ITranslationStore} as it already exists.
+   * If an editable entry with the same key as the {@link ITranslation} provided already exists in this stack, this
+   * entry is updated within the same {@link ITranslationStore} as it already exists.
    * <p>
-   * If no entry with the same key as the {@link ITranslation} provided exists, a new translation is created within the
-   * {@link ITranslationStore} provided.
+   * Otherwise, a new translation is created within the {@link ITranslationStore} provided.
    * <p>
    * If the {@link ITranslation} provided contains more {@link Language languages} than the store in which it will be
    * created/updated, languages will be created as needed.
-   * 
+   *
    * @param newTranslation
    *          The new {@link ITranslation} that should be merged into this store. Must not be {@code null}.
    * @param targetInCaseNew
-   *          The {@link ITranslationStore} in which the entry should be created in case it does not yet exist. May be
-   *          {@code null} which means the primary (first) editable stored available in this stack is used.
+   *          An optional {@link ITranslationStore} in which the entry should be created in case it does not yet exist.
+   *          May be {@code null} which means the primary (first) editable store available in this stack is used.
+   * @return The created or updated {@link ITranslationEntry}.
    * @throws IllegalArgumentException
    *           if the given translation is invalid, the given store does not belong to this stack or is not editable.
    * @see #addNewTranslation(ITranslation, ITranslationStore)
    * @see #updateTranslation(ITranslation)
    */
-  public synchronized void mergeTranslation(ITranslation newTranslation, ITranslationStore targetInCaseNew) {
-    Ensure.notNull(newTranslation, "A translation must be specified.");
-    if (containsKey(newTranslation.key())) {
-      updateTranslation(newTranslation);
-    }
-    else {
-      addNewTranslation(newTranslation, targetInCaseNew);
-    }
+  public synchronized ITranslationEntry mergeTranslation(ITranslation newTranslation, ITranslationStore targetInCaseNew) {
+    ITranslation merged = translation(Ensure.notNull(newTranslation).key())
+        .map(t -> t.merged(newTranslation))
+        .orElse(newTranslation);
+    return updateTranslation(merged)
+        .orElseGet(() -> addNewTranslation(merged, targetInCaseNew));
+  }
+
+  /**
+   * Imports translations from tabular data.
+   * <p>
+   * Stores the texts in the first editable {@link ITranslationStore} that contains the corresponding key.
+   * <p>
+   * For new {@link ITranslation translations} the store given is used. If no specific target is specified, the first
+   * editable store in this stack is used.
+   *
+   * @param rawTableData
+   *          The tabular data to import. The {@link List} must contain a header row specifying the languages and the
+   *          key column. Only rows after such a header row are imported.
+   * @param targetInCaseNew
+   *          The {@link ITranslationStore} in which the entry should be created in case it does not yet exist. May be *
+   *          {@code null} which means the primary (first) editable store available in this stack is used.
+   * @param keyColumnName
+   *          The text that identifies the key column in the header row. Must not be {@code null} or an empty
+   *          {@link String}.
+   * @return An {@link ITranslationImportInfo} describing the result of the import. It allows to access e.g. the
+   *         imported entries, ignored columns or rows.
+   */
+  public synchronized ITranslationImportInfo importTranslations(List<List<String>> rawTableData, String keyColumnName, ITranslationStore targetInCaseNew) {
+    TranslationImporter importer = new TranslationImporter(this, rawTableData, keyColumnName, targetInCaseNew);
+    importer.tryImport();
+    return importer;
   }
 
   /**
@@ -254,24 +305,40 @@
   public synchronized void changeKey(String oldKey, String newKey) {
     Ensure.notBlank(oldKey, "Cannot change a blank key.");
     Ensure.notBlank(newKey, "Cannot update to a blank key.");
-    Ensure.isFalse(translation(newKey).isPresent(), "Cannot change key '{}' to '{}' because the new key already exists.", oldKey, newKey);
+    if (oldKey.equals(newKey)) {
+      return;
+    }
+
+    Optional<IEditableTranslationStore> targetStore = firstEditableStoreWithKey(oldKey);
+    if (!targetStore.isPresent()) {
+      return;
+    }
+    IEditableTranslationStore store = targetStore.get();
+    Ensure.isFalse(isForbidden(validateKey(this, store, newKey)), "Cannot change key '{}' to '{}' because the new key is not valid.", oldKey, newKey);
 
     setChanging(true);
     try {
-      firstEditableStoreWithKey(oldKey)
-          .ifPresent(store -> {
-            // update key in first store
-            fireStackChanged(createChangeKeyEvent(this, store.changeKey(oldKey, newKey), oldKey));
+      Optional<? extends ITranslationEntry> existingEntryWithNewKey = translation(newKey);
+      ITranslationEntry changedEntry = store.changeKey(oldKey, newKey);
 
-            // if the old key is still present: we changed an overridden translation key. So it is no longer overridden now.
-            // create a synthetic event describing that a new one has been added (the one that is no longer overridden).
-            allStores()
-                .filter(s -> s.containsKey(oldKey))
-                .findAny()
-                .flatMap(s -> s.get(oldKey))
-                .ifPresent(entry -> fireStackChanged(createAddTranslationEvent(this, entry)));
-
-          });
+      if (existingEntryWithNewKey.isPresent()) {
+        // the new key already existed in another service before the update. so the changed entry was a normal and is now an overriding or overridden entry.
+        if (existingEntryWithNewKey.get().store().service().order() < changedEntry.store().service().order()) {
+          // it is an overridden entry: remove it
+          fireStackChanged(createRemoveTranslationEvent(this, changedEntry));
+        }
+        else {
+          // it is now an overriding entry: delete existing
+          fireStackChanged(createChangeKeyEvent(this, changedEntry, oldKey));
+          fireStackChanged(createRemoveTranslationEvent(this, existingEntryWithNewKey.get()));
+        }
+      }
+      else {
+        // if the old key is still present: the entry changed from overriding to normal.
+        // create a synthetic event describing that a new one has been added (the one that is no longer overridden).
+        fireStackChanged(createChangeKeyEvent(this, changedEntry, oldKey));
+        translation(oldKey).ifPresent(entry -> fireStackChanged(createAddTranslationEvent(this, entry)));
+      }
     }
     finally {
       setChanging(false);
@@ -279,10 +346,9 @@
   }
 
   protected Optional<IEditableTranslationStore> firstEditableStoreWithKey(String key) {
-    return allEditableStores()
+    return allEditableStoresInternal()
         .filter(s -> s.containsKey(key))
-        .findAny()
-        .map(TranslationStoreStack::toEditableStore);
+        .findFirst();
   }
 
   /**
@@ -292,16 +358,24 @@
    *          The keys of the entries to remove. Must not be {@code null}.
    */
   public synchronized void removeTranslations(Stream<String> keys) {
-    Ensure.notNull(keys)
-        .filter(Strings::hasText)
-        .forEach(key -> firstEditableStoreWithKey(key)
-            .ifPresent(s -> runAndFireChanged(() -> createRemoveTranslationEvent(this, s.removeTranslation(key)))));
+    setChanging(true);
+    try {
+      Ensure.notNull(keys)
+          .filter(Strings::hasText)
+          .forEach(key -> firstEditableStoreWithKey(key)
+              .ifPresent(s -> runAndFireChanged(() -> createRemoveTranslationEvent(this, removeTranslationInternal(s, key)))));
+    }
+    finally {
+      setChanging(false);
+    }
   }
 
-  protected static void validateTranslation(ITranslation translation) {
-    Ensure.notNull(translation, "A translation must be specified.");
-    Ensure.notBlank(translation.key(), "Key must be specified.");
-    Ensure.isFalse(Strings.isEmpty(translation.text(Language.LANGUAGE_DEFAULT).orElse(null)), "Default language translation must be specified.");
+  protected ITranslationEntry removeTranslationInternal(IEditableTranslationStore store, String key) {
+    ITranslationEntry removedEntry = store.removeTranslation(key);
+    Optional<? extends ITranslationEntry> translationAfterRemoval = translation(key);
+    // an overriding entry has been removed. The previously overridden entry becomes visible now
+    translationAfterRemoval.ifPresent(previouslyOverridden -> fireStackChanged(createAddTranslationEvent(this, previouslyOverridden)));
+    return removedEntry;
   }
 
   /**
@@ -311,16 +385,24 @@
    * If the {@link ITranslation} provided contains more {@link Language languages} than the store in which it exists,
    * languages will be created as needed.
    *
-   * @param newEntry
+   * @param newTranslation
    *          The new entry.
+   * @return An {@link Optional} holding the updated entry. The {@link Optional} is empty if no editable
+   *         {@link ITranslationStore} containing the key of the {@link ITranslation} given could be found.
    * @throws IllegalArgumentException
    *           if the specified {@link ITranslation} is {@code null}, has no valid key or does not contain a text for
    *           the {@link Language#LANGUAGE_DEFAULT}.
    */
-  public synchronized void updateTranslation(ITranslation newEntry) {
-    validateTranslation(newEntry);
-    firstEditableStoreWithKey(newEntry.key())
-        .ifPresent(store -> runAndFireChanged(() -> createUpdateTranslationEvent(this, store.updateTranslation(newEntry))));
+  public synchronized Optional<ITranslationEntry> updateTranslation(ITranslation newTranslation) {
+    Ensure.isTrue(validateTranslation(newTranslation) == TranslationValidator.OK);
+    return firstEditableStoreWithKey(newTranslation.key())
+        .map(store -> updateTranslationInternal(newTranslation, store));
+  }
+
+  protected ITranslationEntry updateTranslationInternal(ITranslation newEntry, IEditableTranslationStore storeToUpdate) {
+    ITranslationEntry updateTranslation = storeToUpdate.updateTranslation(newEntry);
+    fireStackChanged(createUpdateTranslationEvent(this, updateTranslation));
+    return updateTranslation;
   }
 
   /**
@@ -353,7 +435,7 @@
    * @return A {@link Stream} with all not overridden {@link ITranslationEntry} instances.
    */
   public synchronized Stream<ITranslationEntry> allEntries() {
-    Map<String, ITranslationEntry> allEntries = new HashMap<>();
+    Map<String, ITranslationEntry> allEntries = new TreeMap<>();
     Iterator<ITranslationStore> storesHighestOrderFirst = m_stores.descendingIterator();
     while (storesHighestOrderFirst.hasNext()) {
       ITranslationStore store = storesHighestOrderFirst.next();
@@ -386,7 +468,7 @@
    */
   public synchronized void flush(IEnvironment env, IProgress progress) {
     runAndFireChanged(() -> {
-      allEditableStores().forEach(store -> toEditableStore(store).flush(env, progress));
+      allEditableStoresInternal().forEach(store -> store.flush(env, progress));
       return createFlushEvent(this);
     });
   }
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationStores.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationStores.java
index 82ec005..56b69a4 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationStores.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationStores.java
@@ -10,6 +10,9 @@
  */
 package org.eclipse.scout.sdk.core.s.nls;
 
+import static java.util.function.Function.identity;
+import static java.util.stream.Collectors.toMap;
+
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -221,7 +224,20 @@
 
   static Stream<ITranslationStore> getAllStoresForModule(Path modulePath, IEnvironment env, IProgress progress) {
     progress.init(20000, "Resolve all translation stores for module '{}'.", modulePath);
-    return Stream.concat(allForJavaModule(modulePath, env, progress.newChild(10000)), allForWebModule(modulePath, env, progress.newChild(10000)));
+    return Stream.concat(allForJavaModule(modulePath, env, progress.newChild(10000)), allForWebModule(modulePath, env, progress.newChild(10000)))
+        .collect(toMap(s -> s.service().type().name(), identity(), TranslationStores::keepLargerStore))
+        .values().stream();
+  }
+
+  /**
+   * In case the same store is part of the java module list and the web module list: keep the one that contains more
+   * elements (unfiltered)
+   */
+  private static ITranslationStore keepLargerStore(ITranslationStore a, ITranslationStore b) {
+    if (a.size() >= b.size()) {
+      return a;
+    }
+    return b;
   }
 
   static boolean isContentAvailable(ITranslationStore s) {
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationValidator.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationValidator.java
new file mode 100644
index 0000000..b3c83e7
--- /dev/null
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/TranslationValidator.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.core.s.nls;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import org.eclipse.scout.sdk.core.util.Ensure;
+import org.eclipse.scout.sdk.core.util.Strings;
+
+/**
+ * Contains methods to validate translations.
+ */
+public final class TranslationValidator {
+
+  public static final int OK = 0;
+
+  public static final int DEFAULT_TRANSLATION_MISSING_ERROR = 1;
+  public static final int DEFAULT_TRANSLATION_EMPTY_ERROR = 2;
+
+  public static final int KEY_EMPTY_ERROR = 3;
+  public static final int KEY_ALREADY_EXISTS_ERROR = 4;
+  public static final int KEY_OVERRIDES_OTHER_STORE_WARNING = 5;
+  public static final int KEY_IS_OVERRIDDEN_BY_OTHER_STORE_WARNING = 6;
+  public static final int KEY_OVERRIDES_AND_IS_OVERRIDDEN_WARNING = 7;
+  public static final int KEY_INVALID_ERROR = 8;
+
+  private TranslationValidator() {
+  }
+
+  public static int validateTranslation(ITranslation toValidate) {
+    Ensure.notNull(toValidate, "A translation must be specified.");
+    int result = validateKey(null, null, toValidate.key(), Collections.singleton(toValidate.key()));
+    if (result != OK) {
+      return result;
+    }
+    return validateDefaultText(toValidate);
+  }
+
+  /**
+   * Checks if the given {@link ITranslation} contains a valid value for the default language.
+   *
+   * @param translation
+   *          The {@link ITranslation} to validate
+   * @return {@link #DEFAULT_TRANSLATION_MISSING_ERROR} if there is no default language,
+   *         {@link #DEFAULT_TRANSLATION_EMPTY_ERROR} if the default text is empty or {@link #OK}.
+   */
+  public static int validateDefaultText(ITranslation translation) {
+    return Ensure.notNull(translation).text(Language.LANGUAGE_DEFAULT)
+        .map(TranslationValidator::validateDefaultText)
+        .orElse(DEFAULT_TRANSLATION_MISSING_ERROR);
+  }
+
+  /**
+   * Checks if the given {@link CharSequence} is a valid value for a default language text entry.
+   *
+   * @param defaultTranslation
+   *          The text for the default language.
+   * @return {@link #DEFAULT_TRANSLATION_EMPTY_ERROR} if the default text is empty or {@link #OK}.
+   */
+  public static int validateDefaultText(CharSequence defaultTranslation) {
+    if (Strings.isBlank(defaultTranslation)) {
+      return DEFAULT_TRANSLATION_EMPTY_ERROR;
+    }
+    return OK;
+  }
+
+  /**
+   * @param result
+   *          The validation result code to check.
+   * @return {@code true} if the given validation result describes a forbidden or invalid state.
+   */
+  public static boolean isForbidden(int result) {
+    return result != OK
+        && result != KEY_OVERRIDES_OTHER_STORE_WARNING
+        && result != KEY_IS_OVERRIDDEN_BY_OTHER_STORE_WARNING
+        && result != KEY_OVERRIDES_AND_IS_OVERRIDDEN_WARNING;
+  }
+
+  /**
+   * Checks if the given key is valid in the context of the {@link TranslationStoreStack stack} and
+   * {@link ITranslationStore store} specified.
+   *
+   * @param stack
+   *          The {@link TranslationStoreStack stack} in which the key would be stored. Must not be {@code null}.
+   * @param target
+   *          The target {@link ITranslationStore store} in which the key would be stored. Must not be {@code null}.
+   * @param keyToValidate
+   *          The key to validate
+   * @return {@link #KEY_EMPTY_ERROR}, {@link #KEY_ALREADY_EXISTS_ERROR},
+   *         {@link #KEY_OVERRIDES_AND_IS_OVERRIDDEN_WARNING}, {@link #KEY_OVERRIDES_OTHER_STORE_WARNING},
+   *         {@link #KEY_IS_OVERRIDDEN_BY_OTHER_STORE_WARNING}, {@link #KEY_INVALID_ERROR} or {@link #OK}.
+   */
+  public static int validateKey(TranslationStoreStack stack, ITranslationStore target, String keyToValidate) {
+    return validateKey(stack, target, keyToValidate, null);
+  }
+
+  /**
+   * Checks if the given key is valid in the context of the {@link TranslationStoreStack stack} and
+   * {@link ITranslationStore store} specified.
+   *
+   * @param stack
+   *          The {@link TranslationStoreStack stack} in which the key would be stored.
+   * @param target
+   *          The target {@link ITranslationStore store} in which the key would be stored.
+   * @param keyToValidate
+   *          The key to validate
+   * @param acceptedKeys
+   *          An optional {@link Collection} of keys for which the given stack and store should be ignored in
+   *          validation.
+   * @return {@link #KEY_EMPTY_ERROR}, {@link #KEY_ALREADY_EXISTS_ERROR},
+   *         {@link #KEY_OVERRIDES_AND_IS_OVERRIDDEN_WARNING}, {@link #KEY_OVERRIDES_OTHER_STORE_WARNING},
+   *         {@link #KEY_IS_OVERRIDDEN_BY_OTHER_STORE_WARNING}, {@link #KEY_INVALID_ERROR} or {@link #OK}.
+   */
+  public static int validateKey(TranslationStoreStack stack, ITranslationStore target, String keyToValidate, Collection<String> acceptedKeys) {
+    if (Strings.isBlank(keyToValidate)) {
+      return KEY_EMPTY_ERROR;
+    }
+
+    if (acceptedKeys == null || !acceptedKeys.contains(keyToValidate)) {
+      if (target.containsKey(keyToValidate)) {
+        return KEY_ALREADY_EXISTS_ERROR;
+      }
+
+      // the stores that are overridden by me
+      long numStoresWithKeyOverridden = stack.allStores()
+          .filter(store -> store.containsKey(keyToValidate))
+          .filter(store -> store.service().order() > target.service().order())
+          .count();
+
+      // the stores that override me
+      long numStoresOverridingKey = stack.allStores()
+          .filter(store -> store.containsKey(keyToValidate))
+          .filter(store -> store.service().order() < target.service().order())
+          .count();
+
+      if (numStoresWithKeyOverridden > 0 && numStoresOverridingKey > 0) {
+        return KEY_OVERRIDES_AND_IS_OVERRIDDEN_WARNING;
+      }
+      if (numStoresWithKeyOverridden > 0) {
+        return KEY_OVERRIDES_OTHER_STORE_WARNING;
+      }
+      if (numStoresOverridingKey > 0) {
+        return KEY_IS_OVERRIDDEN_BY_OTHER_STORE_WARNING;
+      }
+    }
+
+    if (!ITranslation.KEY_REGEX.matcher(keyToValidate).matches()) {
+      return KEY_INVALID_ERROR;
+    }
+
+    return OK;
+  }
+}
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/AbstractTranslationPropertiesFile.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/AbstractTranslationPropertiesFile.java
index a1c42df..bdd5648 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/AbstractTranslationPropertiesFile.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/AbstractTranslationPropertiesFile.java
@@ -15,22 +15,21 @@
 
 import java.io.IOException;
 import java.io.InputStream;
-import java.util.HashMap;
 import java.util.Map;
-import java.util.Map.Entry;
+import java.util.Objects;
 import java.util.Optional;
-import java.util.Properties;
-import java.util.Set;
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
 
+import org.eclipse.scout.sdk.core.generator.properties.PropertiesGenerator;
 import org.eclipse.scout.sdk.core.s.environment.IEnvironment;
 import org.eclipse.scout.sdk.core.s.environment.IProgress;
 import org.eclipse.scout.sdk.core.s.nls.Language;
 import org.eclipse.scout.sdk.core.util.Ensure;
 import org.eclipse.scout.sdk.core.util.SdkException;
+import org.eclipse.scout.sdk.core.util.Strings;
 
 /**
  * <h3>{@link AbstractTranslationPropertiesFile}</h3>
@@ -39,12 +38,13 @@
  */
 public abstract class AbstractTranslationPropertiesFile implements ITranslationPropertiesFile {
 
-  private static final Pattern FILE_PATTERN = Pattern.compile("^[^_]*(?:_(" + Language.LANGUAGE_REGEX + "))?\\.properties$");
+  public static final String FILE_SUFFIX = ".properties";
+  public static final Pattern FILE_PATTERN = Pattern.compile("^[^_]+(?:_(" + Language.LANGUAGE_REGEX + "))?\\" + FILE_SUFFIX + "$");
 
   private final Language m_language;
   private final Supplier<InputStream> m_inputSupplier;
 
-  private Map<String, String> m_entries;
+  private PropertiesGenerator m_fileContent;
 
   protected AbstractTranslationPropertiesFile(Language language, Supplier<InputStream> contentSupplier) {
     m_language = Ensure.notNull(language);
@@ -52,10 +52,10 @@
   }
 
   private Map<String, String> entries() {
-    if (m_entries == null) {
+    if (m_fileContent == null) {
       throw newFail("Properties file has not been loaded yet.");
     }
-    return m_entries;
+    return m_fileContent.properties();
   }
 
   @Override
@@ -80,31 +80,31 @@
 
   @Override
   public boolean load(IProgress progress) {
-    Map<String, String> newEntries = readEntries(progress);
-    if (m_entries != null && m_entries.equals(newEntries)) {
+    PropertiesGenerator newContent = readEntries();
+    if (Objects.equals(m_fileContent, newContent)) {
       return false;
     }
-
-    m_entries = newEntries;
+    m_fileContent = newContent;
     return true;
   }
 
-  private Map<String, String> readEntries(IProgress progress) {
+  private PropertiesGenerator readEntries() {
     try (InputStream in = Ensure.notNull(m_inputSupplier.get())) {
-      return parse(in, progress);
+      return PropertiesGenerator.create(in);
     }
     catch (IOException e) {
-      throw new SdkException(e);
+      throw new SdkException("Error reading properties file for language '{}'.", language(), e);
     }
   }
 
   @Override
   public boolean setTranslation(String key, String text) {
     throwIfReadOnly();
+    Ensure.notBlank(key);
     if (text == null) {
       return removeTranslation(key);
     }
-    String oldTranslation = entries().put(Ensure.notBlank(key), text);
+    String oldTranslation = entries().put(key, text);
     return !text.equals(oldTranslation);
   }
 
@@ -117,72 +117,59 @@
   @Override
   public void flush(IEnvironment env, IProgress progress) {
     throwIfReadOnly();
-    writeEntries(entries(), env, progress);
+    writeEntries(m_fileContent, env, progress);
   }
 
-  private void throwIfReadOnly() {
+  protected void throwIfReadOnly() {
     if (!isEditable()) {
       throw new UnsupportedOperationException("Cannot modify a ready-only resource.");
     }
   }
 
-  protected abstract void writeEntries(Map<String, String> entries, IEnvironment env, IProgress progress);
+  protected abstract void writeEntries(PropertiesGenerator content, IEnvironment env, IProgress progress);
 
   /**
-   * Reads the contents of the specified {@link InputStream} as {@code .properties} file and returns the key-value
-   * mappings.
-   *
-   * @param in
-   *          The {@link InputStream} to load the data from. Must not be {@code null}.
-   * @param progress
-   *          The {@link IProgress} monitor.
-   * @return The mapping.
-   */
-  public static Map<String, String> parse(InputStream in, IProgress progress) {
-    Set<Entry<Object, Object>> entrySet = loadProperties(in, progress).entrySet();
-    Map<String, String> result = new HashMap<>(entrySet.size());
-    for (Entry<Object, Object> entry : entrySet) {
-      Object key = entry.getKey();
-      Object translation = entry.getValue();
-      if (key != null && translation != null) {
-        result.put(key.toString(), translation.toString());
-      }
-    }
-    return result;
-  }
-
-  /**
-   * Parses the language from the specified {@code .properties} file name.
-   *
+   * Parses the language from a translation .properties file respecting the given prefix.<br>
+   * The inverse operation is {@link #getPropertiesFileName(String, Language)}.
+   * 
    * @param fileName
-   *          The file name to parse. Must not be {@code null}.
-   * @return The parsed {@link Language}
-   * @throws IllegalArgumentException
-   *           if it cannot be parsed because it is no valid name.
+   *          The file name to parse. E.g. {@code "Texts_en_GB.properties"}. May not be {@code null}.
+   * @param prefix
+   *          An optional prefix the given file name must have to be accepted.
+   * @return An {@link Optional} holding the language of the given file name or an empty {@link Optional} if it cannot
+   *         be parsed (which might be because the prefix does not match).
    */
-  public static Language parseFromFileNameOrThrow(CharSequence fileName) {
-    Matcher matcher = FILE_PATTERN.matcher(fileName);
-    if (matcher.matches()) {
-      String languagePart = matcher.group(1);
-      if (languagePart == null) {
-        return Language.LANGUAGE_DEFAULT;
-      }
-      return Language.parseThrowingOnError(languagePart);
+  public static Optional<Language> parseLanguageFromFileName(String fileName, String prefix) {
+    if (Strings.isBlank(fileName)) {
+      return Optional.empty();
     }
-    throw newFail("Invalid file name '{}'. Language cannot be parsed.", fileName);
+    if (Strings.hasText(prefix) && !fileName.startsWith(prefix)) {
+      return Optional.empty();
+    }
+
+    Matcher matcher = FILE_PATTERN.matcher(fileName);
+    if (!matcher.matches()) {
+      return Optional.empty();
+    }
+
+    String languagePart = matcher.group(1);
+    if (Strings.isBlank(languagePart)) {
+      return Optional.of(Language.LANGUAGE_DEFAULT);
+    }
+    return Language.parse(languagePart);
   }
 
-  private static Map<Object, Object> loadProperties(InputStream in, IProgress progress) {
-    try {
-      Properties props = new Properties();
-      props.load(in);
-      return props;
-    }
-    catch (IOException e) {
-      throw new SdkException(e);
-    }
-    finally {
-      progress.setWorkRemaining(0);
-    }
+  /**
+   * Gets the filename for a {@code .properties} file using the specified prefix and {@link Language}.<br>
+   * This the inverse operation is {@link #parseLanguageFromFileName(String, String)}.
+   *
+   * @param prefix
+   *          The file prefix. Must not be {@code null}.
+   * @param language
+   *          The language. Must not be {@code null}.
+   * @return The file name.
+   */
+  public static String getPropertiesFileName(String prefix, Language language) {
+    return prefix + '_' + language.locale() + FILE_SUFFIX;
   }
 }
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/EditableTranslationFile.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/EditableTranslationFile.java
index 264ed57..b5f8aed 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/EditableTranslationFile.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/EditableTranslationFile.java
@@ -10,28 +10,21 @@
  */
 package org.eclipse.scout.sdk.core.s.nls.properties;
 
-import static java.util.stream.Collectors.toMap;
+import static org.eclipse.scout.sdk.core.util.Strings.isBlank;
 
 import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardOpenOption;
-import java.util.Arrays;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Properties;
-import java.util.regex.Pattern;
 
-import org.eclipse.scout.sdk.core.builder.ISourceBuilder;
+import org.eclipse.scout.sdk.core.generator.properties.PropertiesGenerator;
 import org.eclipse.scout.sdk.core.s.environment.IEnvironment;
 import org.eclipse.scout.sdk.core.s.environment.IProgress;
+import org.eclipse.scout.sdk.core.s.nls.Language;
 import org.eclipse.scout.sdk.core.util.Ensure;
 import org.eclipse.scout.sdk.core.util.SdkException;
-import org.eclipse.scout.sdk.core.util.Strings;
 
 /**
  * <h3>{@link EditableTranslationFile}</h3>
@@ -43,8 +36,8 @@
   private final Path m_file;
 
   @SuppressWarnings("findbugs:NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE")
-  public EditableTranslationFile(Path file) {
-    super(parseFromFileNameOrThrow(file.getFileName().toString()), () -> toStream(file));
+  public EditableTranslationFile(Path file, Language language) {
+    super(language, () -> toStream(file));
     m_file = Ensure.notNull(file);
   }
 
@@ -67,66 +60,30 @@
   }
 
   @Override
-  protected void writeEntries(Map<String, String> entries, IEnvironment env, IProgress progress) {
+  protected void writeEntries(PropertiesGenerator content, IEnvironment env, IProgress progress) {
     progress.init(100, "Write translation properties file");
 
     // remove empty texts
-    Map<String, String> filteredTexts = entries.entrySet().stream()
-        .filter(e -> !Strings.isBlank(e.getValue()))
-        .collect(toMap(Entry::getKey, Entry::getValue));
+    content.properties().entrySet().removeIf(entry -> isBlank(entry.getValue()) || isBlank(entry.getKey()));
+    progress.worked(10);
 
-    if (filteredTexts.isEmpty()) {
+    if (content.properties().isEmpty()) {
       // this file has no more texts: remove it
       try {
-        Files.deleteIfExists(file());
+        Files.deleteIfExists(path());
       }
       catch (IOException e) {
-        throw new SdkException("Unable to remove file '{}'.", file(), e);
+        throw new SdkException("Unable to remove file '{}'.", path(), e);
       }
-      progress.worked(100);
+      progress.worked(90);
       return;
     }
 
-    String[] propertiesEncodedLines = propertiesEncode(filteredTexts);
-    progress.worked(20);
-
-    Arrays.sort(propertiesEncodedLines);
-    progress.worked(10);
-
-    env.writeResource(b -> appendLines(b, propertiesEncodedLines), file(), progress.newChild(70));
+    env.writeResource(content, path(), progress.newChild(70));
+    progress.worked(90);
   }
 
-  private static void appendLines(ISourceBuilder<?> builder, String[] linesSorted) {
-    for (int i = firstNonCommentLine(linesSorted); i < linesSorted.length; i++) {
-      builder.append(linesSorted[i]).nl();
-    }
-  }
-
-  private static int firstNonCommentLine(String[] lines) {
-    int i = 0;
-    while (lines.length > i && lines[i].startsWith("#")) {
-      i++;
-    }
-    return i;
-  }
-
-  private static String[] propertiesEncode(Map<String, String> entries) {
-    Properties prop = new Properties();
-    //noinspection UseOfPropertiesAsHashtable
-    prop.putAll(entries);
-
-    try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
-      prop.store(out, null);
-      String content = out.toString(StandardCharsets.ISO_8859_1.name());
-      String systemNl = System.lineSeparator();
-      return Pattern.compile(systemNl).split(content);
-    }
-    catch (IOException e) {
-      throw new SdkException("Error encoding translations", e);
-    }
-  }
-
-  public Path file() {
+  public Path path() {
     return m_file;
   }
 }
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/PropertiesTextProviderService.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/PropertiesTextProviderService.java
index 90b2d1a..204c4c8 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/PropertiesTextProviderService.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/PropertiesTextProviderService.java
@@ -84,16 +84,6 @@
     return Optional.of(new PropertiesTextProviderService(txtSvc, folderName.toString(), filePrefix));
   }
 
-  public static boolean resourceMatchesPrefix(String resourceName, String prefix) {
-    if (resourceName == null) {
-      return false;
-    }
-    if (prefix == null) {
-      return false;
-    }
-    return resourceName.matches(prefix + "(?:_[a-zA-Z]{2}){0,3}\\.properties");
-  }
-
   /**
    * @return The module relative folder name without leading and ending folder delimiter.
    */
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/PropertiesTranslationStore.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/PropertiesTranslationStore.java
index fc88077..dc5bdcd 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/PropertiesTranslationStore.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/PropertiesTranslationStore.java
@@ -10,6 +10,8 @@
  */
 package org.eclipse.scout.sdk.core.s.nls.properties;
 
+import static java.util.Collections.unmodifiableMap;
+import static org.eclipse.scout.sdk.core.s.nls.properties.AbstractTranslationPropertiesFile.getPropertiesFileName;
 import static org.eclipse.scout.sdk.core.util.Ensure.newFail;
 
 import java.nio.file.Path;
@@ -182,11 +184,11 @@
         .map(f -> (EditableTranslationFile) f)
         .findAny()
         .orElseThrow(() -> newFail("Cannot create new language because the store '{}' is not editable.", this))
-        .file()
+        .path()
         .getParent();
 
     String fileName = getPropertiesFileName(service().filePrefix(), language);
-    ITranslationPropertiesFile newFile = new EditableTranslationFile(directory.resolve(fileName));
+    ITranslationPropertiesFile newFile = new EditableTranslationFile(directory.resolve(fileName), language);
     newFile.load(new NullProgress());
 
     setDirty(true);
@@ -194,19 +196,6 @@
     m_newFiles.add(newFile);
   }
 
-  /**
-   * Gets the filename for a {@code .properties} file using the specified prefix and {@link Language}.
-   *
-   * @param prefix
-   *          The file prefix. Must not be {@code null}.
-   * @param language
-   *          The language. Must not be {@code null}.
-   * @return The file name.
-   */
-  public static String getPropertiesFileName(String prefix, Language language) {
-    return prefix + '_' + language.locale() + ".properties";
-  }
-
   @Override
   public void flush(IEnvironment env, IProgress progress) {
     if (!isDirty()) {
@@ -245,11 +234,21 @@
   }
 
   @Override
+  public boolean containsLanguage(Language language) {
+    return m_files.containsKey(language);
+  }
+
+  @Override
   public Stream<String> keys() {
     return m_translations.keySet().stream();
   }
 
   @Override
+  public long size() {
+    return m_translations.size();
+  }
+
+  @Override
   public Optional<ITranslationEntry> get(String key) {
     return Optional.ofNullable(m_translations.get(key));
   }
@@ -295,6 +294,10 @@
     return m_files;
   }
 
+  public Map<Language, ITranslationPropertiesFile> files() {
+    return unmodifiableMap(m_files);
+  }
+
   @Override
   public int hashCode() {
     return service().hashCode();
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/ReadOnlyTranslationFile.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/ReadOnlyTranslationFile.java
index 53323f2..67a74d0 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/ReadOnlyTranslationFile.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/properties/ReadOnlyTranslationFile.java
@@ -11,9 +11,9 @@
 package org.eclipse.scout.sdk.core.s.nls.properties;
 
 import java.io.InputStream;
-import java.util.Map;
 import java.util.function.Supplier;
 
+import org.eclipse.scout.sdk.core.generator.properties.PropertiesGenerator;
 import org.eclipse.scout.sdk.core.s.environment.IEnvironment;
 import org.eclipse.scout.sdk.core.s.environment.IProgress;
 import org.eclipse.scout.sdk.core.s.nls.Language;
@@ -25,8 +25,19 @@
  */
 public class ReadOnlyTranslationFile extends AbstractTranslationPropertiesFile {
 
+  private final Object m_source;
+
   public ReadOnlyTranslationFile(Supplier<InputStream> contentSupplier, Language language) {
+    this(contentSupplier, language, null);
+  }
+
+  public ReadOnlyTranslationFile(Supplier<InputStream> contentSupplier, Language language, Object source) {
     super(language, contentSupplier);
+    m_source = source;
+  }
+
+  public Object source() {
+    return m_source;
   }
 
   @Override
@@ -35,7 +46,7 @@
   }
 
   @Override
-  protected void writeEntries(Map<String, String> entries, IEnvironment env, IProgress progress) {
-    // nop
+  protected void writeEntries(PropertiesGenerator content, IEnvironment env, IProgress progress) {
+    throwIfReadOnly();
   }
 }
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/query/TranslationKeysQuery.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/query/TranslationKeysQuery.java
index 75f926c..f19fad2 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/query/TranslationKeysQuery.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/query/TranslationKeysQuery.java
@@ -13,6 +13,8 @@
 import static java.util.Collections.emptySet;
 import static java.util.Collections.singletonList;
 import static java.util.stream.Collectors.toSet;
+import static org.eclipse.scout.sdk.core.util.Strings.hasText;
+import static org.eclipse.scout.sdk.core.util.Strings.indexOf;
 
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -37,9 +39,7 @@
 import org.eclipse.scout.sdk.core.s.util.search.FileQueryMatch;
 import org.eclipse.scout.sdk.core.s.util.search.FileRange;
 import org.eclipse.scout.sdk.core.s.util.search.IFileQuery;
-import org.eclipse.scout.sdk.core.util.Chars;
 import org.eclipse.scout.sdk.core.util.Ensure;
-import org.eclipse.scout.sdk.core.util.Strings;
 
 /**
  * <h3>{@link TranslationKeysQuery}</h3>
@@ -101,7 +101,7 @@
 
   protected boolean acceptCandidate(FileQueryInput candidate) {
     String actualExtension = candidate.fileExtension();
-    return Strings.hasText(actualExtension) && m_acceptedFileExtensions.contains(actualExtension);
+    return hasText(actualExtension) && m_acceptedFileExtensions.contains(actualExtension);
   }
 
   @Override
@@ -118,7 +118,7 @@
         char[] search = buildSearchPattern(suffix, key, suffixAndPrefix.getValue());
         int pos = 0;
         int index;
-        while ((index = Chars.indexOf(search, fileContent, pos)) >= 0) {
+        while ((index = indexOf(search, fileContent, pos)) >= 0) {
           FileRange match = new FileRange(candidate.file(), key, index + suffix.length, index + suffix.length + key.length());
           acceptNlsKeyMatch(key, match);
           pos = index + search.length;
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/query/TranslationPatterns.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/query/TranslationPatterns.java
index 0028ed4..ae9b24b 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/query/TranslationPatterns.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/nls/query/TranslationPatterns.java
@@ -13,6 +13,7 @@
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.scout.sdk.core.util.SourceState.isInCode;
 import static org.eclipse.scout.sdk.core.util.SourceState.isInString;
+import static org.eclipse.scout.sdk.core.util.Strings.nextLineEnd;
 
 import java.nio.CharBuffer;
 import java.util.Locale;
@@ -25,8 +26,8 @@
 import org.eclipse.scout.sdk.core.s.nls.ITranslation;
 import org.eclipse.scout.sdk.core.s.util.search.FileQueryInput;
 import org.eclipse.scout.sdk.core.s.util.search.FileRange;
-import org.eclipse.scout.sdk.core.util.Chars;
 import org.eclipse.scout.sdk.core.util.JavaTypes;
+import org.eclipse.scout.sdk.core.util.Strings;
 
 public final class TranslationPatterns {
 
@@ -65,9 +66,9 @@
     }
 
     protected static boolean lineEndsWithIgnoreMarker(char[] content, int offset) {
-      int lineEnd = Chars.nextLineEnd(content, offset); // because of the regex patterns the full content cannot be shorter than the ignore marker -> no need to check for the bounds
+      int lineEnd = Strings.nextLineEnd(content, offset); // because of the regex patterns the full content cannot be shorter than the ignore marker -> no need to check for the bounds
       CharBuffer end = CharBuffer.wrap(content, lineEnd - IGNORE_MARKER.length(), IGNORE_MARKER.length());
-      return Chars.equals(IGNORE_MARKER, end, false);
+      return Strings.equals(IGNORE_MARKER, end, false);
     }
 
     protected static boolean isKeyInCode(char[] content, int offset) {
@@ -220,7 +221,6 @@
     public Optional<FileRange> keyRangeIfAccept(MatchResult match, FileQueryInput fileQueryInput) {
       int keyGroup = 1;
       boolean isIgnored = textToNextNewLine(fileQueryInput.fileContent(), match.end(keyGroup))
-          .toString()
           .toUpperCase(Locale.ENGLISH)
           .endsWith(IGNORE_MARKER + " -->");
       if (isIgnored) {
@@ -229,9 +229,9 @@
       return Optional.of(toFileRange(match, fileQueryInput, keyGroup));
     }
 
-    public static CharSequence textToNextNewLine(char[] searchIn, int offset) {
-      int lineEnd = Chars.nextLineEnd(searchIn, offset);
-      return CharBuffer.wrap(searchIn, offset, lineEnd - offset);
+    public static String textToNextNewLine(char[] searchIn, int offset) {
+      int lineEnd = nextLineEnd(searchIn, offset);
+      return new String(searchIn, offset, lineEnd - offset);
     }
   }
 }
diff --git a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/util/ScoutTier.java b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/util/ScoutTier.java
index 289f311..9bf47fd 100644
--- a/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/util/ScoutTier.java
+++ b/org.eclipse.scout.sdk.core.s/src/main/java/org/eclipse/scout/sdk/core/s/util/ScoutTier.java
@@ -177,7 +177,7 @@
     if (to == this) {
       return name;
     }
-    return Strings.replace(name, '.' + tierName(), '.' + to.tierName());
+    return Strings.replace(name, '.' + tierName(), '.' + to.tierName()).toString();
   }
 
 }
diff --git a/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/ApiTestGenerator.java b/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/ApiTestGenerator.java
index 5f43153..ed55136 100644
--- a/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/ApiTestGenerator.java
+++ b/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/ApiTestGenerator.java
@@ -12,6 +12,7 @@
 
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
+import static org.eclipse.scout.sdk.core.util.Strings.toStringLiteral;
 
 import java.beans.Introspector;
 import java.lang.reflect.Field;
@@ -57,7 +58,7 @@
     List<IMethodParameter> parameterTypes = method.parameters().stream().collect(toList());
     if (!parameterTypes.isEmpty()) {
       for (int i = 0; i < parameterTypes.size(); i++) {
-        source.append('"').append(parameterTypes.get(i).dataType().reference()).append('"');
+        source.append(toStringLiteral(parameterTypes.get(i).dataType().reference()));
         if (i < parameterTypes.size() - 1) {
           source.append(", ");
         }
@@ -82,7 +83,7 @@
 
   protected static void buildField(IField field, String fieldVarName, StringBuilder source, String flagsRef) {
     source.append("assertHasFlags(").append(fieldVarName).append(", ").append(getFlagsSource(field.flags(), flagsRef)).append(");").append(NL);
-    source.append("assertFieldType(").append(fieldVarName).append(", ").append('"').append(field.dataType().reference()).append("\");").append(NL);
+    source.append("assertFieldType(").append(fieldVarName).append(", ").append(toStringLiteral(field.dataType().reference())).append(");").append(NL);
     createAnnotationsAsserts(field, source, fieldVarName);
   }
 
@@ -173,7 +174,7 @@
     if (!interfaces.isEmpty()) {
       source.append("assertHasSuperInterfaces(").append(typeVarName).append(", new String[]{");
       for (int i = 0; i < interfaces.size(); i++) {
-        source.append('"').append(interfaces.get(i).reference()).append('"');
+        source.append(toStringLiteral(interfaces.get(i).reference()));
         if (i < interfaces.size() - 1) {
           source.append(", ");
         }
diff --git a/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/CoreTestingUtils.java b/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/CoreTestingUtils.java
index 20d2f93..f2def55 100644
--- a/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/CoreTestingUtils.java
+++ b/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/CoreTestingUtils.java
@@ -99,8 +99,9 @@
    *          The text in which the new line characters should be normalized.
    * @return The input text with all {@code \r} removed.
    */
-  public static String normalizeNewLines(String text) {
-    return Strings.replace(text, Character.toString('\r'), "");
+  public static CharSequence normalizeNewLines(CharSequence text) {
+    //noinspection HardcodedLineSeparator
+    return Strings.replace(text, "\r", "");
   }
 
   /**
diff --git a/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/SdkAssertions.java b/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/SdkAssertions.java
index c88c686..6b1419b 100644
--- a/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/SdkAssertions.java
+++ b/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/SdkAssertions.java
@@ -42,6 +42,7 @@
 import org.eclipse.scout.sdk.core.util.JavaTypes;
 import org.eclipse.scout.sdk.core.util.SdkException;
 import org.eclipse.scout.sdk.core.util.Strings;
+import org.opentest4j.AssertionFailedError;
 
 /**
  * <h3>{@link SdkAssertions}</h3>
@@ -120,7 +121,7 @@
    *          The {@link ISourceGenerator} to use. Must not be {@code null}.
    */
   public static void assertEqualsRefFile(IJavaEnvironment env, String fileWithExpectedContent, ISourceGenerator<ISourceBuilder<?>> generator) {
-    String src = Ensure.notNull(generator).toSource(identity(), new JavaBuilderContext(Ensure.notNull(env))).toString();
+    CharSequence src = Ensure.notNull(generator).toSource(identity(), new JavaBuilderContext(Ensure.notNull(env)));
     assertEqualsRefFile(fileWithExpectedContent, src);
   }
 
@@ -132,15 +133,19 @@
    * @param actualContent
    *          The actual content to compare against.
    */
-  public static void assertEqualsRefFile(String fileWithExpectedContent, String actualContent) {
-    String refSrc;
+  public static void assertEqualsRefFile(String fileWithExpectedContent, CharSequence actualContent) {
+    CharSequence refSrc;
     try (InputStream in = SdkAssertions.class.getClassLoader().getResourceAsStream(Ensure.notNull(fileWithExpectedContent))) {
-      refSrc = Strings.fromInputStream(Ensure.notNull(in, "File '{}' could not be found on classpath.", fileWithExpectedContent), StandardCharsets.UTF_8).toString();
+      refSrc = Strings.fromInputStream(Ensure.notNull(in, "File '{}' could not be found on classpath.", fileWithExpectedContent), StandardCharsets.UTF_8);
     }
     catch (IOException e) {
       throw new SdkException(e);
     }
-    assertEquals(normalizeNewLines(refSrc), normalizeNewLines(actualContent));
+    CharSequence expected = normalizeNewLines(refSrc);
+    CharSequence actual = normalizeNewLines(actualContent);
+    if (!Strings.equals(expected, actual)) {
+      throw new AssertionFailedError(null, expected, actual);
+    }
   }
 
   /**
diff --git a/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/TestingElementCommentGenerator.java b/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/TestingElementCommentGenerator.java
index a04d595..18145b7 100644
--- a/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/TestingElementCommentGenerator.java
+++ b/org.eclipse.scout.sdk.core.test/src/main/java/org/eclipse/scout/sdk/core/testing/TestingElementCommentGenerator.java
@@ -24,6 +24,7 @@
  *
  * @since 6.1.0
  */
+@SuppressWarnings("HardcodedLineSeparator")
 public class TestingElementCommentGenerator implements IDefaultElementCommentGeneratorSpi {
 
   @Override
diff --git a/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/UnicodeTest.java b/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/UnicodeTest.java
index fee86e9..391d282 100644
--- a/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/UnicodeTest.java
+++ b/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/UnicodeTest.java
@@ -48,7 +48,7 @@
     testApiOfClassWithUnicode(type);
 
     assertEquals(ClassWithUnicode.UNICODE, type.fields().first().get().constantValue().get().as(String.class));
-    assertEqualsRefFile(REF_FILE_FOLDER + "MethodWithUnicode.txt", type.methods().first().get().source().get().asCharSequence().toString());
+    assertEqualsRefFile(REF_FILE_FOLDER + "MethodWithUnicode.txt", type.methods().first().get().source().get().asCharSequence());
     assertEqualsRefFile(env, REF_FILE_FOLDER + "ClassWithUnicode.txt", type.toWorkingCopy());
   }
 
diff --git a/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/annotation/AnnotationGeneratorTest.java b/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/annotation/AnnotationGeneratorTest.java
index 4f7911c..206cdf5 100644
--- a/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/annotation/AnnotationGeneratorTest.java
+++ b/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/annotation/AnnotationGeneratorTest.java
@@ -64,8 +64,8 @@
 
   @Test
   public void testSuppressWarnings() {
-    assertEquals("@SuppressWarnings({\"checked\", \"all\"})", AnnotationGenerator.createSupressWarnings("checked", "all").toJavaSource().toString());
-    assertEquals("@SuppressWarnings(\"checked\")", AnnotationGenerator.createSupressWarnings("checked").toJavaSource().toString());
+    assertEquals("@SuppressWarnings({\"checked\", \"all\"})", AnnotationGenerator.createSuppressWarnings("checked", "all").toJavaSource().toString());
+    assertEquals("@SuppressWarnings(\"checked\")", AnnotationGenerator.createSuppressWarnings("checked").toJavaSource().toString());
   }
 
   @Test
diff --git a/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/method/MethodGeneratorTest.java b/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/method/MethodGeneratorTest.java
index f519a4c..2a8b501 100644
--- a/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/method/MethodGeneratorTest.java
+++ b/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/method/MethodGeneratorTest.java
@@ -72,7 +72,7 @@
         .asPublic()
         .asAbstract()
         .withAnnotation(AnnotationGenerator.createOverride())
-        .withAnnotation(AnnotationGenerator.createSupressWarnings("checked"))
+        .withAnnotation(AnnotationGenerator.createSuppressWarnings("checked"))
         .withComment(IJavaElementCommentBuilder::appendDefaultElementComment)
         .withElementName("testMethod")
         .withException(IOException.class.getName())
diff --git a/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/properties/PropertiesGeneratorTest.java b/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/properties/PropertiesGeneratorTest.java
new file mode 100644
index 0000000..0728cc6
--- /dev/null
+++ b/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/properties/PropertiesGeneratorTest.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.core.generator.properties;
+
+import static java.lang.System.lineSeparator;
+import static java.util.Collections.singletonMap;
+import static java.util.function.Function.identity;
+import static java.util.stream.Collectors.joining;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.eclipse.scout.sdk.core.builder.BuilderContext;
+import org.junit.jupiter.api.Test;
+
+public class PropertiesGeneratorTest {
+
+  @Test
+  public void testCreateEmpty() {
+    PropertiesGenerator generator = PropertiesGenerator.create();
+    assertTrue(generator.headerLines().isEmpty());
+    assertTrue(generator.properties().isEmpty());
+  }
+
+  @Test
+  public void testPropertiesEncode() {
+    String key = "key";
+    String value = "ö\nä\r\nü";
+    Map<String, String> values = singletonMap(key, value);
+    PropertiesGenerator generator = PropertiesGenerator.create(values);
+    String generatedSource = generator.toSource(identity(), new BuilderContext()).toString();
+    assertEquals("key=\\u00F6\\n\\u00E4\\r\\n\\u00FC\n", generatedSource);
+  }
+
+  @Test
+  @SuppressWarnings({"SimplifiableJUnitAssertion", "EqualsWithItself", "EqualsBetweenInconvertibleTypes"})
+  public void testGenerator() throws IOException {
+    String[] prop1 = new String[]{"prop1", "1"};
+    String[] prop2 = new String[]{"a-prop2", "2"};
+    String[] lines = new String[]{
+        "# comment line 1",
+        "!comment line 2",
+        "  ",
+        prop1[0] + "=" + prop1[1],
+        prop2[0] + "=" + prop2[1]
+    };
+
+    String origContent = Stream.of(lines).collect(joining(lineSeparator()));
+    PropertiesGenerator generator = PropertiesGenerator.create(new ByteArrayInputStream(origContent.getBytes(PropertiesGenerator.ENCODING)));
+
+    // test content (parse)
+    assertEquals(Arrays.asList(lines[0], lines[1], lines[2]), generator.headerLines());
+    Map<String, String> expected = new HashMap<>();
+    expected.put(prop1[0], prop1[1]);
+    expected.put(prop2[0], prop2[1]);
+    assertEquals(expected, generator.properties());
+
+    // test write
+    String generatedSource = generator.toSource(identity(), new BuilderContext()).toString();
+    String expectedSource = lines[0] + "\n" +
+        lines[1] + "\n" +
+        lines[2] + "\n" +
+        lines[4] + "\n" +
+        lines[3] + "\n";
+    assertEquals(expectedSource, generatedSource);
+
+    // test compares
+    PropertiesGenerator generator2 = PropertiesGenerator.create(generator.properties(), generator.headerLines());
+    assertEquals(generator, generator2);
+    assertEquals(generator.hashCode(), generator2.hashCode());
+    assertTrue(generator.equals(generator));
+    assertFalse(generator.equals(""));
+  }
+}
diff --git a/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/transformer/WorkingCopyTransformerTest.java b/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/transformer/WorkingCopyTransformerTest.java
index ec23730..d70ad24 100644
--- a/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/transformer/WorkingCopyTransformerTest.java
+++ b/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/generator/transformer/WorkingCopyTransformerTest.java
@@ -263,7 +263,7 @@
             .withElementName("toString")) // override toString
         .withAllMethodsImplemented(transformer); // override all methods required by super types.
 
-    assertEqualsRefFile(env, REF_FILE_FOLDER + "TranformerTest1.txt", generator);
+    assertEqualsRefFile(env, REF_FILE_FOLDER + "TransformerTest1.txt", generator);
   }
 
   /**
diff --git a/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/util/CharsTest.java b/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/util/CharsTest.java
deleted file mode 100644
index d625aa6..0000000
--- a/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/util/CharsTest.java
+++ /dev/null
@@ -1,199 +0,0 @@
-/*
- * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the Eclipse Public License v1.0
- * which accompanies this distribution, and is available at
- * http://www.eclipse.org/legal/epl-v10.html
- *
- * Contributors:
- *     BSI Business Systems Integration AG - initial API and implementation
- */
-package org.eclipse.scout.sdk.core.util;
-
-import static org.eclipse.scout.sdk.core.util.Chars.indexOf;
-import static org.eclipse.scout.sdk.core.util.Chars.lastIndexOf;
-import static org.eclipse.scout.sdk.core.util.Chars.nextLineEnd;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import java.nio.CharBuffer;
-
-import org.junit.jupiter.api.Test;
-
-public class CharsTest {
-  @Test
-  public void testEqualsCharArray() {
-    char[] a = "".toCharArray();
-    char[] b = "xx".toCharArray();
-    char[] c = "xx".toCharArray();
-    char[] d = "yy".toCharArray();
-    char[] e = "xxx".toCharArray();
-    assertTrue(Chars.equals(a, a));
-    assertTrue(Chars.equals(c, b));
-    assertFalse(Chars.equals(null, b));
-    assertFalse(Chars.equals(a, null));
-    assertFalse(Chars.equals(c, d));
-    assertFalse(Chars.equals(c, e));
-    assertTrue(Chars.equals(null, (char[]) null));
-  }
-
-  @Test
-  public void testEqualsCharArrayIgnoreCase() {
-    char[] a = "".toCharArray();
-    char[] b = "xx".toCharArray();
-    char[] c = "xx".toCharArray();
-    char[] d = "yy".toCharArray();
-    char[] e = "xxx".toCharArray();
-    char[] f = "xXx".toCharArray();
-    assertTrue(Chars.equals(a, a, true));
-    assertTrue(Chars.equals(c, b, true));
-    assertFalse(Chars.equals(null, b, true));
-    assertFalse(Chars.equals(a, null, true));
-    assertFalse(Chars.equals(c, d, true));
-    assertFalse(Chars.equals(c, e, true));
-    assertTrue(Chars.equals(null, (char[]) null, true));
-
-    assertTrue(Chars.equals(a, a, false));
-    assertTrue(Chars.equals(c, b, false));
-    assertFalse(Chars.equals(null, b, false));
-    assertFalse(Chars.equals(a, null, false));
-    assertFalse(Chars.equals(c, d, false));
-    assertFalse(Chars.equals(c, e, false));
-    assertTrue(Chars.equals(null, (char[]) null, false));
-    assertTrue(Chars.equals(e, f, false));
-  }
-
-  @Test
-  public void testEqualsCharSequence() {
-    CharSequence a = "";
-    CharSequence b = CharBuffer.wrap("xx".toCharArray());
-    CharSequence c = "xx";
-    CharSequence d = "yy";
-    CharSequence e = "xxx";
-    assertTrue(Chars.equals(a, a));
-    assertTrue(Chars.equals(c, b));
-    assertFalse(Chars.equals(null, b));
-    assertFalse(Chars.equals(a, null));
-    assertFalse(Chars.equals(c, d));
-    assertFalse(Chars.equals(c, e));
-    assertTrue(Chars.equals(null, (CharSequence) null));
-  }
-
-  @Test
-  public void testEqualsCharSequenceIgnoreCase() {
-    CharSequence a = "";
-    CharSequence b = CharBuffer.wrap("xx".toCharArray());
-    CharSequence c = "xx";
-    CharSequence d = "yy";
-    CharSequence e = "xxx";
-    CharSequence f = "xXx";
-    assertTrue(Chars.equals(a, a, true));
-    assertTrue(Chars.equals(c, b, true));
-    assertFalse(Chars.equals(null, b, true));
-    assertFalse(Chars.equals(a, null, true));
-    assertFalse(Chars.equals(c, d, true));
-    assertFalse(Chars.equals(c, e, true));
-    assertTrue(Chars.equals(null, (CharSequence) null, true));
-
-    assertTrue(Chars.equals(a, a, false));
-    assertTrue(Chars.equals(c, b, false));
-    assertFalse(Chars.equals(null, b, false));
-    assertFalse(Chars.equals(a, null, false));
-    assertFalse(Chars.equals(c, d, false));
-    assertFalse(Chars.equals(c, e, false));
-    assertTrue(Chars.equals(null, (CharSequence) null, false));
-    assertTrue(Chars.equals(e, f, false));
-  }
-
-  @Test
-  public void testIndexOfCharArray() {
-    assertEquals(3, indexOf('d', "abcdraqrd".toCharArray(), 0, 4));
-    assertEquals(-1, indexOf('d', "abcdgaerd".toCharArray(), 0, 3));
-    assertEquals(3, indexOf('d', "abcdw4easd".toCharArray(), 0, 100));
-    assertEquals(-1, indexOf('x', "abcd".toCharArray(), 0, 100));
-    assertEquals(-1, indexOf('a', "abcdrgtd".toCharArray(), 1));
-    assertEquals(-1, indexOf('d', "abcd".toCharArray(), 1, 3));
-    assertEquals(-1, indexOf('x', "abcd".toCharArray()));
-    assertEquals(3, indexOf('d', "abcdasdfd".toCharArray()));
-  }
-
-  @Test
-  public void testIndexOfCharSequence() {
-    assertEquals(3, indexOf('d', "abcdraqrd", 0, 4));
-    assertEquals(-1, indexOf('d', "abcdgaerd", 0, 3));
-    assertEquals(3, indexOf('d', "abcdw4easd", 0, 100));
-    assertEquals(-1, indexOf('x', "abcd", 0, 100));
-    assertEquals(-1, indexOf('a', "abcdrgtd", 1));
-    assertEquals(-1, indexOf('d', "abcd", 1, 3));
-    assertEquals(-1, indexOf('x', "abcd"));
-    assertEquals(3, indexOf('d', "abcdasdfd"));
-  }
-
-  @Test
-  public void testIndexOfCharsInChars() {
-    assertEquals(-1, indexOf("abcdd".toCharArray(), "abc".toCharArray()));
-    assertEquals(-1, indexOf("abcdd".toCharArray(), "abc".toCharArray(), -1));
-    assertEquals(-1, indexOf("abcdd".toCharArray(), "abc".toCharArray(), -1, 3));
-    assertEquals(-1, indexOf("x".toCharArray(), "abc".toCharArray(), -1));
-    assertEquals(0, indexOf("".toCharArray(), "abcdd".toCharArray()));
-
-    assertEquals(-1, indexOf("abc".toCharArray(), "xxabcxx".toCharArray(), 0, 3));
-    assertEquals(-1, indexOf("abc".toCharArray(), "xxabcxx".toCharArray(), 3, 6));
-    assertEquals(2, indexOf("abc".toCharArray(), "xxabcxx".toCharArray(), 0, 5));
-    assertEquals(0, indexOf("abc".toCharArray(), "abc".toCharArray()));
-    assertEquals(-1, indexOf("abx".toCharArray(), "abc".toCharArray()));
-    assertEquals(-1, indexOf("abx".toCharArray(), "abcddd".toCharArray()));
-
-    assertEquals(-1, indexOf("abc".toCharArray(), "xxabcxx".toCharArray(), 0, 3, false));
-    assertEquals(-1, indexOf("abc".toCharArray(), "xxabcxx".toCharArray(), 3, 6, false));
-    assertEquals(2, indexOf("abc".toCharArray(), "xxabcxx".toCharArray(), 0, 5, false));
-    assertEquals(0, indexOf("abc".toCharArray(), "abc".toCharArray(), 0, 3, false));
-    assertEquals(-1, indexOf("abx".toCharArray(), "abc".toCharArray(), 0, 3, false));
-    assertEquals(-1, indexOf("abx".toCharArray(), "abcddd".toCharArray(), 0, 6, false));
-
-    assertEquals(-1, indexOf("abc".toCharArray(), "abc".toCharArray(), 3, 3, false));
-  }
-
-  @Test
-  public void testIndexOfCharsInChars2() {
-    char[] array = new char[]{'a', 'b', 'c'};
-    char[] array2 = new char[]{'a', 'b', 'c', 'a', 'a'};
-    assertTrue(indexOf(array, array2, -1, array2.length, true) < 0);
-  }
-
-  @Test
-  public void testIndexOfCharsInChars3() {
-    char[] array = new char[]{'a', 'b', 'c'};
-    char[] array2 = new char[]{'a', 'b', 'c', 'a', 'a'};
-    assertTrue(indexOf(array, array2, -1, array2.length, false) < 0);
-  }
-
-  @Test
-  public void testNextLineEnd() {
-    assertEquals(3, nextLineEnd("abc".toCharArray(), 0));
-    assertEquals(5, nextLineEnd("first\nsecond\nthird".toCharArray(), 0));
-    assertEquals(12, nextLineEnd("first\nsecond\nthird".toCharArray(), 6));
-    assertEquals(12, nextLineEnd("first\nsecond\r\nthird".toCharArray(), 6));
-    assertEquals(19, nextLineEnd("first\nsecond\r\nthird".toCharArray(), 14));
-    assertEquals(19, nextLineEnd("first\nsecond\r\nthird".toCharArray(), 100));
-    assertEquals(3, nextLineEnd("abc\n".toCharArray(), 0));
-    assertEquals(3, nextLineEnd("abc\r\n".toCharArray(), 0));
-    assertEquals(3, nextLineEnd("abc\r\n".toCharArray(), 3));
-    assertEquals(3, nextLineEnd("abc\r\n".toCharArray(), 4));
-    assertEquals(0, nextLineEnd("\nsecond".toCharArray(), 0));
-  }
-
-  @Test
-  public void testLastIndexOf() {
-    assertEquals(-1, lastIndexOf('x', "abc"));
-    assertEquals(1, lastIndexOf('x', "axc"));
-    assertEquals(3, lastIndexOf('x', "axcxd"));
-
-    assertEquals(-1, lastIndexOf('x', "abcxd", 0, 3));
-    assertEquals(1, lastIndexOf('x', "axcxd", 0, 3));
-    assertEquals(3, lastIndexOf('x', "axcxd", 0, 4));
-    assertEquals(5, lastIndexOf('x', "axcbbxd", 2));
-    assertEquals(-1, lastIndexOf('x', "axcbbxd", 2, 4));
-  }
-}
diff --git a/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/util/StringsTest.java b/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/util/StringsTest.java
index d40a896..f5d3ee2 100644
--- a/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/util/StringsTest.java
+++ b/org.eclipse.scout.sdk.core.test/src/test/java/org/eclipse/scout/sdk/core/util/StringsTest.java
@@ -11,9 +11,30 @@
 package org.eclipse.scout.sdk.core.util;
 
 import static java.lang.System.lineSeparator;
+import static org.eclipse.scout.sdk.core.util.Strings.compareTo;
+import static org.eclipse.scout.sdk.core.util.Strings.countMatches;
+import static org.eclipse.scout.sdk.core.util.Strings.endsWith;
+import static org.eclipse.scout.sdk.core.util.Strings.ensureStartWithUpperCase;
+import static org.eclipse.scout.sdk.core.util.Strings.escapeHtml;
 import static org.eclipse.scout.sdk.core.util.Strings.fromFileAsCharSequence;
 import static org.eclipse.scout.sdk.core.util.Strings.fromFileAsChars;
 import static org.eclipse.scout.sdk.core.util.Strings.fromFileAsString;
+import static org.eclipse.scout.sdk.core.util.Strings.fromInputStream;
+import static org.eclipse.scout.sdk.core.util.Strings.fromStringLiteral;
+import static org.eclipse.scout.sdk.core.util.Strings.fromThrowable;
+import static org.eclipse.scout.sdk.core.util.Strings.hasText;
+import static org.eclipse.scout.sdk.core.util.Strings.indexOf;
+import static org.eclipse.scout.sdk.core.util.Strings.isBlank;
+import static org.eclipse.scout.sdk.core.util.Strings.lastIndexOf;
+import static org.eclipse.scout.sdk.core.util.Strings.nextLineEnd;
+import static org.eclipse.scout.sdk.core.util.Strings.notBlank;
+import static org.eclipse.scout.sdk.core.util.Strings.notEmpty;
+import static org.eclipse.scout.sdk.core.util.Strings.repeat;
+import static org.eclipse.scout.sdk.core.util.Strings.replace;
+import static org.eclipse.scout.sdk.core.util.Strings.replaceEach;
+import static org.eclipse.scout.sdk.core.util.Strings.toCharArray;
+import static org.eclipse.scout.sdk.core.util.Strings.toStringLiteral;
+import static org.eclipse.scout.sdk.core.util.Strings.withoutQuotes;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -23,6 +44,7 @@
 
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
+import java.nio.CharBuffer;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -41,20 +63,20 @@
   @Test
   public void testInputStreamToString() throws IOException {
     String testData = "my test data";
-    assertEquals(testData, Strings.fromInputStream(new ByteArrayInputStream(testData.getBytes(StandardCharsets.UTF_16LE)), StandardCharsets.UTF_16LE).toString());
-    assertEquals(testData, Strings.fromInputStream(new ByteArrayInputStream(testData.getBytes(StandardCharsets.UTF_16BE)), StandardCharsets.UTF_16BE.name()).toString());
+    assertEquals(testData, fromInputStream(new ByteArrayInputStream(testData.getBytes(StandardCharsets.UTF_16LE)), StandardCharsets.UTF_16LE).toString());
+    assertEquals(testData, fromInputStream(new ByteArrayInputStream(testData.getBytes(StandardCharsets.UTF_16BE)), StandardCharsets.UTF_16BE.name()).toString());
   }
 
   @Test
   public void testToCharArray() {
-    assertArrayEquals("".toCharArray(), Strings.toCharArray(new StringBuilder()));
-    assertArrayEquals("abc".toCharArray(), Strings.toCharArray(new StringBuilder("abc")));
-    assertThrows(IllegalArgumentException.class, () -> Strings.toCharArray(null));
+    assertArrayEquals("".toCharArray(), toCharArray(new StringBuilder()));
+    assertArrayEquals("abc".toCharArray(), toCharArray(new StringBuilder("abc")));
+    assertThrows(IllegalArgumentException.class, () -> toCharArray(null));
   }
 
   @Test
   public void testFromThrowable() {
-    String s = Strings.fromThrowable(new Exception());
+    String s = fromThrowable(new Exception());
     assertFalse(s.startsWith(lineSeparator()));
     assertFalse(s.endsWith(lineSeparator()));
   }
@@ -88,139 +110,404 @@
 
   @Test
   public void testRepeat() {
-    assertNull(Strings.repeat(null, 1));
-    assertEquals("", Strings.repeat("", 10));
-    assertEquals("", Strings.repeat("asdf", 0));
-    assertEquals("", Strings.repeat("asdf", -1));
-    assertEquals("aaa", Strings.repeat("a", 3));
-    assertEquals("abab", Strings.repeat("ab", 2));
+    assertNull(repeat(null, 1));
+    assertEquals("", repeat("", 10));
+    assertEquals("", repeat("asdf", 0));
+    assertEquals("", repeat("asdf", -1));
+    assertEquals("aaa", repeat("a", 3).toString());
+    assertEquals("abab", repeat("ab", 2).toString());
   }
 
   @Test
   public void testEndsWith() {
-    assertTrue(Strings.endsWith("", ""));
-    assertTrue(Strings.endsWith("abc", ""));
-    assertFalse(Strings.endsWith(null, ""));
-    assertFalse(Strings.endsWith("abc", null));
-    assertFalse(Strings.endsWith("", null));
-    assertFalse(Strings.endsWith(null, null));
-    assertFalse(Strings.endsWith(null, "abc"));
-    assertFalse(Strings.endsWith("abc", "de"));
-    assertFalse(Strings.endsWith("abc", "abcde"));
-    assertTrue(Strings.endsWith("aabc", "bc"));
-    assertTrue(Strings.endsWith("aa  ", " "));
-    assertTrue(Strings.endsWith("", ""));
-    assertTrue(Strings.endsWith("abcd", "abcd"));
+    assertTrue(endsWith("", ""));
+    assertTrue(endsWith("abc", ""));
+    assertFalse(endsWith(null, ""));
+    assertFalse(endsWith("abc", null));
+    assertFalse(endsWith("", null));
+    assertFalse(endsWith(null, null));
+    assertFalse(endsWith(null, "abc"));
+    assertFalse(endsWith("abc", "de"));
+    assertFalse(endsWith("abc", "abcde"));
+    assertTrue(endsWith("aabc", "bc"));
+    assertTrue(endsWith("aa  ", " "));
+    assertTrue(endsWith("", ""));
+    assertTrue(endsWith("abcd", "abcd"));
   }
 
   @Test
   public void testReplace() {
-    assertNull(Strings.replace(null, null, null));
-    assertEquals("", Strings.replace("", null, null));
-    assertEquals("asdf", Strings.replace("asdf", null, "ff"));
-    assertEquals("asdf", Strings.replace("asdf", "f", null));
-    assertEquals("asdf", Strings.replace("asdf", "", null));
-    assertEquals("sdf", Strings.replace("asdf", "a", ""));
-    assertEquals("gsdf", Strings.replace("asdf", "a", "g"));
-    assertEquals("asdf", Strings.replace("asdf", "xx", "g"));
-  }
-
-  @Test
-  public void testReplaceSequence() {
-    assertNull(Strings.replace(null, 'a', 'b'));
-    assertEquals("", Strings.replace("", 'a', 'b'));
-    assertEquals("akdf", Strings.replace("asdf", 's', 'k'));
-    assertEquals("ksdf", Strings.replace("asdf", 'a', 'k'));
-    assertEquals("asdk", Strings.replace("asdf", 'f', 'k'));
+    assertNull(replace(null, null, null));
+    assertEquals("", replace("", null, null));
+    assertEquals("asdf", replace("asdf", null, "ff"));
+    assertEquals("asdf", replace("asdf", "f", null));
+    assertEquals("asdf", replace("asdf", "", null));
+    assertEquals("sdf", replace("asdf", "a", "").toString());
+    assertEquals("gsdf", replace("asdf", "a", "g").toString());
+    assertEquals("asdf", replace("asdf", "xx", "g").toString());
   }
 
   @Test
   public void testCountMatches() {
-    assertEquals(0, Strings.countMatches(null, "asdf"));
-    assertEquals(0, Strings.countMatches("", "sss"));
-    assertEquals(0, Strings.countMatches("abba", null));
-    assertEquals(0, Strings.countMatches("abba", ""));
-    assertEquals(2, Strings.countMatches("abba", "a"));
-    assertEquals(1, Strings.countMatches("abba", "ab"));
-    assertEquals(0, Strings.countMatches("abba", "xxx"));
+    assertEquals(0, countMatches(null, "asdf"));
+    assertEquals(0, countMatches("", "sss"));
+    assertEquals(0, countMatches("abba", null));
+    assertEquals(0, countMatches("abba", ""));
+    assertEquals(2, countMatches("abba", "a"));
+    assertEquals(1, countMatches("abba", "ab"));
+    assertEquals(0, countMatches("abba", "xxx"));
   }
 
   @Test
   public void testInputStreamToStringWrongCharset() {
-    assertThrows(IOException.class, () -> Strings.fromInputStream(new ByteArrayInputStream("test".getBytes(StandardCharsets.UTF_16LE)), "not-existing"));
+    assertThrows(IOException.class, () -> fromInputStream(new ByteArrayInputStream("test".getBytes(StandardCharsets.UTF_16LE)), "not-existing"));
   }
 
   @Test
   public void testEscapeHtml() {
-    assertEquals("", Strings.escapeHtml(""));
-    assertEquals("a&amp;&lt;&gt;&quot;&#47;&apos;&apos;b", Strings.escapeHtml("a&<>\"/''b"));
-    assertNull(Strings.escapeHtml(null));
+    assertEquals("", escapeHtml(""));
+    assertEquals("a&#38;&#60;&#62;&#92;&#47;&#39;&#39;b", escapeHtml("a&<>\"/''b").toString());
+    assertNull(escapeHtml(null));
   }
 
   @Test
   public void testIsBlankHasText() {
-    assertTrue(Strings.isBlank(null));
-    assertTrue(Strings.isBlank(""));
-    assertTrue(Strings.isBlank(" "));
-    assertFalse(Strings.isBlank("bob"));
-    assertFalse(Strings.isBlank("  bob  "));
-    assertTrue(Strings.isBlank("  \t\r\n  "));
+    assertTrue(isBlank(null));
+    assertTrue(isBlank(""));
+    assertTrue(isBlank(" "));
+    assertFalse(isBlank("bob"));
+    assertFalse(isBlank("  bob  "));
+    assertTrue(isBlank("  \t\r\n  "));
 
-    assertFalse(Strings.hasText(null));
-    assertFalse(Strings.hasText(""));
-    assertFalse(Strings.hasText(" "));
-    assertTrue(Strings.hasText("bob"));
-    assertTrue(Strings.hasText("  bob  "));
-    assertFalse(Strings.hasText("  \t\r\n  "));
+    assertFalse(hasText(null));
+    assertFalse(hasText(""));
+    assertFalse(hasText(" "));
+    assertTrue(hasText("bob"));
+    assertTrue(hasText("  bob  "));
+    assertFalse(hasText("  \t\r\n  "));
   }
 
   @Test
   public void testFromStringLiteral() {
-    assertNull(Strings.fromStringLiteral(null));
-    assertNull(Strings.fromStringLiteral("a"));
-    assertNull(Strings.fromStringLiteral("\"a\\nb"));
-    assertNull(Strings.fromStringLiteral("aaa\""));
-    assertEquals("a\nb", Strings.fromStringLiteral("\"a\\nb\""));
-    assertEquals("", Strings.fromStringLiteral("\"\""));
-    assertEquals("a\"b", Strings.fromStringLiteral("\"a\\\"b\""));
+    assertNull(fromStringLiteral(null));
+    assertEquals("a", fromStringLiteral("a"));
+    assertEquals("\"a\nb", fromStringLiteral("\"a\\nb").toString());
+    assertEquals("aaa\"", fromStringLiteral("aaa\"").toString());
+    assertEquals("a\nb", fromStringLiteral("\"a\\nb\"").toString());
+    assertEquals("", fromStringLiteral("\"\"").toString());
+    assertEquals("a\"b", fromStringLiteral("\"a\\\"b\"").toString());
   }
 
   @Test
   public void testToStringLiteral() {
-    assertEquals("\"a\\nb\"", Strings.toStringLiteral("a\nb"));
-    assertEquals("\"a\\\"b\"", Strings.toStringLiteral("a\"b"));
-    assertNull(Strings.toStringLiteral(null));
-    String in = "teststring \na\"";
-    assertEquals(in, Strings.fromStringLiteral(Strings.toStringLiteral(in)));
+    assertEquals("\"a\\nb\"", toStringLiteral("a\nb").toString());
+    assertEquals("\"a\\\"b\"", toStringLiteral("a\"b").toString());
+    assertNull(toStringLiteral(null));
+    CharSequence in = "teststring \na\"";
+    assertEquals(in, fromStringLiteral(toStringLiteral(in)).toString());
+  }
+
+  @Test
+  public void testWithoutQuotes() {
+    assertEquals("", withoutQuotes(""));
+    assertNull(withoutQuotes(null));
+    assertEquals("a", withoutQuotes("a"));
+    assertEquals("aaa", withoutQuotes("aaa"));
+
+    assertEquals("aaa", withoutQuotes("'aaa'"));
+    assertEquals("'aaa'", withoutQuotes("'aaa'", true, false, true));
+
+    assertEquals("aaa", withoutQuotes("`aaa`"));
+    assertEquals("`aaa`", withoutQuotes("`aaa`", true, true, false));
+
+    assertEquals("aaa", withoutQuotes("\"aaa\""));
+    assertEquals("\"aaa\"", withoutQuotes("\"aaa\"", false, true, false));
+
+    assertEquals("'aaa'", withoutQuotes("'aaa'", false, false, false));
+    assertEquals("'`aaa`'", withoutQuotes("'`aaa`'", false, false, true));
+    assertEquals("'aaa'", withoutQuotes("''aaa''", false, true, false));
   }
 
   @Test
   public void testReplaceEach() {
-    assertNull(Strings.replaceEach(null, new String[]{"aa"}, new String[]{"bb"}));
-    assertEquals("", Strings.replaceEach("", new String[]{"aa"}, new String[]{"bb"}));
-    assertEquals("aba", Strings.replaceEach("aba", null, null));
-    assertEquals("aba", Strings.replaceEach("aba", new String[0], null));
-    assertEquals("aba", Strings.replaceEach("aba", null, new String[0]));
-    assertEquals("aba", Strings.replaceEach("aba", new String[]{"a"}, null));
-    assertEquals("b", Strings.replaceEach("aba", new String[]{"a"}, new String[]{""}));
-    assertEquals("aba", Strings.replaceEach("aba", new String[]{null}, new String[]{"a"}));
-    assertEquals("aba", Strings.replaceEach("aba", new String[]{"b"}, new String[]{null}));
-    assertEquals("wcte", Strings.replaceEach("abcde", new String[]{"ab", "d"}, new String[]{"w", "t"}));
-    assertEquals("abcde", Strings.replaceEach("abcde", new String[]{"ab", "d"}, new String[]{}));
-    assertThrows(IllegalArgumentException.class, () -> assertEquals("abcde", Strings.replaceEach("abcde", new String[]{"ab", "d"}, new String[]{"a"})));
+    assertNull(replaceEach(null, new String[]{"aa"}, new String[]{"bb"}));
+    assertEquals("", replaceEach("", new String[]{"aa"}, new String[]{"bb"}));
+    assertEquals("aba", replaceEach("aba", null, null));
+    assertEquals("aba", replaceEach("aba", new String[0], null));
+    assertEquals("aba", replaceEach("aba", null, new String[0]));
+    assertEquals("aba", replaceEach("aba", new String[]{"a"}, null));
+    assertEquals("b", replaceEach("aba", new String[]{"a"}, new String[]{""}).toString());
+    assertEquals("aba", replaceEach("aba", new String[]{null}, new String[]{"a"}).toString());
+    assertEquals("aba", replaceEach("aba", new String[]{"b"}, new String[]{null}).toString());
+    assertEquals("wcte", replaceEach("abcde", new String[]{"ab", "d"}, new String[]{"w", "t"}).toString());
+    assertEquals("abcde", replaceEach("abcde", new String[]{"ab", "d"}, new String[]{}).toString());
+    assertThrows(IllegalArgumentException.class, () -> assertEquals("abcde", replaceEach("abcde", new String[]{"ab", "d"}, new String[]{"a"}).toString()));
   }
 
   @Test
   public void testEnsureStartWithUpperCase() {
-    assertNull(Strings.ensureStartWithUpperCase(null));
-    assertEquals("", Strings.ensureStartWithUpperCase(""));
-    assertEquals("  ", Strings.ensureStartWithUpperCase("  "));
-    assertEquals("A", Strings.ensureStartWithUpperCase("a"));
-    assertEquals("Ab", Strings.ensureStartWithUpperCase("ab"));
-    assertEquals("A", Strings.ensureStartWithUpperCase("A"));
-    assertEquals("Ab", Strings.ensureStartWithUpperCase("Ab"));
-    assertEquals("ABC", Strings.ensureStartWithUpperCase("ABC"));
-    assertEquals("Abc", Strings.ensureStartWithUpperCase("abc"));
-    assertEquals("ABC", Strings.ensureStartWithUpperCase("aBC"));
+    assertNull(ensureStartWithUpperCase(null));
+    assertEquals("", ensureStartWithUpperCase(""));
+    assertEquals("  ", ensureStartWithUpperCase("  ").toString());
+    assertEquals("A", ensureStartWithUpperCase("a").toString());
+    assertEquals("Ab", ensureStartWithUpperCase("ab").toString());
+    assertEquals("A", ensureStartWithUpperCase("A").toString());
+    assertEquals("Ab", ensureStartWithUpperCase("Ab").toString());
+    assertEquals("ABC", ensureStartWithUpperCase("ABC").toString());
+    assertEquals("Abc", ensureStartWithUpperCase("abc").toString());
+    assertEquals("ABC", ensureStartWithUpperCase("aBC").toString());
+  }
+
+  @Test
+  public void testEqualsCharArray() {
+    char[] a = "".toCharArray();
+    char[] b = "xx".toCharArray();
+    char[] c = "xx".toCharArray();
+    char[] d = "yy".toCharArray();
+    char[] e = "xxx".toCharArray();
+    assertTrue(Strings.equals(a, a));
+    assertTrue(Strings.equals(c, b));
+    assertFalse(Strings.equals(null, b));
+    assertFalse(Strings.equals(a, null));
+    assertFalse(Strings.equals(c, d));
+    assertFalse(Strings.equals(c, e));
+    assertTrue(Strings.equals(null, (char[]) null));
+  }
+
+  @Test
+  public void testEqualsCharArrayIgnoreCase() {
+    char[] a = "".toCharArray();
+    char[] b = "xx".toCharArray();
+    char[] c = "xx".toCharArray();
+    char[] d = "yy".toCharArray();
+    char[] e = "xxx".toCharArray();
+    char[] f = "xXx".toCharArray();
+    assertTrue(Strings.equals(a, a, true));
+    assertTrue(Strings.equals(c, b, true));
+    assertFalse(Strings.equals(null, b, true));
+    assertFalse(Strings.equals(a, null, true));
+    assertFalse(Strings.equals(c, d, true));
+    assertFalse(Strings.equals(c, e, true));
+    assertTrue(Strings.equals(null, (char[]) null, true));
+
+    assertTrue(Strings.equals(a, a, false));
+    assertTrue(Strings.equals(c, b, false));
+    assertFalse(Strings.equals(null, b, false));
+    assertFalse(Strings.equals(a, null, false));
+    assertFalse(Strings.equals(c, d, false));
+    assertFalse(Strings.equals(c, e, false));
+    assertTrue(Strings.equals(null, (char[]) null, false));
+    assertTrue(Strings.equals(e, f, false));
+  }
+
+  @Test
+  public void testEqualsCharSequence() {
+    CharSequence a = "";
+    CharSequence b = CharBuffer.wrap("xx".toCharArray());
+    CharSequence c = "xx";
+    CharSequence d = "yy";
+    CharSequence e = "xxx";
+    assertTrue(Strings.equals(a, a));
+    assertTrue(Strings.equals(c, b));
+    assertFalse(Strings.equals(null, b));
+    assertFalse(Strings.equals(a, null));
+    assertFalse(Strings.equals(c, d));
+    assertFalse(Strings.equals(c, e));
+    assertTrue(Strings.equals(null, (CharSequence) null));
+  }
+
+  @Test
+  public void testEqualsCharSequenceIgnoreCase() {
+    CharSequence a = "";
+    CharSequence b = CharBuffer.wrap("xx".toCharArray());
+    CharSequence c = "xx";
+    CharSequence d = "yy";
+    CharSequence e = "xxx";
+    CharSequence f = "xXx";
+    assertTrue(Strings.equals(a, a, true));
+    assertTrue(Strings.equals(c, b, true));
+    assertFalse(Strings.equals(null, b, true));
+    assertFalse(Strings.equals(a, null, true));
+    assertFalse(Strings.equals(c, d, true));
+    assertFalse(Strings.equals(c, e, true));
+    assertTrue(Strings.equals(null, (CharSequence) null, true));
+
+    assertTrue(Strings.equals(a, a, false));
+    assertTrue(Strings.equals(c, b, false));
+    assertFalse(Strings.equals(null, b, false));
+    assertFalse(Strings.equals(a, null, false));
+    assertFalse(Strings.equals(c, d, false));
+    assertFalse(Strings.equals(c, e, false));
+    assertTrue(Strings.equals(null, (CharSequence) null, false));
+    assertTrue(Strings.equals(e, f, false));
+  }
+
+  @Test
+  public void testIndexOfCharArray() {
+    assertEquals(3, indexOf('d', "abcdraqrd".toCharArray(), 0, 4));
+    assertEquals(-1, indexOf('d', "abcdgaerd".toCharArray(), 0, 3));
+    assertEquals(3, indexOf('d', "abcdw4easd".toCharArray(), 0, 100));
+    assertEquals(-1, indexOf('x', "abcd".toCharArray(), 0, 100));
+    assertEquals(-1, indexOf('a', "abcdrgtd".toCharArray(), 1));
+    assertEquals(-1, indexOf('d', "abcd".toCharArray(), 1, 3));
+    assertEquals(-1, indexOf('x', "abcd".toCharArray()));
+    assertEquals(3, indexOf('d', "abcdasdfd".toCharArray()));
+  }
+
+  @Test
+  public void testIndexOfCharSequence() {
+    assertEquals(3, indexOf('d', "abcdraqrd", 0, 4));
+    assertEquals(-1, indexOf('d', "abcdgaerd", 0, 3));
+    assertEquals(3, indexOf('d', "abcdw4easd", 0, 100));
+    assertEquals(-1, indexOf('x', "abcd", 0, 100));
+    assertEquals(-1, indexOf('a', "abcdrgtd", 1));
+    assertEquals(-1, indexOf('d', "abcd", 1, 3));
+    assertEquals(-1, indexOf('x', "abcd"));
+    assertEquals(3, indexOf('d', "abcdasdfd"));
+  }
+
+  @Test
+  public void testIndexOfCharsInChars() {
+    assertEquals(-1, indexOf("abcdd".toCharArray(), "abc".toCharArray()));
+    assertEquals(-1, indexOf("abcdd".toCharArray(), "abc".toCharArray(), -1));
+    assertEquals(-1, indexOf("abcdd".toCharArray(), "abc".toCharArray(), -1, 3));
+    assertEquals(-1, indexOf("x".toCharArray(), "abc".toCharArray(), -1));
+    assertEquals(0, indexOf("".toCharArray(), "abcdd".toCharArray()));
+
+    assertEquals(-1, indexOf("abc".toCharArray(), "xxabcxx".toCharArray(), 0, 3));
+    assertEquals(-1, indexOf("abc".toCharArray(), "xxabcxx".toCharArray(), 3, 6));
+    assertEquals(2, indexOf("abc".toCharArray(), "xxabcxx".toCharArray(), 0, 5));
+    assertEquals(0, indexOf("abc".toCharArray(), "abc".toCharArray()));
+    assertEquals(-1, indexOf("abx".toCharArray(), "abc".toCharArray()));
+    assertEquals(-1, indexOf("abx".toCharArray(), "abcddd".toCharArray()));
+
+    assertEquals(-1, indexOf("abc".toCharArray(), "xxabcxx".toCharArray(), 0, 3, false));
+    assertEquals(-1, indexOf("abc".toCharArray(), "xxabcxx".toCharArray(), 3, 6, false));
+    assertEquals(2, indexOf("abc".toCharArray(), "xxabcxx".toCharArray(), 0, 5, false));
+    assertEquals(0, indexOf("abc".toCharArray(), "abc".toCharArray(), 0, 3, false));
+    assertEquals(-1, indexOf("abx".toCharArray(), "abc".toCharArray(), 0, 3, false));
+    assertEquals(-1, indexOf("abx".toCharArray(), "abcddd".toCharArray(), 0, 6, false));
+
+    assertEquals(-1, indexOf("abc".toCharArray(), "abc".toCharArray(), 3, 3, false));
+  }
+
+  @Test
+  public void testIndexOfCharsInChars2() {
+    char[] array = new char[]{'a', 'b', 'c'};
+    char[] array2 = new char[]{'a', 'b', 'c', 'a', 'a'};
+    assertTrue(indexOf(array, array2, -1, array2.length, true) < 0);
+  }
+
+  @Test
+  public void testIndexOfCharsInChars3() {
+    char[] array = new char[]{'a', 'b', 'c'};
+    char[] array2 = new char[]{'a', 'b', 'c', 'a', 'a'};
+    assertTrue(indexOf(array, array2, -1, array2.length, false) < 0);
+  }
+
+  @Test
+  public void testIndexOfCharSequenceInCharSequence() {
+    assertEquals(-1, indexOf("abcdd", "abc"));
+    assertEquals(-1, indexOf("abcdd", "abc", -1));
+    assertEquals(-1, indexOf("abcdd", "abc", -1, 3));
+    assertEquals(-1, indexOf("x", "abc", -1));
+    assertEquals(0, indexOf("", "abcdd"));
+
+    assertEquals(-1, indexOf("abc", "xxabcxx", 0, 3));
+    assertEquals(-1, indexOf("abc", "xxabcxx", 3, 6));
+    assertEquals(2, indexOf("abc", "xxabcxx", 0, 5));
+    assertEquals(0, indexOf("abc", "abc"));
+    assertEquals(-1, indexOf("abx", "abc"));
+    assertEquals(-1, indexOf("abx", "abcddd"));
+
+    assertEquals(-1, indexOf("abc", "xxabcxx", 0, 3));
+    assertEquals(-1, indexOf("abc", "xxabcxx", 3, 6));
+    assertEquals(2, indexOf("abc", "xxabcxx", 0, 5));
+    assertEquals(0, indexOf("abc", "abc", 0, 3));
+    assertEquals(-1, indexOf("abx", "abc", 0, 3));
+    assertEquals(-1, indexOf("abx", "abcddd", 0, 6));
+
+    assertEquals(-1, indexOf("abc", "abc", 3, 3));
+  }
+
+  @Test
+  public void testIndexOfCharSequenceInCharSequence2() {
+    CharSequence array = "abc";
+    CharSequence array2 = "abcaa";
+    assertTrue(indexOf(array, array2, -1, array2.length()) < 0);
+  }
+
+  @Test
+  public void testIndexOfCharSequenceInCharSequence3() {
+    CharSequence array = "abc";
+    CharSequence array2 = "abcaa";
+    assertTrue(indexOf(array, array2, -1, array2.length()) < 0);
+  }
+
+  @Test
+  public void testNextLineEnd() {
+    assertEquals(3, nextLineEnd("abc".toCharArray(), 0));
+    assertEquals(5, nextLineEnd("first\nsecond\nthird".toCharArray(), 0));
+    assertEquals(12, nextLineEnd("first\nsecond\nthird".toCharArray(), 6));
+    assertEquals(12, nextLineEnd("first\nsecond\r\nthird".toCharArray(), 6));
+    assertEquals(19, nextLineEnd("first\nsecond\r\nthird".toCharArray(), 14));
+    assertEquals(19, nextLineEnd("first\nsecond\r\nthird".toCharArray(), 100));
+    assertEquals(3, nextLineEnd("abc\n".toCharArray(), 0));
+    assertEquals(3, nextLineEnd("abc\r\n".toCharArray(), 0));
+    assertEquals(3, nextLineEnd("abc\r\n".toCharArray(), 3));
+    assertEquals(3, nextLineEnd("abc\r\n".toCharArray(), 4));
+    assertEquals(0, nextLineEnd("\nsecond".toCharArray(), 0));
+  }
+
+  @Test
+  public void testLastIndexOf() {
+    assertEquals(-1, lastIndexOf('x', "abc"));
+    assertEquals(1, lastIndexOf('x', "axc"));
+    assertEquals(3, lastIndexOf('x', "axcxd"));
+
+    assertEquals(-1, lastIndexOf('x', "abcxd", 0, 3));
+    assertEquals(1, lastIndexOf('x', "axcxd", 0, 3));
+    assertEquals(3, lastIndexOf('x', "axcxd", 0, 4));
+    assertEquals(5, lastIndexOf('x', "axcbbxd", 2));
+    assertEquals(-1, lastIndexOf('x', "axcbbxd", 2, 4));
+  }
+
+  @Test
+  public void testReplaceSequence() {
+    assertNull(replace(null, 'a', 'b'));
+    assertEquals("", replace("", 'a', 'b'));
+    assertEquals("akdf", replace("asdf", 's', 'k').toString());
+    assertEquals("ksdf", replace("asdf", 'a', 'k').toString());
+    assertEquals("asdk", replace("asdf", 'f', 'k').toString());
+  }
+
+  @Test
+  public void testNotEmpty() {
+    assertFalse(notEmpty(null).isPresent());
+    assertTrue(notEmpty("a").isPresent());
+    assertTrue(notEmpty(" ").isPresent());
+    assertTrue(notEmpty(" a ").isPresent());
+    assertFalse(notEmpty("").isPresent());
+  }
+
+  @Test
+  public void testNotBlank() {
+    assertFalse(notBlank(null).isPresent());
+    assertTrue(notBlank("a").isPresent());
+    assertFalse(notBlank(" ").isPresent());
+    assertTrue(notBlank(" a ").isPresent());
+    assertFalse(notBlank("").isPresent());
+  }
+
+  @Test
+  public void testCompareTo() {
+    assertTrue(compareTo(null, "a") < 0);
+    assertTrue(compareTo("b", null) > 0);
+    assertTrue(compareTo("b", "a") > 0);
+    assertTrue(compareTo("a", "b") < 0);
+    assertEquals(0, compareTo("a", "a"));
+    assertEquals(0, compareTo(null, null));
+    assertEquals(0, compareTo("", ""));
+    assertTrue(compareTo("a", "ab") < 0);
   }
 }
diff --git a/org.eclipse.scout.sdk.core.test/src/test/resources/org/eclipse/scout/sdk/core/generator/transformer/TranformerTest1.txt b/org.eclipse.scout.sdk.core.test/src/test/resources/org/eclipse/scout/sdk/core/generator/transformer/TransformerTest1.txt
similarity index 100%
rename from org.eclipse.scout.sdk.core.test/src/test/resources/org/eclipse/scout/sdk/core/generator/transformer/TranformerTest1.txt
rename to org.eclipse.scout.sdk.core.test/src/test/resources/org/eclipse/scout/sdk/core/generator/transformer/TransformerTest1.txt
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/AbstractSourceBuilder.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/AbstractSourceBuilder.java
index 97e8c45..0c00ebf 100644
--- a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/AbstractSourceBuilder.java
+++ b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/AbstractSourceBuilder.java
@@ -48,6 +48,11 @@
   }
 
   @Override
+  public TYPE appendLine(CharSequence s) {
+    return append(s).nl();
+  }
+
+  @Override
   public TYPE append(boolean b) {
     return append(Boolean.toString(b));
   }
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/ISourceBuilder.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/ISourceBuilder.java
index 9ad5e09..13b7db3 100644
--- a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/ISourceBuilder.java
+++ b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/ISourceBuilder.java
@@ -129,6 +129,17 @@
   TYPE append(long l);
 
   /**
+   * Appends the {@link CharSequence} given to this builder and adds a line separator afterwards.
+   * 
+   * @param s
+   *          The {@link CharSequence} to append. Must not be {@code null}.
+   * @return This builder
+   * @see #nl()
+   * @see #append(CharSequence)
+   */
+  TYPE appendLine(CharSequence s);
+
+  /**
    * Appends a newline delimiter to this {@link ISourceBuilder}. The delimiter is specified by the
    * {@link IBuilderContext#lineDelimiter()} associated with this {@link ISourceBuilder}.
    *
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/java/expression/ExpressionBuilder.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/java/expression/ExpressionBuilder.java
index ac6868b..c6c3b3a 100644
--- a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/java/expression/ExpressionBuilder.java
+++ b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/java/expression/ExpressionBuilder.java
@@ -75,7 +75,7 @@
   }
 
   @Override
-  public TYPE stringLiteral(String literalValue) {
+  public TYPE stringLiteral(CharSequence literalValue) {
     if (literalValue == null) {
       return nullLiteral();
     }
@@ -83,7 +83,7 @@
   }
 
   @Override
-  public TYPE appendDefaultValueOf(String dataTypeFqn) {
+  public TYPE appendDefaultValueOf(CharSequence dataTypeFqn) {
     String defaultVal = JavaTypes.defaultValueOf(dataTypeFqn);
     if (defaultVal != null) {
       return append(defaultVal);
@@ -92,17 +92,17 @@
   }
 
   @Override
-  public TYPE enumValue(String enumType, CharSequence enumField) {
+  public TYPE enumValue(CharSequence enumType, CharSequence enumField) {
     return ref(enumType).dot().append(Ensure.notNull(enumField));
   }
 
   @Override
-  public TYPE stringLiteralArray(String[] elements, boolean formatWithNewlines) {
+  public TYPE stringLiteralArray(CharSequence[] elements, boolean formatWithNewlines) {
     return stringLiteralArray(elements, formatWithNewlines, false);
   }
 
   @Override
-  public TYPE stringLiteralArray(String[] elements, boolean formatWithNewlines, boolean stringLiteralOnSingleElementArray) {
+  public TYPE stringLiteralArray(CharSequence[] elements, boolean formatWithNewlines, boolean stringLiteralOnSingleElementArray) {
     Ensure.notNull(elements);
     if (stringLiteralOnSingleElementArray && elements.length == 1) {
       return stringLiteral(elements[0]);
@@ -111,7 +111,7 @@
   }
 
   @Override
-  public TYPE stringLiteralArray(Stream<String> elements, boolean formatWithNewlines) {
+  public TYPE stringLiteralArray(Stream<? extends CharSequence> elements, boolean formatWithNewlines) {
     Stream<ISourceGenerator<ISourceBuilder<?>>> stringLiteralGenerators = Ensure.notNull(elements)
         .<ISourceGenerator<IExpressionBuilder<?>>> map(e -> b -> b.stringLiteral(e))
         .map(g -> g.generalize(ExpressionBuilder::create));
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/java/expression/IExpressionBuilder.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/java/expression/IExpressionBuilder.java
index a333b83..56bbb55 100644
--- a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/java/expression/IExpressionBuilder.java
+++ b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/builder/java/expression/IExpressionBuilder.java
@@ -36,6 +36,7 @@
    *          Specifies if a new line should be created for each array element.
    * @return This builder
    */
+  @SuppressWarnings("HtmlTagCanBeJavadocTag")
   TYPE array(Stream<? extends ISourceGenerator<ISourceBuilder<?>>> elements, boolean formatWithNewlines);
 
   /**
@@ -47,7 +48,7 @@
    *          The name of the enum field to reference. Must not be {@code null}.
    * @return This builder
    */
-  TYPE enumValue(String enumType, CharSequence enumField);
+  TYPE enumValue(CharSequence enumType, CharSequence enumField);
 
   /**
    * Appends a class literal for the given type.
@@ -61,9 +62,9 @@
   TYPE classLiteral(CharSequence reference);
 
   /**
-   * Appends a {@link String} literal with given value. The value is automatically escaped as necessary and surrounded
-   * with quotes.<br>
-   * If the specified value is {@code null}, a null literal is appended (see {@link #nullLiteral()}).
+   * Appends a string literal with given value. The value is automatically escaped as necessary and surrounded with
+   * double quotes.<br>
+   * If the specified value is {@code null}, a null literal is appended instead (see {@link #nullLiteral()}).
    * <p>
    * <b>Example:</b> {@code abc"def} -> {@code "abc\"def"}
    *
@@ -71,7 +72,7 @@
    *          The literal value without leading and trailing quotes or {@code null}.
    * @return This builder
    */
-  TYPE stringLiteral(String literalValue);
+  TYPE stringLiteral(CharSequence literalValue);
 
   /**
    * Appends {@code null}.
@@ -81,8 +82,8 @@
   TYPE nullLiteral();
 
   /**
-   * Appends a {@link String} array with the elements from the specified {@link Stream}. The values are automatically
-   * escaped as necessary and surrounded with quotes.
+   * Appends a string array with the elements from the specified {@link Stream}. The values are automatically escaped as
+   * necessary and surrounded with quotes.
    *
    * @param elements
    *          The raw elements of the array. Must not be {@code null}. If a value in the {@link Stream} is {@code null},
@@ -90,13 +91,13 @@
    * @param formatWithNewlines
    *          Specifies if a new line should be created for each array element.
    * @return This builder
-   * @see #stringLiteral(String)
+   * @see #stringLiteral(CharSequence)
    * @see #nullLiteral()
    */
-  TYPE stringLiteralArray(Stream<String> elements, boolean formatWithNewlines);
+  TYPE stringLiteralArray(Stream<? extends CharSequence> elements, boolean formatWithNewlines);
 
   /**
-   * Appends a {@link String} array with the elements from the specified array. The values are automatically escaped as
+   * Appends a string array with the elements from the specified array. The values are automatically escaped as
    * necessary and surrounded with quotes.
    *
    * @param elements
@@ -105,13 +106,13 @@
    * @param formatWithNewlines
    *          Specifies if a new line should be created for each array element.
    * @return This builder
-   * @see #stringLiteral(String)
+   * @see #stringLiteral(CharSequence)
    * @see #nullLiteral()
    */
-  TYPE stringLiteralArray(String[] elements, boolean formatWithNewlines);
+  TYPE stringLiteralArray(CharSequence[] elements, boolean formatWithNewlines);
 
   /**
-   * Appends a {@link String} array with the elements from the specified array. The values are automatically escaped as
+   * Appends a string array with the elements from the specified array. The values are automatically escaped as
    * necessary and surrounded with quotes.
    *
    * @param elements
@@ -120,14 +121,14 @@
    * @param formatWithNewlines
    *          Specifies if a new line should be created for each array element.
    * @param stringLiteralOnSingleElementArray
-   *          If {@code true} and the given element array exactly contains one element, a {@link String} literal is
-   *          appended instead of an array with one {@link String} literal. This may be useful e.g. for annotation
-   *          element values: An element of type {@link String} array may also be filled with a {@link String} literal.
+   *          If {@code true} and the given element array exactly contains one element, a string literal is appended
+   *          instead of an array with one string literal. This may be useful e.g. for annotation element values: An
+   *          element of type string array may also be filled with a string literal.
    * @return This builder
-   * @see #stringLiteral(String)
+   * @see #stringLiteral(CharSequence)
    * @see #nullLiteral()
    */
-  TYPE stringLiteralArray(String[] elements, boolean formatWithNewlines, boolean stringLiteralOnSingleElementArray);
+  TYPE stringLiteralArray(CharSequence[] elements, boolean formatWithNewlines, boolean stringLiteralOnSingleElementArray);
 
   /**
    * Appends the default value for the given data type. This method has no effect if the given data type has no default
@@ -137,7 +138,7 @@
    *          The fully qualified data type.
    * @return This builder
    */
-  TYPE appendDefaultValueOf(String dataTypeFqn);
+  TYPE appendDefaultValueOf(CharSequence dataTypeFqn);
 
   /**
    * Appends a {@code new} clause including a trailing space.
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/generator/annotation/AnnotationGenerator.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/generator/annotation/AnnotationGenerator.java
index 44d4d1f..dd3ecc6 100644
--- a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/generator/annotation/AnnotationGenerator.java
+++ b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/generator/annotation/AnnotationGenerator.java
@@ -101,7 +101,7 @@
    *          The name of the class the generated (derived) element is based on. Must not be blank.
    * @return A new {@code Generated} {@link IAnnotationGenerator} with the specified value and a default comment.
    */
-  public static IAnnotationGenerator<?> createGenerated(String typeThatGeneratedTheCode) {
+  public static IAnnotationGenerator<?> createGenerated(CharSequence typeThatGeneratedTheCode) {
     return createGenerated(typeThatGeneratedTheCode, "This class is auto generated. No manual modifications recommended.");
   }
 
@@ -112,13 +112,11 @@
    *          The comment value of the {@code Generated} annotation. May be {@code null}.
    * @return A new {@code Generated} {@link IAnnotationGenerator} with the specified value and comment.
    */
-  public static IAnnotationGenerator<?> createGenerated(String typeThatGeneratedTheCode, String comments) {
+  public static IAnnotationGenerator<?> createGenerated(CharSequence typeThatGeneratedTheCode, CharSequence comments) {
     IAnnotationGenerator<?> result = new AnnotationGenerator<>()
         .withElementName(GeneratedAnnotation.TYPE_NAME)
         .withElement(GeneratedAnnotation.VALUE_ELEMENT_NAME, b -> b.stringLiteral(Ensure.notBlank(typeThatGeneratedTheCode)));
-
-    Strings.notBlank(comments)
-        .ifPresent(c -> result.withElement(GeneratedAnnotation.COMMENTS_ELEMENT_NAME, b -> b.stringLiteral(c)));
+    Strings.notBlank(comments).ifPresent(c -> result.withElement(GeneratedAnnotation.COMMENTS_ELEMENT_NAME, b -> b.stringLiteral(c)));
     return result;
   }
 
@@ -135,7 +133,7 @@
    *          The tokens to suppress. May not be {@code null}.
    * @return A new {@link SuppressWarnings} {@link IAnnotationGenerator} with the specified suppression values.
    */
-  public static IAnnotationGenerator<?> createSupressWarnings(String... values) {
+  public static IAnnotationGenerator<?> createSuppressWarnings(CharSequence... values) {
     return new AnnotationGenerator<>()
         .withElementName(SuppressWarnings.class.getName())
         .withElement("value", b -> b.stringLiteralArray(values, false, true));
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/generator/properties/PropertiesGenerator.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/generator/properties/PropertiesGenerator.java
new file mode 100644
index 0000000..b3c9de6
--- /dev/null
+++ b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/generator/properties/PropertiesGenerator.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.core.generator.properties;
+
+import static java.lang.System.lineSeparator;
+import static org.eclipse.scout.sdk.core.util.Strings.fromInputStream;
+import static org.eclipse.scout.sdk.core.util.Strings.toCharArray;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.CharArrayReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Objects;
+import java.util.Properties;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import org.eclipse.scout.sdk.core.builder.ISourceBuilder;
+import org.eclipse.scout.sdk.core.generator.ISourceGenerator;
+import org.eclipse.scout.sdk.core.util.SdkException;
+
+/**
+ * Generator to create .properties files. It supports header comments.
+ */
+public class PropertiesGenerator implements ISourceGenerator<ISourceBuilder<?>> {
+
+  /**
+   * The encoding used to load the content from an {@link InputStream}. As by the definition of the .properties file
+   * format.
+   */
+  public static final Charset ENCODING = StandardCharsets.ISO_8859_1;
+  private static final Pattern LINE_SEPARATOR_REGEX = Pattern.compile(lineSeparator());
+
+  private final Map<String, String> m_properties = new HashMap<>();
+  private final List<String> m_headerLines = new ArrayList<>();
+
+  /**
+   * @return A new empty {@link PropertiesGenerator}.
+   */
+  public static PropertiesGenerator create() {
+    return create((Map<String, String>) null);
+  }
+
+  /**
+   * @param properties
+   *          The initial property map. May be {@code null}.
+   * @return A new {@link PropertiesGenerator} pre filled with the given properties.
+   */
+  public static PropertiesGenerator create(Map<String, String> properties) {
+    return create(properties, null);
+  }
+
+  /**
+   * @param properties
+   *          The initial property map. May be {@code null}.
+   * @param headerLines
+   *          The header lines to add before the first properties. The lines are added as provided. To add comments the
+   *          lines must include a comment char (#).
+   * @return A new {@link PropertiesGenerator} pre filled with the given properties and header lines.
+   */
+  public static PropertiesGenerator create(Map<String, String> properties, Collection<String> headerLines) {
+    return new PropertiesGenerator(properties, headerLines);
+  }
+
+  /**
+   * Creates a new {@link PropertiesGenerator} pre filled with the properties and header lines from the
+   * {@link InputStream} given.
+   * 
+   * @param in
+   *          The {@link InputStream} in the .properties file format. Must not be {@code null}.
+   * @return A new {@link PropertiesGenerator} with the content from the given stream.
+   * @throws IOException
+   */
+  public static PropertiesGenerator create(InputStream in) throws IOException {
+    PropertiesGenerator result = new PropertiesGenerator(null, null);
+    result.load(in);
+    return result;
+  }
+
+  protected PropertiesGenerator(Map<String, String> properties, Collection<String> headerLines) {
+    if (properties != null) {
+      m_properties.putAll(properties);
+    }
+    if (headerLines != null) {
+      m_headerLines.addAll(headerLines);
+    }
+  }
+
+  /**
+   * Loads this generator with the content of the given {@link InputStream} having the .properties file format.<br>
+   * All existing content is replaced.
+   * 
+   * @param input
+   *          The {@link InputStream} in the .properties file format. Must not be {@code null}.
+   * @return This generator.
+   * @throws IOException
+   */
+  public PropertiesGenerator load(InputStream input) throws IOException {
+    char[] content = toCharArray(fromInputStream(input, ENCODING));
+    try (BufferedReader reader = new BufferedReader(new CharArrayReader(content))) {
+      readHeaderLines(reader);
+    }
+    try (Reader reader = new CharArrayReader(content)) {
+      readProperties(reader);
+    }
+    return this;
+  }
+
+  /**
+   * @return All header lines. The resulting {@link List} may be modified.
+   */
+  public List<String> headerLines() {
+    return m_headerLines;
+  }
+
+  /**
+   * @return All the property mappings. The resulting {@link Map} may be modified.
+   */
+  public Map<String, String> properties() {
+    return m_properties;
+  }
+
+  @Override
+  public void generate(ISourceBuilder<?> builder) {
+    m_headerLines.forEach(builder::appendLine);
+    getAsPropertiesEncodedLines()
+        .sorted()
+        .forEach(builder::appendLine);
+  }
+
+  protected Stream<String> getAsPropertiesEncodedLines() {
+    Properties prop = new Properties();
+    //noinspection UseOfPropertiesAsHashtable
+    prop.putAll(m_properties);
+
+    String content;
+    try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+      prop.store(out, null);
+      content = out.toString(ENCODING.name());
+    }
+    catch (IOException e) {
+      throw new SdkException("Error encoding properties", e);
+    }
+
+    return LINE_SEPARATOR_REGEX.splitAsStream(content)
+        .filter(line -> !isComment(line));
+  }
+
+  private static boolean isComment(CharSequence line) {
+    return line.charAt(0) == '#' || line.charAt(0) == '!';
+  }
+
+  private void readHeaderLines(BufferedReader reader) throws IOException {
+    String line;
+    m_headerLines.clear();
+    while ((line = reader.readLine()) != null) {
+      String naturalLine = line.trim();
+      if (naturalLine.isEmpty()) {
+        m_headerLines.add(line);
+      }
+      else if (isComment(naturalLine)) {
+        m_headerLines.add(line);
+      }
+      else {
+        return;
+      }
+    }
+  }
+
+  private void readProperties(Reader reader) throws IOException {
+    Properties props = new Properties();
+    props.load(reader);
+    m_properties.clear();
+    for (Entry<Object, Object> entry : props.entrySet()) {
+      Object key = entry.getKey();
+      Object value = entry.getValue();
+      if (key instanceof String && value instanceof String) {
+        m_properties.put((String) key, (String) value);
+      }
+    }
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    PropertiesGenerator that = (PropertiesGenerator) o;
+    return m_properties.equals(that.m_properties) &&
+        m_headerLines.equals(that.m_headerLines);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(m_properties, m_headerLines);
+  }
+}
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/imports/TypeReferenceDescriptor.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/imports/TypeReferenceDescriptor.java
index 0512ba7..63ae0f4 100644
--- a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/imports/TypeReferenceDescriptor.java
+++ b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/imports/TypeReferenceDescriptor.java
@@ -10,10 +10,16 @@
  */
 package org.eclipse.scout.sdk.core.imports;
 
+import static org.eclipse.scout.sdk.core.util.JavaTypes.isPrimitive;
+import static org.eclipse.scout.sdk.core.util.JavaTypes.qualifier;
+import static org.eclipse.scout.sdk.core.util.JavaTypes.simpleName;
+import static org.eclipse.scout.sdk.core.util.Strings.isBlank;
+import static org.eclipse.scout.sdk.core.util.Strings.isEmpty;
+import static org.eclipse.scout.sdk.core.util.Strings.replace;
+
 import java.util.Objects;
 
 import org.eclipse.scout.sdk.core.util.JavaTypes;
-import org.eclipse.scout.sdk.core.util.Strings;
 
 /**
  * <h3>{@link TypeReferenceDescriptor}</h3>
@@ -34,7 +40,7 @@
 
   TypeReferenceDescriptor(CharSequence fqn, boolean isTypeArg) {
     m_isTypeArg = isTypeArg;
-    m_isBaseType = JavaTypes.isPrimitive(fqn);
+    m_isBaseType = isPrimitive(fqn);
     if (isBaseType()) {
       m_packageName = null;
       m_simpleName = fqn.toString();
@@ -42,13 +48,13 @@
       m_qualifiedName = m_simpleName;
     }
     else {
-      String nameWithoutDollar = Strings.replace(fqn, JavaTypes.C_DOLLAR, JavaTypes.C_DOT);
-      String qualifier = JavaTypes.qualifier(fqn);
-      String qualifierFromNameWithoutDollar = JavaTypes.qualifier(nameWithoutDollar);
-      m_qualifier = Strings.isBlank(qualifierFromNameWithoutDollar) ? null : qualifierFromNameWithoutDollar;
-      m_packageName = Strings.isBlank(qualifier) ? null : qualifier;
-      m_simpleName = JavaTypes.simpleName(nameWithoutDollar);
-      m_qualifiedName = Strings.isEmpty(m_qualifier) ? m_simpleName : m_qualifier + JavaTypes.C_DOT + m_simpleName;
+      CharSequence nameWithoutDollar = replace(fqn, JavaTypes.C_DOLLAR, JavaTypes.C_DOT);
+      String qualifier = qualifier(fqn);
+      String qualifierFromNameWithoutDollar = qualifier(nameWithoutDollar);
+      m_qualifier = isBlank(qualifierFromNameWithoutDollar) ? null : qualifierFromNameWithoutDollar;
+      m_packageName = isBlank(qualifier) ? null : qualifier;
+      m_simpleName = simpleName(nameWithoutDollar);
+      m_qualifiedName = isEmpty(m_qualifier) ? m_simpleName : (m_qualifier + JavaTypes.C_DOT + m_simpleName);
     }
   }
 
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/log/FormattingTuple.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/log/FormattingTuple.java
index f5b2aa9..85b163f 100644
--- a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/log/FormattingTuple.java
+++ b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/log/FormattingTuple.java
@@ -16,7 +16,7 @@
 import java.util.Optional;
 
 /**
- * <h3>{@link FormattingTuple}</h3> Use {@link MessageFormatter#arrayFormat(String, Object...)} to obtain a
+ * <h3>{@link FormattingTuple}</h3> Use {@link MessageFormatter#arrayFormat(CharSequence, Object...)} to obtain a
  * {@link FormattingTuple} instance.
  *
  * @since 6.1.0
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/log/LogMessage.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/log/LogMessage.java
index 348d278..66fce67 100644
--- a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/log/LogMessage.java
+++ b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/log/LogMessage.java
@@ -45,7 +45,7 @@
 
   /**
    * @return The main message to log. It corresponds to the message entered in
-   *         {@link SdkLog#log(Level, String, Object...)}. If the message used placeholders (see
+   *         {@link SdkLog#log(Level, CharSequence, Object...)}. If the message used placeholders (see
    *         {@link MessageFormatter}) these have already been replaced. Is never {@code null}.
    */
   public String text() {
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/log/SdkLog.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/log/SdkLog.java
index ccd4d23..f5af973 100644
--- a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/log/SdkLog.java
+++ b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/log/SdkLog.java
@@ -11,6 +11,7 @@
 package org.eclipse.scout.sdk.core.log;
 
 import static java.util.Collections.singletonList;
+import static org.eclipse.scout.sdk.core.util.Strings.repeat;
 
 import java.time.Clock;
 import java.time.LocalDateTime;
@@ -86,7 +87,7 @@
   static String defaultPrefixFor(Level level) {
     int levelColWidth = 8;
     String levelName = level.getName();
-    String spaces = Strings.repeat(" ", levelColWidth - levelName.length());
+    CharSequence spaces = repeat(" ", levelColWidth - levelName.length());
     return new StringBuilder().append(logTime())
         .append(" [").append(levelName).append(']')
         .append(spaces).toString();
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/Chars.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/Chars.java
deleted file mode 100644
index fc116e2..0000000
--- a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/Chars.java
+++ /dev/null
@@ -1,462 +0,0 @@
-/*
- * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the Eclipse Public License v1.0
- * which accompanies this distribution, and is available at
- * http://www.eclipse.org/legal/epl-v10.html
- *
- * Contributors:
- *     BSI Business Systems Integration AG - initial API and implementation
- */
-package org.eclipse.scout.sdk.core.util;
-
-/**
- * Holds helper methods to deal with char arrays and {@link CharSequence}s.
- */
-public final class Chars {
-
-  private Chars() {
-  }
-
-  /**
-   * Checks if the two arrays have the same content comparing the character case sensitive.
-   * 
-   * <pre>
-   *   first=null & second=null -> true
-   *   first=a & second=a -> true
-   *   first=abc & second=def -> false
-   *   first=null & second=a -> false
-   * </pre>
-   * 
-   * @param first
-   *          The first array
-   * @param second
-   *          The second array
-   * @return {@code true} if both have equal content or both are {@code null}.
-   */
-  public static boolean equals(char[] first, char[] second) {
-    //noinspection ArrayEquality
-    if (first == second) {
-      return true;
-    }
-    if (first == null || second == null) {
-      return false;
-    }
-    if (first.length != second.length) {
-      return false;
-    }
-    for (int i = first.length; --i >= 0;) {
-      if (first[i] != second[i]) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  /**
-   * Checks if the two arrays have the same content comparing the character using the case sensitivity given. See
-   * {@link #equals(char[], char[])} for more details.
-   *
-   * @param first
-   *          The first array
-   * @param second
-   *          The second array
-   * @param isCaseSensitive
-   *          specifies whether or not the equality should be case sensitive
-   * @return {@code true} if the two arrays are identical character by character according to the value of
-   *         isCaseSensitive or if both are {@code null}.
-   */
-  public static boolean equals(char[] first, char[] second, boolean isCaseSensitive) {
-    if (isCaseSensitive) {
-      return equals(first, second);
-    }
-    //noinspection ArrayEquality
-    if (first == second) {
-      return true;
-    }
-    if (first == null || second == null) {
-      return false;
-    }
-    if (first.length != second.length) {
-      return false;
-    }
-    for (int i = first.length; --i >= 0;) {
-      if (Character.toLowerCase(first[i]) != Character.toLowerCase(second[i])) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  /**
-   * Checks if the two {@link CharSequence}s have the same content comparing the character case sensitive.
-   *
-   * <pre>
-   *   first=null & second=null -> true
-   *   first=a & second=a -> true
-   *   first=abc & second=def -> false
-   *   first=null & second=a -> false
-   * </pre>
-   *
-   * @param first
-   *          The first {@link CharSequence}
-   * @param second
-   *          The second {@link CharSequence}
-   * @return {@code true} if both have equal content or both are {@code null}.
-   */
-  public static boolean equals(CharSequence first, CharSequence second) {
-    if (first == second) {
-      return true;
-    }
-    if (first == null || second == null) {
-      return false;
-    }
-    if (first.length() != second.length()) {
-      return false;
-    }
-    for (int i = first.length(); --i >= 0;) {
-      if (first.charAt(i) != second.charAt(i)) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  /**
-   * Checks if the two {@link CharSequence}s have the same content comparing the character using the case sensitivity
-   * given. See {@link #equals(CharSequence, CharSequence)} for more details.
-   *
-   * @param first
-   *          The first {@link CharSequence}
-   * @param second
-   *          The second {@link CharSequence}
-   * @param isCaseSensitive
-   *          specifies whether or not the equality should be case sensitive
-   * @return {@code true} if the two sequences are identical character by character according to the value of
-   *         isCaseSensitive or if both are {@code null}.
-   */
-  public static boolean equals(CharSequence first, CharSequence second, boolean isCaseSensitive) {
-    if (isCaseSensitive) {
-      return equals(first, second);
-    }
-
-    if (first == second) {
-      return true;
-    }
-    if (first == null || second == null) {
-      return false;
-    }
-    if (first.length() != second.length()) {
-      return false;
-    }
-    for (int i = first.length(); --i >= 0;) {
-      if (Character.toLowerCase(first.charAt(i)) != Character.toLowerCase(second.charAt(i))) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  /**
-   * Gets the first index having the character given.
-   *
-   * @param toBeFound
-   *          The character to search
-   * @param searchIn
-   *          The array to search in. Must not be {@code null}.
-   * @return The first zero based index having the character given.
-   * @throws NullPointerException
-   *           if the array is {@code null}.
-   */
-  public static int indexOf(char toBeFound, char[] searchIn) {
-    return indexOf(toBeFound, searchIn, 0);
-  }
-
-  /**
-   * Gets the first index having the character given. It starts searching at index start (inclusive) and searches to the
-   * end of the array.
-   *
-   * @param toBeFound
-   *          The character to search
-   * @param searchIn
-   *          The array to search in. Must not be {@code null}.
-   * @param start
-   *          The first index to consider.
-   * @return The first zero based index between start and the end of the array.
-   * @throws NullPointerException
-   *           if the array is {@code null}.
-   */
-  public static int indexOf(char toBeFound, char[] searchIn, int start) {
-    return indexOf(toBeFound, searchIn, start, searchIn.length);
-  }
-
-  /**
-   * Gets the first index having the character given. It starts searching at index start (inclusive) and stops before
-   * index end (exclusive).
-   * 
-   * @param toBeFound
-   *          The character to search
-   * @param searchIn
-   *          The array to search in. Must not be {@code null}.
-   * @param start
-   *          The first index to consider.
-   * @param end
-   *          Where to stop searching (exclusive)
-   * @return The first zero based index between start and end having the character given.
-   * @throws NullPointerException
-   *           if the array is {@code null}.
-   */
-  public static int indexOf(char toBeFound, char[] searchIn, int start, int end) {
-    int limit = Math.min(end, searchIn.length);
-    for (int i = start; i < limit; ++i) {
-      if (toBeFound == searchIn[i]) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
-  /**
-   * Gets the first index having the character given.
-   *
-   * @param toBeFound
-   *          The character to search
-   * @param searchIn
-   *          The {@link CharSequence} to search in. Must not be {@code null}.
-   * @return The first zero based index having the character given.
-   * @throws NullPointerException
-   *           if the {@link CharSequence} is {@code null}.
-   */
-  public static int indexOf(char toBeFound, CharSequence searchIn) {
-    return indexOf(toBeFound, searchIn, 0);
-  }
-
-  /**
-   * Gets the first index having the character given. It starts searching at index start (inclusive) and searches to the
-   * end of the array.
-   *
-   * @param toBeFound
-   *          The character to search
-   * @param searchIn
-   *          The {@link CharSequence} to search in. Must not be {@code null}.
-   * @param start
-   *          The first index to consider.
-   * @return The first zero based index between start and the end of the array.
-   * @throws NullPointerException
-   *           if the {@link CharSequence} is {@code null}.
-   */
-  public static int indexOf(char toBeFound, CharSequence searchIn, int start) {
-    return indexOf(toBeFound, searchIn, start, searchIn.length());
-  }
-
-  /**
-   * Gets the first index having the character given. It starts searching at index start (inclusive) and stops before
-   * index end (exclusive).
-   *
-   * @param toBeFound
-   *          The character to search
-   * @param searchIn
-   *          The {@link CharSequence} to search in. Must not be {@code null}.
-   * @param start
-   *          The first index to consider.
-   * @param end
-   *          Where to stop searching (exclusive)
-   * @return The first zero based index between start and end having the character given.
-   * @throws NullPointerException
-   *           if the {@link CharSequence} is {@code null}.
-   */
-  public static int indexOf(char toBeFound, CharSequence searchIn, int start, int end) {
-    int limit = Math.max(Math.min(end, searchIn.length()), 0);
-    for (int i = start; i < limit; i++) {
-      if (toBeFound == searchIn.charAt(i)) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
-  /**
-   * Like {@link #indexOf(char[], char[], int, int, boolean)} but performs a case sensitive search in the full array.
-   */
-  public static int indexOf(char[] toBeFound, char[] searchIn) {
-    return indexOf(toBeFound, searchIn, 0);
-  }
-
-  /**
-   * Like {@link #indexOf(char[], char[], int, int, boolean)} but performs a case sensitive search from the given start
-   * (inclusive) to the end of the array.
-   */
-  public static int indexOf(char[] toBeFound, char[] searchIn, int start) {
-    return indexOf(toBeFound, searchIn, start, searchIn.length);
-  }
-
-  /**
-   * Like {@link #indexOf(char[], char[], int, int, boolean)} but performs a case sensitive search.
-   */
-  public static int indexOf(char[] toBeFound, char[] searchIn, int start, int end) {
-    return indexOf(toBeFound, searchIn, start, end, true);
-  }
-
-  /**
-   * Answers the first index in searchIn for which toBeFound is a matching subarray following the case rule starting at
-   * the index start. Answers -1 if no match is found.<br>
-   * Examples:
-   * <ol>
-   * <li>
-   * 
-   * <pre>
-   * toBeFound = { 'c' }
-   * searchIn = { ' a', 'b', 'c', 'd' }
-   * result => 2
-   * </pre>
-   * 
-   * </li>
-   * <li>
-   * 
-   * <pre>
-   * toBeFound = { 'e' }
-   * searchIn = { ' a', 'b', 'c', 'd' }
-   * result => -1
-   * </pre>
-   * 
-   * </li>
-   * <li>
-   * 
-   * <pre>
-   * toBeFound = { 'b', 'c' }
-   * searchIn = { ' a', 'b', 'c', 'd' }
-   * result => 1
-   * </pre>
-   * 
-   * </li>
-   * </ol>
-   *
-   * @param toBeFound
-   *          the subarray to search
-   * @param searchIn
-   *          the array to be searched
-   * @param start
-   *          the starting index (inclusive)
-   * @param end
-   *          the end index (exclusive)
-   * @param isCaseSensitive
-   *          flag to know if the matching should be case sensitive
-   * @return the first index in searchIn for which the toBeFound array is a matching subarray following the case rule.
-   * @throws NullPointerException
-   *           if searchIn is {@code null} or toBeFound is {@code null}
-   */
-  public static int indexOf(char[] toBeFound, char[] searchIn, int start, int end, boolean isCaseSensitive) {
-    int toBeFoundLength = toBeFound.length;
-    if (toBeFoundLength > end || start < 0) {
-      return -1;
-    }
-    if (toBeFoundLength == 0) {
-      return 0;
-    }
-    if (isCaseSensitive) {
-      arrayLoop: for (int i = start, max = end - toBeFoundLength + 1; i < max; i++) {
-        if (searchIn[i] == toBeFound[0]) {
-          for (int j = 1; j < toBeFoundLength; j++) {
-            if (searchIn[i + j] != toBeFound[j]) {
-              continue arrayLoop;
-            }
-          }
-          return i;
-        }
-      }
-    }
-    else {
-      arrayLoop: for (int i = start, max = end - toBeFoundLength + 1; i < max; i++) {
-        if (Character.toLowerCase(searchIn[i]) == Character.toLowerCase(toBeFound[0])) {
-          for (int j = 1; j < toBeFoundLength; j++) {
-            if (Character.toLowerCase(searchIn[i + j]) != Character.toLowerCase(toBeFound[j])) {
-              continue arrayLoop;
-            }
-          }
-          return i;
-        }
-      }
-    }
-    return -1;
-  }
-
-  /**
-   * Searches for the last index in the {@link CharSequence} which has the character given.
-   * 
-   * @param toBeFound
-   *          The character to find
-   * @param searchIn
-   *          The {@link CharSequence} to search in.
-   * @return The last zero based index or -1 if it could not be found.
-   * @throws NullPointerException
-   *           if the sequence is {@code null}.
-   */
-  public static int lastIndexOf(char toBeFound, CharSequence searchIn) {
-    return lastIndexOf(toBeFound, searchIn, 0);
-  }
-
-  /**
-   * Searches for the last index after the given startIndex which has the character specified.
-   * 
-   * @param toBeFound
-   *          The character to find.
-   * @param searchIn
-   *          The {@link CharSequence} to search in.
-   * @param startIndex
-   *          The index to start.
-   * @return The last zero based index after the startIndex or -1 if it could not be found.
-   * @throws NullPointerException
-   *           if the sequence is {@code null}.
-   */
-  public static int lastIndexOf(char toBeFound, CharSequence searchIn, int startIndex) {
-    return lastIndexOf(toBeFound, searchIn, startIndex, searchIn.length());
-  }
-
-  /**
-   * Searches for the last index between the startIndex and the endIndex having the given character.
-   * 
-   * @param toBeFound
-   *          The character to find.
-   * @param searchIn
-   *          The {@link CharSequence} to search in.
-   * @param startIndex
-   *          The index where to start the search.
-   * @param endIndex
-   *          The index where to end the search.
-   * @returnThe last zero based index between the startIndex and the endIndex or -1 if it could not be found in this
-   *            section.
-   * @throws NullPointerException
-   *           if the sequence is {@code null}.
-   */
-  @SuppressWarnings("squid:S881")
-  public static int lastIndexOf(char toBeFound, CharSequence searchIn, int startIndex, int endIndex) {
-    for (int i = endIndex; --i >= startIndex;) {
-      if (toBeFound == searchIn.charAt(i)) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
-  /**
-   * Gets the next index after the given offset at which the current line ends. If invoked for the last line, the array
-   * length (end) is returned.
-   * 
-   * @param searchIn
-   *          The array to search in.
-   * @param offset
-   *          The offset within the array where to start the search.
-   * @return The next line end character after the given offset. If no one can be found the array length is returned.
-   */
-  @SuppressWarnings("HardcodedLineSeparator")
-  public static int nextLineEnd(char[] searchIn, int offset) {
-    int nlPos = indexOf('\n', searchIn, offset);
-    if (nlPos < 0) {
-      return searchIn.length; // no more newline found: search to the end of searchIn
-    }
-    if (nlPos > 0 && searchIn[nlPos - 1] == '\r') {
-      nlPos--;
-    }
-    return nlPos;
-  }
-}
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/CoreUtils.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/CoreUtils.java
index 6fb1b00..d34b13b 100644
--- a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/CoreUtils.java
+++ b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/CoreUtils.java
@@ -10,6 +10,11 @@
  */
 package org.eclipse.scout.sdk.core.util;
 
+import java.awt.*;
+import java.awt.datatransfer.Clipboard;
+import java.awt.datatransfer.ClipboardOwner;
+import java.awt.datatransfer.DataFlavor;
+import java.awt.datatransfer.StringSelection;
 import java.io.IOException;
 import java.net.URI;
 import java.nio.file.FileVisitResult;
@@ -393,4 +398,62 @@
 
     return Strings.notBlank(val);
   }
+
+  /**
+   * @return the clipboard content as plain text or {@code null} if there is no plain text in the clipboard.
+   */
+  public static String getTextFromClipboard() {
+    if (GraphicsEnvironment.isHeadless()) {
+      return null;
+    }
+    try {
+      Clipboard systemClipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
+      Object data = systemClipboard.getData(DataFlavor.stringFlavor);
+      if (data != null) {
+        return data.toString();
+      }
+    }
+    catch (Exception e) {
+      SdkLog.debug("Unable to read plain text from system clipboard.", e);
+    }
+    return null;
+  }
+
+  /**
+   * Sets the given {@link String} into the system clipboard
+   *
+   * @param text
+   *          The text to set
+   * @return {@code true} if the text has been successfully set to the system clipboard.
+   */
+  public static boolean setTextToClipboard(String text) {
+    return setTextToClipboard(text, null);
+  }
+
+  /**
+   * Sets the given {@link String} into the system clipboard
+   * 
+   * @param text
+   *          The text to set
+   * @param ownershipLostCallback
+   *          An optional callback invoked if the owner ship of the clipboard is lost.
+   * @return {@code true} if the text has been successfully set to the system clipboard.
+   */
+  public static boolean setTextToClipboard(String text, ClipboardOwner ownershipLostCallback) {
+    if (GraphicsEnvironment.isHeadless() || text == null) {
+      return false;
+    }
+
+    try {
+      StringSelection stringSelection = new StringSelection(text);
+      ClipboardOwner owner = ownershipLostCallback == null ? stringSelection : ownershipLostCallback;
+      Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
+      clipboard.setContents(stringSelection, owner);
+      return true;
+    }
+    catch (Exception e) {
+      SdkLog.debug("Error setting text to system clipboard.", e);
+      return false;
+    }
+  }
 }
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/JavaTypes.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/JavaTypes.java
index d19143b..7502bcc 100644
--- a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/JavaTypes.java
+++ b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/JavaTypes.java
@@ -13,6 +13,8 @@
 import static java.util.Collections.emptyList;
 import static java.util.stream.Collectors.collectingAndThen;
 import static java.util.stream.Collectors.toSet;
+import static org.eclipse.scout.sdk.core.util.Strings.indexOf;
+import static org.eclipse.scout.sdk.core.util.Strings.lastIndexOf;
 
 import java.util.ArrayDeque;
 import java.util.ArrayList;
@@ -369,8 +371,8 @@
    *           if name is null
    */
   public static String qualifier(CharSequence name) {
-    int firstGenericStart = Chars.indexOf(C_GENERIC_START, name);
-    int lastDot = Chars.lastIndexOf(C_DOT, name, 0, firstGenericStart == -1 ? name.length() - 1 : firstGenericStart);
+    int firstGenericStart = indexOf(C_GENERIC_START, name);
+    int lastDot = lastIndexOf(C_DOT, name, 0, firstGenericStart == -1 ? name.length() - 1 : firstGenericStart);
     if (lastDot == -1) {
       return "";
     }
@@ -458,7 +460,7 @@
    *           if the given type is syntactically incorrect
    */
   public static String erasure(CharSequence parameterizedType) {
-    int firstParamIndex = Chars.indexOf(C_GENERIC_START, parameterizedType);
+    int firstParamIndex = indexOf(C_GENERIC_START, parameterizedType);
     if (firstParamIndex < 0) {
       return parameterizedType.toString();
     }
@@ -635,7 +637,7 @@
         return end + 1;
       }
 
-      int arrayStart = Chars.indexOf(C_ARRAY, src, fqnStart, end);
+      int arrayStart = indexOf(C_ARRAY, src, fqnStart, end);
       boolean isArray = arrayStart > fqnStart;
 
       CharSequence fqn = src.subSequence(fqnStart, isArray ? arrayStart : end);
diff --git a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/Strings.java b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/Strings.java
index 7678a8b..2fa8b15 100644
--- a/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/Strings.java
+++ b/org.eclipse.scout.sdk.core/src/main/java/org/eclipse/scout/sdk/core/util/Strings.java
@@ -11,6 +11,7 @@
 package org.eclipse.scout.sdk.core.util;
 
 import static java.lang.System.lineSeparator;
+import static org.eclipse.scout.sdk.core.util.Ensure.newFail;
 
 import java.beans.Introspector;
 import java.io.IOException;
@@ -29,8 +30,8 @@
 import java.util.Optional;
 
 /**
- * <h3>{@link Strings}</h3><br>
- * Static utility methods to work with {@link String}s.<br>
+ * <h3>{@link Strings}</h3> Static utility methods to work with character sequences like {@link String},
+ * {@link CharSequence}, {@link StringBuilder} or {@code char[]}.
  *
  * @since 6.1.0
  */
@@ -42,6 +43,519 @@
   }
 
   /**
+   * Checks if the two arrays have the same content comparing the character case sensitive.
+   *
+   * <pre>
+   *   first=null & second=null -> true
+   *   first=a & second=a -> true
+   *   first=abc & second=def -> false
+   *   first=null & second=a -> false
+   * </pre>
+   *
+   * @param first
+   *          The first array
+   * @param second
+   *          The second array
+   * @return {@code true} if both have equal content or both are {@code null}.
+   */
+  public static boolean equals(char[] first, char[] second) {
+    //noinspection ArrayEquality
+    if (first == second) {
+      return true;
+    }
+    if (first == null || second == null) {
+      return false;
+    }
+    if (first.length != second.length) {
+      return false;
+    }
+    for (int i = first.length; --i >= 0;) {
+      if (first[i] != second[i]) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Checks if the two arrays have the same content comparing the character using the case sensitivity given. See
+   * {@link #equals(char[], char[])} for more details.
+   *
+   * @param first
+   *          The first array
+   * @param second
+   *          The second array
+   * @param isCaseSensitive
+   *          specifies whether or not the equality should be case sensitive
+   * @return {@code true} if the two arrays are identical character by character according to the value of
+   *         isCaseSensitive or if both are {@code null}.
+   */
+  public static boolean equals(char[] first, char[] second, boolean isCaseSensitive) {
+    if (isCaseSensitive) {
+      return equals(first, second);
+    }
+    //noinspection ArrayEquality
+    if (first == second) {
+      return true;
+    }
+    if (first == null || second == null) {
+      return false;
+    }
+    if (first.length != second.length) {
+      return false;
+    }
+    for (int i = first.length; --i >= 0;) {
+      if (Character.toLowerCase(first[i]) != Character.toLowerCase(second[i])) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Checks if the two {@link CharSequence}s have the same content comparing the character case sensitive.
+   *
+   * <pre>
+   *   first=null & second=null -> true
+   *   first=a & second=a -> true
+   *   first=abc & second=def -> false
+   *   first=null & second=a -> false
+   * </pre>
+   *
+   * @param first
+   *          The first {@link CharSequence}
+   * @param second
+   *          The second {@link CharSequence}
+   * @return {@code true} if both have equal content or both are {@code null}.
+   */
+  public static boolean equals(CharSequence first, CharSequence second) {
+    if (first == second) {
+      return true;
+    }
+    if (first == null || second == null) {
+      return false;
+    }
+    if (first.length() != second.length()) {
+      return false;
+    }
+    for (int i = first.length(); --i >= 0;) {
+      if (first.charAt(i) != second.charAt(i)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Checks if the two {@link CharSequence}s have the same content comparing the character using the case sensitivity
+   * given. See {@link #equals(CharSequence, CharSequence)} for more details.
+   *
+   * @param first
+   *          The first {@link CharSequence}
+   * @param second
+   *          The second {@link CharSequence}
+   * @param isCaseSensitive
+   *          specifies whether or not the equality should be case sensitive
+   * @return {@code true} if the two sequences are identical character by character according to the value of
+   *         isCaseSensitive or if both are {@code null}.
+   */
+  public static boolean equals(CharSequence first, CharSequence second, boolean isCaseSensitive) {
+    if (isCaseSensitive) {
+      return equals(first, second);
+    }
+
+    if (first == second) {
+      return true;
+    }
+    if (first == null || second == null) {
+      return false;
+    }
+    if (first.length() != second.length()) {
+      return false;
+    }
+    for (int i = first.length(); --i >= 0;) {
+      if (Character.toLowerCase(first.charAt(i)) != Character.toLowerCase(second.charAt(i))) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Gets the first index having the character given.
+   *
+   * @param toBeFound
+   *          The character to search
+   * @param searchIn
+   *          The array to search in. Must not be {@code null}.
+   * @return The first zero based index having the character given.
+   * @throws NullPointerException
+   *           if the array is {@code null}.
+   */
+  public static int indexOf(char toBeFound, char[] searchIn) {
+    return indexOf(toBeFound, searchIn, 0);
+  }
+
+  /**
+   * Gets the first index having the character given. It starts searching at index start (inclusive) and searches to the
+   * end of the array.
+   *
+   * @param toBeFound
+   *          The character to search
+   * @param searchIn
+   *          The array to search in. Must not be {@code null}.
+   * @param start
+   *          The first index to consider.
+   * @return The first zero based index between start and the end of the array.
+   * @throws NullPointerException
+   *           if the array is {@code null}.
+   */
+  public static int indexOf(char toBeFound, char[] searchIn, int start) {
+    return indexOf(toBeFound, searchIn, start, searchIn.length);
+  }
+
+  /**
+   * Gets the first index having the character given. It starts searching at index start (inclusive) and stops before
+   * index end (exclusive).
+   *
+   * @param toBeFound
+   *          The character to search
+   * @param searchIn
+   *          The array to search in. Must not be {@code null}.
+   * @param start
+   *          The first index to consider.
+   * @param end
+   *          Where to stop searching (exclusive)
+   * @return The first zero based index between start and end having the character given.
+   * @throws NullPointerException
+   *           if the array is {@code null}.
+   */
+  public static int indexOf(char toBeFound, char[] searchIn, int start, int end) {
+    int limit = Math.min(end, searchIn.length);
+    for (int i = start; i < limit; ++i) {
+      if (toBeFound == searchIn[i]) {
+        return i;
+      }
+    }
+    return INDEX_NOT_FOUND;
+  }
+
+  /**
+   * Gets the first index having the character given.
+   *
+   * @param toBeFound
+   *          The character to search
+   * @param searchIn
+   *          The {@link CharSequence} to search in. Must not be {@code null}.
+   * @return The first zero based index having the character given.
+   * @throws NullPointerException
+   *           if the {@link CharSequence} is {@code null}.
+   */
+  public static int indexOf(char toBeFound, CharSequence searchIn) {
+    return indexOf(toBeFound, searchIn, 0);
+  }
+
+  /**
+   * Gets the first index having the character given. It starts searching at index start (inclusive) and searches to the
+   * end of the array.
+   *
+   * @param toBeFound
+   *          The character to search
+   * @param searchIn
+   *          The {@link CharSequence} to search in. Must not be {@code null}.
+   * @param start
+   *          The first index to consider.
+   * @return The first zero based index between start and the end of the array.
+   * @throws NullPointerException
+   *           if the {@link CharSequence} is {@code null}.
+   */
+  public static int indexOf(char toBeFound, CharSequence searchIn, int start) {
+    return indexOf(toBeFound, searchIn, start, searchIn.length());
+  }
+
+  /**
+   * Gets the first index having the character given. It starts searching at index start (inclusive) and stops before
+   * index end (exclusive).
+   *
+   * @param toBeFound
+   *          The character to search
+   * @param searchIn
+   *          The {@link CharSequence} to search in. Must not be {@code null}.
+   * @param start
+   *          The first index to consider.
+   * @param end
+   *          Where to stop searching (exclusive)
+   * @return The first zero based index between start and end having the character given.
+   * @throws NullPointerException
+   *           if the {@link CharSequence} is {@code null}.
+   */
+  public static int indexOf(char toBeFound, CharSequence searchIn, int start, int end) {
+    int limit = Math.max(Math.min(end, searchIn.length()), 0);
+    for (int i = start; i < limit; i++) {
+      if (toBeFound == searchIn.charAt(i)) {
+        return i;
+      }
+    }
+    return INDEX_NOT_FOUND;
+  }
+
+  /**
+   * Like {@link #indexOf(char[], char[])}.
+   */
+  public static int indexOf(CharSequence toBeFound, CharSequence searchIn) {
+    return indexOf(toBeFound, searchIn, 0);
+  }
+
+  /**
+   * Like {@link #indexOf(char[], char[], int)}.
+   */
+  public static int indexOf(CharSequence toBeFound, CharSequence searchIn, int start) {
+    return indexOf(toBeFound, searchIn, start, searchIn.length());
+  }
+
+  /**
+   * Like {@link #indexOf(char[], char[], int, int)}.
+   */
+  public static int indexOf(CharSequence toBeFound, CharSequence searchIn, int start, int end) {
+    int toBeFoundLength = toBeFound.length();
+    if (toBeFoundLength > end || start < 0) {
+      return INDEX_NOT_FOUND;
+    }
+    if (toBeFoundLength == 0) {
+      return 0;
+    }
+    arrayLoop: for (int i = start, max = end - toBeFoundLength + 1; i < max; i++) {
+      if (searchIn.charAt(i) == toBeFound.charAt(0)) {
+        for (int j = 1; j < toBeFoundLength; j++) {
+          if (searchIn.charAt(i + j) != toBeFound.charAt(j)) {
+            continue arrayLoop;
+          }
+        }
+        return i;
+      }
+    }
+    return INDEX_NOT_FOUND;
+  }
+
+  /**
+   * Like {@link #indexOf(char[], char[], int, int, boolean)} but performs a case sensitive search in the full array.
+   */
+  public static int indexOf(char[] toBeFound, char[] searchIn) {
+    return indexOf(toBeFound, searchIn, 0);
+  }
+
+  /**
+   * Like {@link #indexOf(char[], char[], int, int, boolean)} but performs a case sensitive search from the given start
+   * (inclusive) to the end of the array.
+   */
+  public static int indexOf(char[] toBeFound, char[] searchIn, int start) {
+    return indexOf(toBeFound, searchIn, start, searchIn.length);
+  }
+
+  /**
+   * Like {@link #indexOf(char[], char[], int, int, boolean)} but performs a case sensitive search.
+   */
+  public static int indexOf(char[] toBeFound, char[] searchIn, int start, int end) {
+    return indexOf(toBeFound, searchIn, start, end, true);
+  }
+
+  /**
+   * Answers the first index in searchIn for which toBeFound is a matching followup array. Answers -1 if no match is
+   * found.<br>
+   * Examples:
+   * <ol>
+   * <li>
+   *
+   * <pre>
+   * toBeFound = { 'c' }
+   * searchIn = { ' a', 'b', 'c', 'd' }
+   * result => 2
+   * </pre>
+   *
+   * </li>
+   * <li>
+   *
+   * <pre>
+   * toBeFound = { 'e' }
+   * searchIn = { ' a', 'b', 'c', 'd' }
+   * result => -1
+   * </pre>
+   *
+   * </li>
+   * <li>
+   *
+   * <pre>
+   * toBeFound = { 'b', 'c' }
+   * searchIn = { ' a', 'b', 'c', 'd' }
+   * result => 1
+   * </pre>
+   *
+   * </li>
+   * </ol>
+   *
+   * @param toBeFound
+   *          the subarray to search. Must not be {@code null}.
+   * @param searchIn
+   *          the array to be searched in. Must not be {@code null}.
+   * @param start
+   *          the starting index (inclusive) describing where in searchIn to begin searching.
+   * @param end
+   *          the end index (exclusive) describing where in searchIn to stop searching.
+   * @param isCaseSensitive
+   *          describes if the comparation should be case sensitive or not.
+   * @return the first index in searchIn for which the toBeFound array is a matching followup array or -1 if it cannot
+   *         be found.
+   * @throws NullPointerException
+   *           if searchIn is {@code null} or toBeFound is {@code null}
+   */
+  public static int indexOf(char[] toBeFound, char[] searchIn, int start, int end, boolean isCaseSensitive) {
+    int toBeFoundLength = toBeFound.length;
+    if (toBeFoundLength > end || start < 0) {
+      return INDEX_NOT_FOUND;
+    }
+    if (toBeFoundLength == 0) {
+      return 0;
+    }
+    if (isCaseSensitive) {
+      arrayLoop: for (int i = start, max = end - toBeFoundLength + 1; i < max; i++) {
+        if (searchIn[i] == toBeFound[0]) {
+          for (int j = 1; j < toBeFoundLength; j++) {
+            if (searchIn[i + j] != toBeFound[j]) {
+              continue arrayLoop;
+            }
+          }
+          return i;
+        }
+      }
+    }
+    else {
+      arrayLoop: for (int i = start, max = end - toBeFoundLength + 1; i < max; i++) {
+        if (Character.toLowerCase(searchIn[i]) == Character.toLowerCase(toBeFound[0])) {
+          for (int j = 1; j < toBeFoundLength; j++) {
+            if (Character.toLowerCase(searchIn[i + j]) != Character.toLowerCase(toBeFound[j])) {
+              continue arrayLoop;
+            }
+          }
+          return i;
+        }
+      }
+    }
+    return INDEX_NOT_FOUND;
+  }
+
+  /**
+   * Searches for the last index in the {@link CharSequence} which has the character given.
+   *
+   * @param toBeFound
+   *          The character to find
+   * @param searchIn
+   *          The {@link CharSequence} to search in.
+   * @return The last zero based index or -1 if it could not be found.
+   * @throws NullPointerException
+   *           if the sequence is {@code null}.
+   */
+  public static int lastIndexOf(char toBeFound, CharSequence searchIn) {
+    return lastIndexOf(toBeFound, searchIn, 0);
+  }
+
+  /**
+   * Searches for the last index after the given startIndex which has the character specified.
+   *
+   * @param toBeFound
+   *          The character to find.
+   * @param searchIn
+   *          The {@link CharSequence} to search in.
+   * @param startIndex
+   *          The index to start.
+   * @return The last zero based index after the startIndex or -1 if it could not be found.
+   * @throws NullPointerException
+   *           if the sequence is {@code null}.
+   */
+  public static int lastIndexOf(char toBeFound, CharSequence searchIn, int startIndex) {
+    return lastIndexOf(toBeFound, searchIn, startIndex, searchIn.length());
+  }
+
+  /**
+   * Searches for the last index between the startIndex and the endIndex having the given character.
+   *
+   * @param toBeFound
+   *          The character to find.
+   * @param searchIn
+   *          The {@link CharSequence} to search in.
+   * @param startIndex
+   *          The index where to start the search.
+   * @param endIndex
+   *          The index where to end the search.
+   * @returnThe last zero based index between the startIndex and the endIndex or -1 if it could not be found in this
+   *            section.
+   * @throws NullPointerException
+   *           if the sequence is {@code null}.
+   */
+  @SuppressWarnings("squid:S881")
+  public static int lastIndexOf(char toBeFound, CharSequence searchIn, int startIndex, int endIndex) {
+    for (int i = endIndex; --i >= startIndex;) {
+      if (toBeFound == searchIn.charAt(i)) {
+        return i;
+      }
+    }
+    return INDEX_NOT_FOUND;
+  }
+
+  /**
+   * Gets the next index after the given offset at which the current line ends. If invoked for the last line, the array
+   * length (end) is returned.
+   *
+   * @param searchIn
+   *          The array to search in.
+   * @param offset
+   *          The offset within the array where to start the search.
+   * @return The next line end character after the given offset. If no one can be found the array length is returned.
+   */
+  @SuppressWarnings("HardcodedLineSeparator")
+  public static int nextLineEnd(char[] searchIn, int offset) {
+    int nlPos = indexOf('\n', searchIn, offset);
+    if (nlPos < 0) {
+      return searchIn.length; // no more newline found: search to the end of searchIn
+    }
+    if (nlPos > 0 && searchIn[nlPos - 1] == '\r') {
+      nlPos--;
+    }
+    return nlPos;
+  }
+
+  /**
+   * Replaces all occurrences of a character in the specified {@link CharSequence} with another character.
+   *
+   * @param text
+   *          The text in which the characters should be replaced.
+   * @param search
+   *          The character to be replaced.
+   * @param replacement
+   *          The new character to insert instead.
+   * @return A {@link CharSequence} with the new content or {@code null} if the input text is {@code null}.
+   */
+  public static CharSequence replace(CharSequence text, char search, char replacement) {
+    if (text == null) {
+      return null;
+    }
+    if (text.length() < 1) {
+      return "";
+    }
+
+    StringBuilder result = new StringBuilder(text.length());
+    for (int i = 0; i < text.length(); i++) {
+      char c = text.charAt(i);
+      if (c == search) {
+        result.append(replacement);
+      }
+      else {
+        result.append(c);
+      }
+    }
+    return result;
+  }
+
+  /**
    * Converts the {@link StringBuilder} specified into a {@code char[]}.
    *
    * @param s
@@ -59,7 +573,7 @@
 
   /**
    * <p>
-   * Repeat a String n times to form a new {@link String}.
+   * Repeat a {@link CharSequence} n times to form a new {@link CharSequence}.
    * </p>
    * <b>Examples:</b>
    *
@@ -73,12 +587,12 @@
    * </pre>
    *
    * @param str
-   *          the {@link String} to repeat, may be {@code null}.
+   *          the {@link CharSequence} to repeat, may be {@code null}.
    * @param n
    *          number of times to repeat, negative treated as zero.
-   * @return a new String consisting of the original String repeated
+   * @return a new {@link CharSequence} consisting of the original CharSequence repeated
    */
-  public static String repeat(String str, int n) {
+  public static CharSequence repeat(CharSequence str, int n) {
     if (str == null) {
       return null;
     }
@@ -89,12 +603,12 @@
     for (int i = 0; i < n; i++) {
       b.append(str);
     }
-    return b.toString();
+    return b;
   }
 
   /**
    * <p>
-   * Replaces a String with another String inside a larger String
+   * Replaces a {@link CharSequence} with another {@link CharSequence} inside a larger {@link CharSequence}
    * </p>
    * <p>
    * A {@code null} reference passed to this method is a no-op.
@@ -119,17 +633,17 @@
    * @param text
    *          text to search and replace in, may be {@code null}.
    * @param searchString
-   *          the String to search for, may be {@code null}.
+   *          the {@link CharSequence} to search for, may be {@code null}.
    * @param replacement
-   *          the String to replace it with, may be {@code null}.
-   * @return the text with any replacements processed, {@code null} if {@code null} String input.
+   *          the {@link CharSequence} to replace it with, may be {@code null}.
+   * @return the text with any replacements processed, {@code null} if {@code null} input.
    */
-  public static String replace(String text, String searchString, String replacement) {
+  public static CharSequence replace(CharSequence text, CharSequence searchString, CharSequence replacement) {
     if (isEmpty(text) || isEmpty(searchString) || replacement == null || Objects.equals(searchString, replacement)) {
       return text;
     }
     int start = 0;
-    int end = text.indexOf(searchString, start);
+    int end = indexOf(searchString, text, start);
     if (end == INDEX_NOT_FOUND) {
       return text;
     }
@@ -141,50 +655,18 @@
     while (end != INDEX_NOT_FOUND) {
       buf.append(text, start, end).append(replacement);
       start = end + replLength;
-      end = text.indexOf(searchString, start);
+      end = indexOf(searchString, text, start);
     }
-    buf.append(text.substring(start));
-    return buf.toString();
-  }
-
-  /**
-   * Replaces all occurrences of a character in the specified {@link CharSequence} with another character.
-   *
-   * @param text
-   *          The text in which the characters should be replaced.
-   * @param search
-   *          The character to be replaced.
-   * @param replacement
-   *          The new character to insert instead.
-   * @return A {@link String} with the new content or {@code null} if the input text is {@code null}.
-   */
-  public static String replace(CharSequence text, char search, char replacement) {
-    if (text == null) {
-      return null;
-    }
-    if (text.length() < 1) {
-      return "";
-    }
-
-    StringBuilder result = new StringBuilder(text.length());
-    for (int i = 0; i < text.length(); i++) {
-      char c = text.charAt(i);
-      if (c == search) {
-        result.append(replacement);
-      }
-      else {
-        result.append(c);
-      }
-    }
-    return result.toString();
+    buf.append(text.subSequence(start, text.length()));
+    return buf;
   }
 
   /**
    * <p>
-   * Counts how many times the substring appears in the larger {@link String}.
+   * Counts how many times the substring appears in the larger {@link CharSequence}.
    * </p>
    * <p>
-   * A {@code null} or empty ("") {@link String} input returns {@code 0}.
+   * A {@code null} or empty ("") {@link CharSequence} input returns {@code 0}.
    * </p>
    * <p>
    * <p>
@@ -201,18 +683,18 @@
    * </pre>
    *
    * @param str
-   *          the {@link String} to check, may be {@code null}.
+   *          the {@link CharSequence} to check, may be {@code null}.
    * @param sub
    *          the substring to count, may be {@code null}.
-   * @return the number of occurrences, 0 if either {@link String} is {@code null}.
+   * @return the number of occurrences, 0 if either {@link CharSequence} is {@code null}.
    */
-  public static int countMatches(String str, String sub) {
+  public static int countMatches(CharSequence str, CharSequence sub) {
     if (isEmpty(str) || isEmpty(sub)) {
       return 0;
     }
     int count = 0;
     int idx = 0;
-    while ((idx = str.indexOf(sub, idx)) != INDEX_NOT_FOUND) {
+    while ((idx = indexOf(sub, str, idx)) != INDEX_NOT_FOUND) {
       count++;
       idx += sub.length();
     }
@@ -347,27 +829,6 @@
   }
 
   /**
-   * Converts the given input string literal into the representing original string.<br>
-   * This is the inverse function of {@link #toStringLiteral(String)}.
-   *
-   * @param s
-   *          The literal with leading and ending double-quotes
-   * @return the original (un-escaped) string. if it is no valid literal string, {@code null} is returned.
-   */
-  public static String fromStringLiteral(String s) {
-    if (s == null) {
-      return null;
-    }
-
-    int len = s.length();
-    if (len < 2 || s.charAt(0) != '"' || s.charAt(len - 1) != '"') {
-      return null;
-    }
-
-    return replaceLiterals(s.substring(1, len - 1), true);
-  }
-
-  /**
    * Converts the stack trace of the given {@link Throwable} into a {@link String}.
    * <p>
    * The resulting {@link String} contains no leading or trailing line separators.
@@ -389,11 +850,26 @@
     }
   }
 
-  private static String replaceLiterals(String result, boolean fromLiteral) {
+  /**
+   * Converts the given input string literal into the representing original string.<br>
+   * This is the inverse function of {@link #toStringLiteral(CharSequence)}.
+   *
+   * @param s
+   *          The literal with leading and ending double-quotes
+   * @return the original (un-escaped) string. if it is no valid literal string, {@code null} is returned.
+   */
+  public static CharSequence fromStringLiteral(CharSequence s) {
+    if (s == null) {
+      return null;
+    }
+    return replaceLiterals(withoutQuotes(s), true);
+  }
+
+  private static CharSequence replaceLiterals(CharSequence result, boolean fromLiteral) {
     //noinspection HardcodedLineSeparator
-    String[] a = {"\b", "\t", "\n", "\f", "\r", "\"", "\\", "\0", "\1", "\2", "\3", "\4", "\5", "\6", "\7"};
+    CharSequence[] a = {"\b", "\t", "\n", "\f", "\r", "\"", "\\", "\0", "\1", "\2", "\3", "\4", "\5", "\6", "\7"};
     //noinspection HardcodedLineSeparator
-    String[] b = {"\\b", "\\t", "\\n", "\\f", "\\r", "\\\"", "\\\\", "\\0", "\\1", "\\2", "\\3", "\\4", "\\5", "\\6", "\\7"};
+    CharSequence[] b = {"\\b", "\\t", "\\n", "\\f", "\\r", "\\\"", "\\\\", "\\0", "\\1", "\\2", "\\3", "\\4", "\\5", "\\6", "\\7"};
 
     if (fromLiteral) {
       return replaceEach(result, b, a);
@@ -402,15 +878,15 @@
   }
 
   /**
-   * Converts the given string into a string literal with leading and ending double-quotes including escaping of the
-   * given string.<br>
-   * This is the inverse function of {@link #fromStringLiteral(String)}.
+   * Converts the given {@link CharSequence} into a string literal with leading and ending double-quotes including
+   * escaping of the given string.<br>
+   * This is the inverse function of {@link #fromStringLiteral(CharSequence)}.
    *
    * @param s
    *          the string to convert.
    * @return the literal string ready to be directly inserted into java source or null if the input string is null.
    */
-  public static String toStringLiteral(String s) {
+  public static CharSequence toStringLiteral(CharSequence s) {
     if (s == null) {
       return null;
     }
@@ -419,11 +895,51 @@
     b.append('"'); // opening delimiter
     b.append(replaceLiterals(s, false));
     b.append('"'); // closing delimiter
-    return b.toString();
+    return b;
   }
 
   /**
-   * ensures the given java name starts with an upper case character.<br>
+   * Removes leading or trailing double quotes ("), single quotes (') or back ticks (`) from the input.
+   * 
+   * @param literal
+   *          The literal from which the quotes should be removed.
+   * @return The input with removed quotes.
+   */
+  public static CharSequence withoutQuotes(CharSequence literal) {
+    return withoutQuotes(literal, true, true, true);
+  }
+
+  /**
+   * Removes leading and trailing quotes (if existing) from the literal given.<br>
+   * Only the first quotes are removed. If there are nested quotes, the are part of the result.
+   * 
+   * @param literal
+   *          The literal from which the quotes should be removed.
+   * @param removeDouble
+   *          {@code true} if double quotes (") should be removed if found.
+   * @param removeSingle
+   *          {@code true} if single quotes (') should be removed if found.
+   * @param removeBackTick
+   *          {@code true} if back ticks (`) should be removed if found.
+   * @return The input with removed leading and trailing quotes respecting the enabled quote types.
+   */
+  public static CharSequence withoutQuotes(CharSequence literal, boolean removeDouble, boolean removeSingle, boolean removeBackTick) {
+    if (literal == null || literal.length() < 2 || (!removeDouble && !removeSingle && !removeBackTick)) {
+      return literal;
+    }
+
+    boolean[] enabled = new boolean[]{removeDouble, removeSingle, removeBackTick};
+    char[] toRemove = new char[]{'"', '\'', '`'};
+    for (int i = 0; i < toRemove.length; i++) {
+      if (enabled[i] && literal.charAt(0) == toRemove[i] && literal.charAt(literal.length() - 1) == toRemove[i]) {
+        return literal.subSequence(1, literal.length() - 1);
+      }
+    }
+    return literal;
+  }
+
+  /**
+   * Ensures the given name starts with an upper case character.<br>
    * <br>
    * <b>Note:</b><br>
    * To ensure the first char starts with a lower case letter use {@link Introspector#decapitalize(String)}
@@ -433,14 +949,13 @@
    * @return null if the input is null, an empty string if the given string is empty or only contains white spaces.
    *         Otherwise the input string is returned with the first character modified to upper case.
    */
-  public static String ensureStartWithUpperCase(String name) {
+  public static CharSequence ensureStartWithUpperCase(CharSequence name) {
     if (isEmpty(name) || Character.isUpperCase(name.charAt(0))) {
       return name;
     }
-
-    char[] chars = name.toCharArray();
-    chars[0] = Character.toUpperCase(chars[0]);
-    return new String(chars);
+    return new StringBuilder(name.length())
+        .append(Character.toUpperCase(name.charAt(0)))
+        .append(name, 1, name.length());
   }
 
   /**
@@ -450,15 +965,15 @@
    *          The input HTML.
    * @return The escaped version.
    */
-  public static String escapeHtml(String html) {
+  public static CharSequence escapeHtml(CharSequence html) {
     return replaceEach(html,
-        new String[]{"\"", "&", "<", ">", "'", "/"},
-        new String[]{"&quot;", "&amp;", "&lt;", "&gt;", "&apos;", "&#47;"});
+        new CharSequence[]{"\"", "&", "<", ">", "'", "/"},
+        new CharSequence[]{"&#92;", "&#38;", "&#60;", "&#62;", "&#39;", "&#47;"});
   }
 
   /**
    * <p>
-   * Replaces all occurrences of Strings within another String.
+   * Replaces all occurrences of strings within another string.
    * </p>
    * <p>
    * A {@code null} reference passed to this method is a no-op, or if any "search string" or "string to replace" is
@@ -483,16 +998,16 @@
    * @param text
    *          text to search and replace in, no-op if {@code null}.
    * @param searchList
-   *          the Strings to search for, no-op if {@code null}.
+   *          the strings to search for, no-op if {@code null}.
    * @param replacementList
-   *          the Strings to replace them with, no-op if {@code null}.
-   * @return the text with any replacements processed, {@code null} if {@code null} String input.
+   *          the strings to replace them with, no-op if {@code null}.
+   * @return the text with any replacements processed, {@code null} if {@code null} input.
    * @throws IllegalArgumentException
    *           if the lengths of the arrays are not the same ({@code null} is ok, and/or size 0)
    */
   @SuppressWarnings("pmd:NPathComplexity")
-  public static String replaceEach(String text, String[] searchList, String[] replacementList) {
-    if (text == null || text.isEmpty()) {
+  public static CharSequence replaceEach(CharSequence text, CharSequence[] searchList, CharSequence[] replacementList) {
+    if (text == null || text.length() == 0) {
       return text;
     }
     if (searchList == null || searchList.length == 0) {
@@ -504,37 +1019,31 @@
 
     int searchLength = searchList.length;
     int replacementLength = replacementList.length;
-
-    // make sure lengths are ok, these need to be equal
-    if (searchLength != replacementLength) {
-      throw new IllegalArgumentException("Search and Replace array lengths don't match: "
-          + searchLength
-          + " vs "
-          + replacementLength);
+    if (searchLength != replacementLength) { // make sure lengths are ok, these need to be equal
+      throw newFail("Search and Replace array lengths don't match: {} vs {}", searchLength, replacementLength);
     }
 
     // keep track of which still have matches
     boolean[] noMoreMatchesForReplIndex = new boolean[searchLength];
 
     // index on index that the match was found
-    int textIndex = -1;
-    int replaceIndex = -1;
+    int textIndex = INDEX_NOT_FOUND;
+    int replaceIndex = INDEX_NOT_FOUND;
     int tempIndex;
 
     // index of replace array that will replace the search string found
     for (int i = 0; i < searchLength; i++) {
-      if (noMoreMatchesForReplIndex[i] || searchList[i] == null ||
-          searchList[i].isEmpty() || replacementList[i] == null) {
+      if (noMoreMatchesForReplIndex[i] || searchList[i] == null || searchList[i].length() == 0 || replacementList[i] == null) {
         continue;
       }
-      tempIndex = text.indexOf(searchList[i]);
+      tempIndex = indexOf(searchList[i], text);
 
       // see if we need to keep searching for this
-      if (tempIndex == -1) {
+      if (tempIndex == INDEX_NOT_FOUND) {
         noMoreMatchesForReplIndex[i] = true;
       }
       else {
-        if (textIndex == -1 || tempIndex < textIndex) {
+        if (textIndex == INDEX_NOT_FOUND || tempIndex < textIndex) {
           textIndex = tempIndex;
           replaceIndex = i;
         }
@@ -542,7 +1051,7 @@
     }
 
     // no search strings found, we are done
-    if (textIndex == -1) {
+    if (textIndex == INDEX_NOT_FOUND) {
       return text;
     }
 
@@ -550,9 +1059,8 @@
 
     // get a good guess on the size of the result buffer so it doesn't have to double if it goes over a bit
     int increase = getLengthIncreaseGuess(text, searchList, replacementList);
-
     StringBuilder result = new StringBuilder(text.length() + increase);
-    while (textIndex != -1) {
+    while (textIndex != INDEX_NOT_FOUND) {
 
       for (int i = start; i < textIndex; i++) {
         result.append(text.charAt(i));
@@ -561,22 +1069,21 @@
 
       start = textIndex + searchList[replaceIndex].length();
 
-      textIndex = -1;
-      replaceIndex = -1;
+      textIndex = INDEX_NOT_FOUND;
+      replaceIndex = INDEX_NOT_FOUND;
       // find the next earliest match
       for (int i = 0; i < searchLength; i++) {
-        if (noMoreMatchesForReplIndex[i] || searchList[i] == null ||
-            searchList[i].isEmpty() || replacementList[i] == null) {
+        if (noMoreMatchesForReplIndex[i] || searchList[i] == null || searchList[i].length() == 0 || replacementList[i] == null) {
           continue;
         }
-        tempIndex = text.indexOf(searchList[i], start);
+        tempIndex = indexOf(searchList[i], text, start);
 
         // see if we need to keep searching for this
-        if (tempIndex == -1) {
+        if (tempIndex == INDEX_NOT_FOUND) {
           noMoreMatchesForReplIndex[i] = true;
         }
         else {
-          if (textIndex == -1 || tempIndex < textIndex) {
+          if (textIndex == INDEX_NOT_FOUND || tempIndex < textIndex) {
             textIndex = tempIndex;
             replaceIndex = i;
           }
@@ -587,24 +1094,23 @@
     for (int i = start; i < textLength; i++) {
       result.append(text.charAt(i));
     }
-    return result.toString();
+    return result;
   }
 
-  private static int getLengthIncreaseGuess(CharSequence text, String[] searchList, String[] replacementList) {
+  private static int getLengthIncreaseGuess(CharSequence text, CharSequence[] searchList, CharSequence[] replacementList) {
     int increase = 0;
     // count the replacement text elements that are larger than their corresponding text being replaced
     for (int i = 0; i < searchList.length; i++) {
       if (searchList[i] == null || replacementList[i] == null) {
         continue;
       }
-      int greater = replacementList[i].length() - searchList[i].length();
-      if (greater > 0) {
-        increase += 3 * greater; // assume 3 matches
+      int longer = replacementList[i].length() - searchList[i].length();
+      if (longer > 0) {
+        increase += 3 * longer; // assume 3 matches
       }
     }
     // have upper-bound at 20% increase, then let Java take over
-    increase = Math.min(increase, text.length() / 5);
-    return increase;
+    return Math.min(increase, text.length() / 5);
   }
 
   /**
@@ -731,6 +1237,15 @@
    * @see String#compareTo(String)
    */
   public static int compareTo(CharSequence a, CharSequence b) {
+    if (a == null && b == null) {
+      return 0;
+    }
+    if (a == null) {
+      return INDEX_NOT_FOUND;
+    }
+    if (b == null) {
+      return 1;
+    }
     int limit = Math.min(a.length(), b.length());
     for (int i = 0; i < limit; i++) {
       char x = a.charAt(i);
diff --git a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/classid/ClassIdDuplicateResolution.java b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/classid/ClassIdDuplicateResolution.java
index 398ec54..349301e 100644
--- a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/classid/ClassIdDuplicateResolution.java
+++ b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/classid/ClassIdDuplicateResolution.java
@@ -74,7 +74,7 @@
     }
   }
 
-  protected static BiConsumer<EclipseEnvironment, EclipseProgress> createUpdateAnnotationInJavaSourceOperation(IType annotationOwner, String newId) {
+  protected static BiConsumer<EclipseEnvironment, EclipseProgress> createUpdateAnnotationInJavaSourceOperation(IType annotationOwner, CharSequence newId) {
     return new AnnotationNewOperation(ScoutAnnotationGenerator.createClassId(newId), annotationOwner);
   }
 }
diff --git a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/TranslationInputValidator.java b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/TranslationInputValidator.java
index 1ea37a6..57b3a1b 100644
--- a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/TranslationInputValidator.java
+++ b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/TranslationInputValidator.java
@@ -10,15 +10,17 @@
  */
 package org.eclipse.scout.sdk.s2e.ui.internal.nls;
 
+import static org.eclipse.scout.sdk.core.s.nls.TranslationValidator.isForbidden;
+import static org.eclipse.scout.sdk.core.s.nls.TranslationValidator.validateDefaultText;
+import static org.eclipse.scout.sdk.core.s.nls.TranslationValidator.validateKey;
+
 import java.util.Collection;
-import java.util.Optional;
 
 import org.eclipse.core.runtime.IStatus;
 import org.eclipse.core.runtime.Status;
-import org.eclipse.scout.sdk.core.s.nls.ITranslation;
-import org.eclipse.scout.sdk.core.s.nls.ITranslationEntry;
+import org.eclipse.scout.sdk.core.s.nls.ITranslationStore;
 import org.eclipse.scout.sdk.core.s.nls.TranslationStoreStack;
-import org.eclipse.scout.sdk.core.util.Strings;
+import org.eclipse.scout.sdk.core.s.nls.TranslationValidator;
 import org.eclipse.scout.sdk.s2e.ui.internal.S2ESdkUiActivator;
 
 /**
@@ -32,35 +34,57 @@
   }
 
   public static IStatus validateDefaultTranslation(CharSequence defaultTranslation) {
-    if (Strings.isEmpty(defaultTranslation)) {
-      return new Status(IStatus.ERROR, S2ESdkUiActivator.PLUGIN_ID, -1, "The default translation must be set.", null);
+    return toStatus(validateDefaultText(defaultTranslation));
+  }
+
+  public static IStatus validateNlsKey(TranslationStoreStack project, ITranslationStore target, String key) {
+    return validateNlsKey(project, target, key, null);
+  }
+
+  public static IStatus validateNlsKey(TranslationStoreStack project, ITranslationStore target, String key, Collection<String> acceptedKeys) {
+    return toStatus(validateKey(project, target, key, acceptedKeys));
+  }
+
+  public static IStatus validateTranslationStore(ITranslationStore store) {
+    if (store == null) {
+      return new Status(IStatus.ERROR, S2ESdkUiActivator.PLUGIN_ID, -1, "Please choose a service.", null);
+    }
+    if (!store.isEditable()) {
+      return new Status(IStatus.ERROR, S2ESdkUiActivator.PLUGIN_ID, -1, "The selected service is read-only.", null);
     }
     return Status.OK_STATUS;
   }
 
-  public static IStatus validateNlsKey(TranslationStoreStack project, String key) {
-    return validateNlsKey(project, key, null);
+  private static IStatus toStatus(int validationResult) {
+    if (validationResult == TranslationValidator.OK) {
+      return Status.OK_STATUS;
+    }
+    int severity = IStatus.ERROR;
+    if (!isForbidden(validationResult)) {
+      severity = IStatus.WARNING;
+    }
+    return new Status(severity, S2ESdkUiActivator.PLUGIN_ID, validationResult, getValidationText(validationResult), null);
   }
 
-  public static IStatus validateNlsKey(TranslationStoreStack project, String key, Collection<String> exceptions) {
-    if (Strings.isBlank(key)) {
-      return new Status(IStatus.ERROR, S2ESdkUiActivator.PLUGIN_ID, -1, "Please specify a key.", null);
+  private static String getValidationText(int validationResult) {
+    switch (validationResult) {
+      case TranslationValidator.OK:
+        return "";
+      case TranslationValidator.DEFAULT_TRANSLATION_MISSING_ERROR:
+      case TranslationValidator.DEFAULT_TRANSLATION_EMPTY_ERROR:
+        return "The default text must be set.";
+      case TranslationValidator.KEY_EMPTY_ERROR:
+        return "Please specify a key.";
+      case TranslationValidator.KEY_ALREADY_EXISTS_ERROR:
+        return "This key already exists!";
+      case TranslationValidator.KEY_OVERRIDES_OTHER_STORE_WARNING:
+        return "The key overrides an inherited entry.";
+      case TranslationValidator.KEY_IS_OVERRIDDEN_BY_OTHER_STORE_WARNING:
+        return "The key is overridden by another entry.";
+      case TranslationValidator.KEY_OVERRIDES_AND_IS_OVERRIDDEN_WARNING:
+        return "The key overrides an inherited entry and is itself overridden by another entry.";
+      default:
+        return "The key name is not valid.";
     }
-
-    if (exceptions == null || !exceptions.contains(key)) {
-      Optional<? extends ITranslationEntry> e = project.translation(key);
-      if (e.isPresent()) {
-        if (e.get().store().isEditable()) {
-          return new Status(IStatus.ERROR, S2ESdkUiActivator.PLUGIN_ID, -1, "A key '" + key + "' already exists!", null);
-        }
-        return new Status(IStatus.WARNING, S2ESdkUiActivator.PLUGIN_ID, -1, "The key '" + key + "' overrides an inherited entry.", null);
-      }
-    }
-
-    if (!ITranslation.KEY_REGEX.matcher(key).matches()) {
-      return new Status(IStatus.ERROR, S2ESdkUiActivator.PLUGIN_ID, -1, "The key name is not valid.", null);
-    }
-
-    return Status.OK_STATUS;
   }
 }
diff --git a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/TranslationNewDialog.java b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/TranslationNewDialog.java
index 0f1bf76..a111639 100644
--- a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/TranslationNewDialog.java
+++ b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/TranslationNewDialog.java
@@ -10,12 +10,18 @@
  */
 package org.eclipse.scout.sdk.s2e.ui.internal.nls;
 
+import static org.eclipse.scout.sdk.core.s.nls.TranslationValidator.isForbidden;
+import static org.eclipse.scout.sdk.s2e.ui.internal.nls.TranslationInputValidator.validateDefaultTranslation;
+import static org.eclipse.scout.sdk.s2e.ui.internal.nls.TranslationInputValidator.validateNlsKey;
+import static org.eclipse.scout.sdk.s2e.ui.internal.nls.TranslationInputValidator.validateTranslationStore;
+
 import org.eclipse.core.runtime.IStatus;
 import org.eclipse.core.runtime.MultiStatus;
 import org.eclipse.jface.window.Window;
 import org.eclipse.scout.sdk.core.s.nls.ITranslation;
 import org.eclipse.scout.sdk.core.s.nls.Language;
 import org.eclipse.scout.sdk.core.s.nls.TranslationStoreStack;
+import org.eclipse.scout.sdk.core.s.nls.TranslationValidator;
 import org.eclipse.scout.sdk.s2e.ui.fields.text.TextField;
 import org.eclipse.scout.sdk.s2e.ui.internal.S2ESdkUiActivator;
 import org.eclipse.scout.sdk.s2e.ui.wizard.AbstractWizardPage;
@@ -39,20 +45,23 @@
 
   @Override
   protected void revalidate() {
-    MultiStatus status = new MultiStatus(S2ESdkUiActivator.PLUGIN_ID, -1, "multi status", null);
-    status.add(TranslationInputValidator.validateNlsKey(getNlsProject(), getKeyField().getText()));
+    MultiStatus status = new MultiStatus(S2ESdkUiActivator.PLUGIN_ID, TranslationValidator.OK, "multi status", null);
+
+    status.add(validateTranslationStore(getSelectedStore().orElse(null)));
+    getSelectedStore().ifPresent(store -> status.add(validateNlsKey(getNlsProject(), store, getKeyField().getText())));
 
     TextField defaultLanguageField = getTranslationField(Language.LANGUAGE_DEFAULT);
     if (defaultLanguageField != null) {
-      status.add(TranslationInputValidator.validateDefaultTranslation(defaultLanguageField.getText()));
+      status.add(validateDefaultTranslation(defaultLanguageField.getText()));
     }
 
+    IStatus worst = AbstractWizardPage.getHighestSeverityStatus(status);
     if (status.isOK()) {
-      setMessage("Create a new translation entry.");
+      setMessage("Create a new translation.");
     }
     else {
-      setMessage(AbstractWizardPage.getHighestSeverityStatus(status));
+      setMessage(worst);
     }
-    getButton(Window.OK).setEnabled(status.getSeverity() != IStatus.ERROR);
+    getButton(Window.OK).setEnabled(!isForbidden(worst.getCode()));
   }
 }
diff --git a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/editor/NlsFilterComponent.java b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/editor/NlsFilterComponent.java
index 2bd2c2e..b240b26 100644
--- a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/editor/NlsFilterComponent.java
+++ b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/editor/NlsFilterComponent.java
@@ -150,7 +150,7 @@
         text = "";
       }
       else {
-        text = Strings.replace(text, "&", "");
+        text = Strings.replace(text, "&", "").toString();
       }
       return CharOperation.match(m_pattern, text.toCharArray(), false);
     }
diff --git a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/editor/NlsTableController.java b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/editor/NlsTableController.java
index 2917c88..5da4acc 100644
--- a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/editor/NlsTableController.java
+++ b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/editor/NlsTableController.java
@@ -272,7 +272,7 @@
         Language lang = languageOfColumn(columnIndex);
         String text = element.text(lang).orElse("");
         //noinspection HardcodedLineSeparator
-        return Strings.replaceEach(text, new String[]{"\n", "\r"}, new String[]{" ", ""});
+        return Strings.replaceEach(text, new String[]{"\n", "\r"}, new String[]{" ", ""}).toString();
     }
   }
 
diff --git a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/editor/NlsTableCursor.java b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/editor/NlsTableCursor.java
index 3bd3f95..48fb60c 100644
--- a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/editor/NlsTableCursor.java
+++ b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/editor/NlsTableCursor.java
@@ -10,6 +10,8 @@
  */
 package org.eclipse.scout.sdk.s2e.ui.internal.nls.editor;
 
+import static org.eclipse.scout.sdk.core.s.nls.TranslationValidator.isForbidden;
+
 import java.util.EventListener;
 import java.util.HashMap;
 import java.util.Map;
@@ -20,6 +22,7 @@
 import org.eclipse.core.runtime.Status;
 import org.eclipse.jface.resource.ColorRegistry;
 import org.eclipse.scout.sdk.core.s.nls.ITranslationEntry;
+import org.eclipse.scout.sdk.core.s.nls.ITranslationStore;
 import org.eclipse.scout.sdk.core.s.nls.Language;
 import org.eclipse.scout.sdk.core.util.EventListenerList;
 import org.eclipse.scout.sdk.s2e.ui.internal.nls.TranslationInputValidator;
@@ -217,7 +220,8 @@
   private IStatus validateEditingText() {
     int selectedColumn = getCursor().getColumn();
     if (selectedColumn == NlsTableController.INDEX_COLUMN_KEYS) {
-      return TranslationInputValidator.validateNlsKey(m_controller.stack(), m_editingText.getText());
+      ITranslationStore storeOfSelectedRow = getSelection().get().store();
+      return TranslationInputValidator.validateNlsKey(m_controller.stack(), storeOfSelectedRow, m_editingText.getText());
     }
     return Status.OK_STATUS;
   }
@@ -244,11 +248,11 @@
     m_editingText = new TableTextEditor(getCursor(), isKeyColumn ? SWT.NONE : SWT.MULTI | SWT.V_SCROLL | SWT.H_SCROLL);
     m_editingText.setText(input);
     m_editingText.addModifyListener(e -> {
-      if (validateEditingText().isOK()) {
-        m_editingText.setForeground(null);
+      if (isForbidden(validateEditingText().getCode())) {
+        m_editingText.setForeground(m_editingText.getDisplay().getSystemColor(SWT.COLOR_RED));
       }
       else {
-        m_editingText.setForeground(m_editingText.getDisplay().getSystemColor(SWT.COLOR_RED));
+        m_editingText.setForeground(null);
       }
     });
     if (defaultText != null) {
@@ -321,7 +325,7 @@
       return;
     }
 
-    if (!validateEditingText().isOK()) {
+    if (isForbidden(validateEditingText().getCode())) {
       disposeText();
       return;
     }
diff --git a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/proposal/AbstractTranslationProposal.java b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/proposal/AbstractTranslationProposal.java
index eda38d5..b5e9a09 100644
--- a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/proposal/AbstractTranslationProposal.java
+++ b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/proposal/AbstractTranslationProposal.java
@@ -110,18 +110,18 @@
   protected static Point findKeyRange(IDocument document, int offset) throws BadLocationException {
     IRegion lineRange = document.getLineInformationOfOffset(offset);
     // find start
-    int startOffest = -1;
+    int startOffset = -1;
     int index = offset - 1;
     while (index > 0 && index > lineRange.getOffset()) {
       if (document.getChar(index) == '"') {
         if (index > 1) {
           if (document.getChar(index - 1) != '\\') {
-            startOffest = index + 1;
+            startOffset = index + 1;
             break;
           }
         }
         else {
-          startOffest = index + 1;
+          startOffset = index + 1;
           break;
         }
       }
@@ -146,12 +146,12 @@
       index++;
     }
 
-    if (startOffest > -1) {
+    if (startOffset > -1) {
       if (endOffset < 0) {
         // no end found: use the line end
         endOffset = lineRange.getOffset() + lineRange.getLength();
       }
-      return new Point(startOffest, endOffset);
+      return new Point(startOffset, endOffset);
     }
     return null;
   }
diff --git a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/proposal/TranslationNewProposal.java b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/proposal/TranslationNewProposal.java
index cc146f8..558da57 100644
--- a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/proposal/TranslationNewProposal.java
+++ b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/proposal/TranslationNewProposal.java
@@ -66,7 +66,7 @@
       proposalFieldText = "";
     }
     else {
-      proposalFieldText = Strings.fromStringLiteral('"' + searchText + '"');
+      proposalFieldText = Strings.fromStringLiteral('"' + searchText + '"').toString();
     }
 
     String key = m_stack.generateNewKey(proposalFieldText);
diff --git a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/proposal/TranslationProposal.java b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/proposal/TranslationProposal.java
index f9c999e..4621674 100644
--- a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/proposal/TranslationProposal.java
+++ b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/nls/proposal/TranslationProposal.java
@@ -10,6 +10,9 @@
  */
 package org.eclipse.scout.sdk.s2e.ui.internal.nls.proposal;
 
+import static org.eclipse.scout.sdk.core.util.Strings.escapeHtml;
+import static org.eclipse.scout.sdk.core.util.Strings.replaceEach;
+
 import java.util.Locale;
 import java.util.Map;
 import java.util.Map.Entry;
@@ -20,7 +23,6 @@
 import org.eclipse.scout.sdk.core.log.SdkLog;
 import org.eclipse.scout.sdk.core.s.nls.ITranslation;
 import org.eclipse.scout.sdk.core.s.nls.Language;
-import org.eclipse.scout.sdk.core.util.Strings;
 import org.eclipse.scout.sdk.s2e.ui.ISdkIcons;
 import org.eclipse.scout.sdk.s2e.ui.internal.S2ESdkUiActivator;
 import org.eclipse.swt.graphics.Image;
@@ -56,10 +58,9 @@
 
     StringBuilder b = new StringBuilder();
     for (Entry<Language, String> e : allTranslations.entrySet()) {
-      String text = e.getValue();
       //noinspection HardcodedLineSeparator
-      text = Strings.replaceEach(Strings.escapeHtml(text), new String[]{"\n", "\r"}, new String[]{"<br>", ""});
-      b.append("<b>").append(text).append("</b> [").append(e.getKey().displayName()).append("]<br>");
+      CharSequence text = replaceEach(escapeHtml(e.getValue()), new String[]{"\n", "\r"}, new String[]{"<br>", ""});
+      b.append("<b>").append(text).append("</b> [").append(escapeHtml(e.getKey().displayName())).append("]<br>");
     }
     return b.toString();
   }
diff --git a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/template/LinkedAsyncProposalModelPresenter.java b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/template/LinkedAsyncProposalModelPresenter.java
index 4df4bdb..cfd3148 100644
--- a/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/template/LinkedAsyncProposalModelPresenter.java
+++ b/org.eclipse.scout.sdk.s2e.ui/src/main/java/org/eclipse/scout/sdk/s2e/ui/internal/template/LinkedAsyncProposalModelPresenter.java
@@ -72,7 +72,7 @@
     if (endPosition != null) {
       offset = endPosition.getOffset();
     }
-    if (offset != -1 && endPosition != null) {
+    if (offset != -1) {
       ui.setExitPosition(viewer, offset + endPosition.getLength(), 0, Integer.MAX_VALUE);
     }
     else if (!switchedEditor) {
diff --git a/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/environment/EclipseEnvironment.java b/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/environment/EclipseEnvironment.java
index 7f73c93..49b4fce 100644
--- a/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/environment/EclipseEnvironment.java
+++ b/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/environment/EclipseEnvironment.java
@@ -204,8 +204,8 @@
    *          it is created.
    * @param progress
    *          The {@link EclipseProgress} monitor. Typically a {@link IProgress#newChild(int)} should be passed to this
-   *          method. The write operation will call {@link IProgress#init(int, String, Object...)} on the argument. Must
-   *          not be {@code null}.
+   *          method. The write operation will call {@link IProgress#init(int, CharSequence, Object...)} on the
+   *          argument. Must not be {@code null}.
    */
   public void writeResource(CharSequence content, IFile file, EclipseProgress progress) {
     doWriteResource(file, content, progress, true).awaitDoneThrowingOnErrorOrCancel();
@@ -225,8 +225,8 @@
    *          it is created.
    * @param progress
    *          The {@link EclipseProgress} monitor. Typically a {@link IProgress#newChild(int)} should be passed to this
-   *          method. The write operation will call {@link IProgress#init(int, String, Object...)} on the argument. Must
-   *          not be {@code null}.
+   *          method. The write operation will call {@link IProgress#init(int, CharSequence, Object...)} on the
+   *          argument. Must not be {@code null}.
    * @return An {@link IFuture} that can be used to wait until the file has been written. If there was an exception
    *         writing the resource, this exception will be thrown on result access of this {@link IFuture}.
    */
diff --git a/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/environment/ResourceWriteOperation.java b/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/environment/ResourceWriteOperation.java
index 5a8e68a..9f64b62 100644
--- a/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/environment/ResourceWriteOperation.java
+++ b/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/environment/ResourceWriteOperation.java
@@ -22,7 +22,6 @@
 import org.eclipse.core.runtime.IProgressMonitor;
 import org.eclipse.core.runtime.IStatus;
 import org.eclipse.scout.sdk.core.log.SdkLog;
-import org.eclipse.scout.sdk.core.util.Chars;
 import org.eclipse.scout.sdk.core.util.Ensure;
 import org.eclipse.scout.sdk.core.util.Strings;
 import org.eclipse.scout.sdk.s2e.util.CharSequenceInputStream;
@@ -91,7 +90,7 @@
     }
     try (InputStream in = file.getContents()) {
       StringBuilder fileContent = Strings.fromInputStream(in, file.getCharset());
-      return Chars.equals(fileContent, newContent);
+      return Strings.equals(fileContent, newContent);
     }
     catch (IOException | CoreException e) {
       SdkLog.warning("Unable to read contents of file '{}'.", file, e);
diff --git a/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/nls/EclipseTranslationStoreSupplier.java b/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/nls/EclipseTranslationStoreSupplier.java
index 9e3b512..2379b34 100644
--- a/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/nls/EclipseTranslationStoreSupplier.java
+++ b/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/nls/EclipseTranslationStoreSupplier.java
@@ -11,8 +11,9 @@
 package org.eclipse.scout.sdk.s2e.nls;
 
 import static java.util.Collections.emptyList;
+import static java.util.stream.Collectors.toList;
 import static org.eclipse.scout.sdk.core.model.api.Flags.isAbstract;
-import static org.eclipse.scout.sdk.core.s.nls.properties.PropertiesTextProviderService.resourceMatchesPrefix;
+import static org.eclipse.scout.sdk.core.s.nls.properties.AbstractTranslationPropertiesFile.parseLanguageFromFileName;
 
 import java.io.InputStream;
 import java.nio.file.Path;
@@ -46,7 +47,6 @@
 import org.eclipse.scout.sdk.core.s.environment.IProgress;
 import org.eclipse.scout.sdk.core.s.nls.ITranslationStore;
 import org.eclipse.scout.sdk.core.s.nls.ITranslationStoreSupplier;
-import org.eclipse.scout.sdk.core.s.nls.properties.AbstractTranslationPropertiesFile;
 import org.eclipse.scout.sdk.core.s.nls.properties.EditableTranslationFile;
 import org.eclipse.scout.sdk.core.s.nls.properties.ITranslationPropertiesFile;
 import org.eclipse.scout.sdk.core.s.nls.properties.PropertiesTextProviderService;
@@ -133,10 +133,10 @@
 
   private static boolean loadStoreFromWorkspace(IType jdtType, PropertiesTranslationStore store, IProgress progress) {
     try {
-      store.load(filesFromWorkspace(jdtType, store), progress);
+      store.load(filesFromWorkspace(jdtType, store.service()), progress);
       return true;
     }
-    catch (CoreException e) {
+    catch (JavaModelException e) {
       SdkLog.warning("Unable to load properties files of type '{}'.", jdtType.getFullyQualifiedName(), e);
       return false;
     }
@@ -159,30 +159,34 @@
     }
   }
 
-  private static Collection<ITranslationPropertiesFile> filesFromWorkspace(IJavaElement jdtType, @SuppressWarnings("TypeMayBeWeakened") PropertiesTranslationStore store) throws CoreException {
-    IPath translationPath = new org.eclipse.core.runtime.Path(store.service().folder());
-    List<IFile> propertiesFiles = getAllTranslations(jdtType.getJavaProject(), translationPath, store.service().filePrefix());
-
-    Collection<ITranslationPropertiesFile> translationFiles = new ArrayList<>(propertiesFiles.size());
-    for (IFile file : propertiesFiles) {
-      translationFiles.add(new EditableTranslationFile(file.getLocation().toFile().toPath()));
-    }
-    return translationFiles;
+  private static Collection<ITranslationPropertiesFile> filesFromWorkspace(IJavaElement jdtType, PropertiesTextProviderService service) throws JavaModelException {
+    IPath translationPath = new org.eclipse.core.runtime.Path(service.folder());
+    return getFiles(jdtType.getJavaProject(), translationPath, service.filePrefix());
   }
 
-  private static List<IFile> getAllTranslations(IJavaProject toLookAt, IPath path, String fileNamePrefix) throws CoreException {
-    return getAllTranslations(getFoldersOfProject(toLookAt, path), fileNamePrefix);
+  private static List<ITranslationPropertiesFile> getFiles(IJavaProject toLookAt, IPath path, String fileNamePrefix) throws JavaModelException {
+    return getFoldersOfProject(toLookAt, path)
+        .flatMap(EclipseTranslationStoreSupplier::filesInFolder)
+        .map(file -> toEditableTranslationFile(file, fileNamePrefix))
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .collect(toList());
   }
 
-  private static List<IFolder> getFoldersOfProject(IJavaProject project, IPath path) throws JavaModelException {
+  private static Optional<ITranslationPropertiesFile> toEditableTranslationFile(IResource file, String fileNamePrefix) {
+    return parseLanguageFromFileName(file.getName(), fileNamePrefix)
+        .map(lang -> new EditableTranslationFile(file.getLocation().toFile().toPath(), lang));
+  }
+
+  private static Stream<IFolder> getFoldersOfProject(IJavaProject project, IPath path) throws JavaModelException {
     if (!JdtUtils.exists(project) || !project.getProject().isAccessible()) {
-      return emptyList();
+      return Stream.empty();
     }
 
     // check runtime dir
     IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot();
     IClasspathEntry[] clEntries = project.getRawClasspath();
-    List<IFolder> folders = new ArrayList<>();
+    Collection<IFolder> folders = new ArrayList<>();
     for (IClasspathEntry entry : clEntries) {
       if (entry.getEntryKind() == IClasspathEntry.CPE_SOURCE) {
         IPath toCheck = entry.getPath().append(path);
@@ -198,22 +202,18 @@
     if (foundFolder != null && foundFolder.exists()) {
       folders.add(foundFolder);
     }
-    return folders;
+    return folders.stream();
   }
 
-  private static List<IFile> getAllTranslations(Iterable<IFolder> folders, String fileNamePrefix) throws CoreException {
-    List<IFile> files = new ArrayList<>();
-    for (IFolder folder : folders) {
-      if (folder.exists()) {
-        IResource[] resources = folder.members(IResource.NONE);
-        for (IResource resource : resources) {
-          if (resource instanceof IFile && resourceMatchesPrefix(resource.getName(), fileNamePrefix)) {
-            files.add((IFile) resource);
-          }
-        }
-      }
+  private static Stream<IFile> filesInFolder(IFolder folder) {
+    try {
+      return Stream.of(folder.members(IResource.NONE))
+          .filter(member -> member instanceof IFile)
+          .map(member -> (IFile) member);
     }
-    return files;
+    catch (CoreException e) {
+      throw new SdkException("Cannot read content of folder '{}'.", folder, e);
+    }
   }
 
   private static Collection<ITranslationPropertiesFile> filesFromPlatform(IPackageFragmentRoot r, @SuppressWarnings("TypeMayBeWeakened") PropertiesTranslationStore store) throws JavaModelException {
@@ -230,10 +230,9 @@
     for (Object o : textFolder.getNonJavaResources()) {
       if (o instanceof IStorage) {
         IStorage f = (IStorage) o;
-        String fileName = f.getName();
-        if (resourceMatchesPrefix(fileName, fileNamePrefix)) {
-          translationFiles.add(new ReadOnlyTranslationFile(() -> contentsOf(f), AbstractTranslationPropertiesFile.parseFromFileNameOrThrow(fileName)));
-        }
+        parseLanguageFromFileName(f.getName(), fileNamePrefix)
+            .map(lang -> new ReadOnlyTranslationFile(() -> contentsOf(f), lang, f))
+            .ifPresent(translationFiles::add);
       }
     }
     return translationFiles;
diff --git a/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/operation/OrganizeImportOperation.java b/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/operation/OrganizeImportOperation.java
index 6d0d0a3..c8e59fb 100644
--- a/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/operation/OrganizeImportOperation.java
+++ b/org.eclipse.scout.sdk.s2e/src/main/java/org/eclipse/scout/sdk/s2e/operation/OrganizeImportOperation.java
@@ -13,6 +13,7 @@
 import java.util.function.Consumer;
 
 import org.eclipse.core.runtime.CoreException;
+import org.eclipse.core.runtime.ICoreRunnable;
 import org.eclipse.jdt.core.ICompilationUnit;
 import org.eclipse.jdt.core.manipulation.JavaManipulation;
 import org.eclipse.jdt.core.manipulation.OrganizeImportsOperation;
@@ -48,7 +49,7 @@
     ICompilationUnit unit = getCompilationUnit();
     try {
       CodeGenerationSettings settings = JavaPreferencesSettings.getCodeGenerationSettings(unit.getJavaProject());
-      OrganizeImportsOperation organizeImps = new OrganizeImportsOperation(unit, null, settings.importIgnoreLowercase, !unit.isWorkingCopy(), true, null);
+      ICoreRunnable organizeImps = new OrganizeImportsOperation(unit, null, settings.importIgnoreLowercase, !unit.isWorkingCopy(), true, null);
       organizeImps.run(t.monitor());
     }
     catch (CoreException e) {
diff --git a/org.eclipse.scout.sdk.s2i/build.gradle.kts b/org.eclipse.scout.sdk.s2i/build.gradle.kts
index 6df25e2..0127cc9 100644
--- a/org.eclipse.scout.sdk.s2i/build.gradle.kts
+++ b/org.eclipse.scout.sdk.s2i/build.gradle.kts
@@ -44,6 +44,7 @@
 dependencies {
     api("org.eclipse.scout.sdk", "org.eclipse.scout.sdk.core.s", SCOUT_SDK_VERSION)
     api("org.eclipse.scout.sdk", "org.eclipse.scout.sdk.core.ecj", SCOUT_SDK_VERSION)
+    api("org.apache.commons", "commons-csv", "1.8")
     implementation("org.jetbrains.kotlin", "kotlin-stdlib-jdk8", KOTLIN_VERSION)
     testImplementation("org.mockito", "mockito-core", "3.3.3")
 }
@@ -54,7 +55,7 @@
     version = "IU-2019.2.3"
     downloadSources = true
 
-    setPlugins("java", "maven", "copyright", "properties")
+    setPlugins("java", "maven", "copyright", "properties", "CSS", "JavaScriptLanguage")
     updateSinceUntilBuild = false
 
     tasks {
@@ -64,12 +65,12 @@
     }
 }
 
-tasks.withType<JavaCompile> {
+tasks.withType<JavaCompile>().configureEach {
     sourceCompatibility = "1.8"
     targetCompatibility = "1.8"
 }
 
-tasks.withType<KotlinCompile> {
+tasks.withType<KotlinCompile>().configureEach {
     sourceCompatibility = "1.8"
     targetCompatibility = "1.8"
     kotlinOptions {
diff --git a/org.eclipse.scout.sdk.s2i/org.eclipse.scout.sdk.s2i.iml b/org.eclipse.scout.sdk.s2i/org.eclipse.scout.sdk.s2i.iml
index 69a607b..f41b9de 100644
--- a/org.eclipse.scout.sdk.s2i/org.eclipse.scout.sdk.s2i.iml
+++ b/org.eclipse.scout.sdk.s2i/org.eclipse.scout.sdk.s2i.iml
@@ -51,6 +51,7 @@
     <orderEntry type="module" module-name="org.eclipse.scout.sdk.core" />
     <orderEntry type="module" module-name="org.eclipse.scout.sdk.core.s" />
     <orderEntry type="module" module-name="org.eclipse.scout.sdk.core.ecj" />
+    <orderEntry type="library" name="Gradle: org.apache.commons:commons-csv:1.8" level="project" />
     <orderEntry type="library" scope="PROVIDED" name="Gradle: com.jetbrains:aapt-proto-jarjar:2019.2.3" level="project" />
     <orderEntry type="library" scope="PROVIDED" name="Gradle: com.jetbrains:annotations:2019.2.3" level="project" />
     <orderEntry type="library" scope="PROVIDED" name="Gradle: com.jetbrains:asm-5.0.3:2019.2.3" level="project" />
@@ -354,6 +355,34 @@
         </SOURCES>
       </library>
     </orderEntry>
+    <orderEntry type="library" scope="PROVIDED" name="Gradle: unzipped.com.jetbrains.plugins:css-openapi:ideaIU-IU-192.6817.14-withSources" level="project" />
+    <orderEntry type="library" scope="PROVIDED" name="Gradle: unzipped.com.jetbrains.plugins:css:ideaIU-IU-192.6817.14-withSources" level="project" />
+    <orderEntry type="module-library" scope="PROVIDED">
+      <library name="Gradle: unzipped.com.jetbrains.plugins:resources_en:ideaIU-IU-192.6817.14-withSources">
+        <CLASSES>
+          <root url="jar://$USER_HOME$/.gradle/caches/modules-2/files-2.1/com.jetbrains.intellij.idea/ideaIU/2019.2.3/5442cc61648265370e631b2f5f7f329410abe347/ideaIU-2019.2.3/plugins/CSS/lib/resources_en.jar!/" />
+        </CLASSES>
+        <JAVADOC />
+        <SOURCES>
+          <root url="jar://$USER_HOME$/.gradle/caches/modules-2/files-2.1/com.jetbrains.intellij.idea/ideaIC/2019.2.3/f09035cd0dc18d7f89bf011c76b9e3d3407d2cd1/ideaIC-2019.2.3-sources.jar!/" />
+        </SOURCES>
+      </library>
+    </orderEntry>
+    <orderEntry type="library" scope="PROVIDED" name="Gradle: unzipped.com.jetbrains.plugins:javascript-openapi:ideaIU-IU-192.6817.14-withSources" level="project" />
+    <orderEntry type="library" scope="PROVIDED" name="Gradle: unzipped.com.jetbrains.plugins:JavaScriptLanguage:ideaIU-IU-192.6817.14-withSources" level="project" />
+    <orderEntry type="library" scope="PROVIDED" name="Gradle: unzipped.com.jetbrains.plugins:js-test-common:ideaIU-IU-192.6817.14-withSources" level="project" />
+    <orderEntry type="module-library" scope="PROVIDED">
+      <library name="Gradle: unzipped.com.jetbrains.plugins:resources_en:ideaIU-IU-192.6817.14-withSources">
+        <CLASSES>
+          <root url="jar://$USER_HOME$/.gradle/caches/modules-2/files-2.1/com.jetbrains.intellij.idea/ideaIU/2019.2.3/5442cc61648265370e631b2f5f7f329410abe347/ideaIU-2019.2.3/plugins/JavaScriptLanguage/lib/resources_en.jar!/" />
+        </CLASSES>
+        <JAVADOC />
+        <SOURCES>
+          <root url="jar://$USER_HOME$/.gradle/caches/modules-2/files-2.1/com.jetbrains.intellij.idea/ideaIC/2019.2.3/f09035cd0dc18d7f89bf011c76b9e3d3407d2cd1/ideaIC-2019.2.3-sources.jar!/" />
+        </SOURCES>
+      </library>
+    </orderEntry>
+    <orderEntry type="library" scope="PROVIDED" name="Gradle: unzipped.com.jetbrains.plugins:semver4j-2.2.0:ideaIU-IU-192.6817.14-withSources" level="project" />
     <orderEntry type="library" name="Gradle: org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.3.61" level="project" />
     <orderEntry type="library" name="Gradle: wsdl4j:wsdl4j:1.6.2" level="project" />
     <orderEntry type="library" name="Gradle: org.eclipse.jdt:ecj:3.21.0" level="project" />
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/Extensions.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/Extensions.kt
index 600eb70..19a1cf7 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/Extensions.kt
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/Extensions.kt
@@ -50,6 +50,7 @@
 import org.eclipse.scout.sdk.s2i.environment.model.JavaEnvironmentWithIdea
 import java.lang.reflect.InvocationTargetException
 import java.nio.file.Path
+import java.nio.file.Paths
 import java.util.function.Function
 import java.util.stream.Stream
 
@@ -229,6 +230,8 @@
 
 fun VirtualFile.containingModule(project: Project) = ProjectFileIndex.getInstance(project).getModuleForFile(this)
 
+fun Module.moduleDirPath(): Path = Paths.get(ModuleUtil.getModuleDirPath(this))
+
 fun PsiClass.visitSupers(visitor: IBreadthFirstVisitor<PsiClass>): TreeVisitResult {
     val supplier: Function<PsiClass, Stream<out PsiClass>> = Function { a -> a.supers.stream() }
     return TreeTraversals.create(visitor, supplier).traverse(this)
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/IdeaHomePathMacro.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/IdeaHomePathMacro.kt
index b6bce28..91d17c9 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/IdeaHomePathMacro.kt
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/IdeaHomePathMacro.kt
@@ -13,12 +13,13 @@
 import com.intellij.ide.macro.Macro
 import com.intellij.openapi.actionSystem.DataContext
 import com.intellij.openapi.application.PathManager
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
 
 open class IdeaHomePathMacro : Macro() {
 
     override fun getName(): String = "IdeaHomePath"
 
-    override fun getDescription(): String = "Installation Path of the running IntelliJ IDEA instance."
+    override fun getDescription(): String = message("idea.home.path.macro.desc")
 
     override fun expand(dataContext: DataContext): String? = PathManager.getHomePath()
 }
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/classid/AddMissingClassIdQuickFix.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/classid/AddMissingClassIdQuickFix.kt
index 3808382..e5a11d8 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/classid/AddMissingClassIdQuickFix.kt
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/classid/AddMissingClassIdQuickFix.kt
@@ -32,7 +32,7 @@
 
     override fun getFamilyName(): String = quickFixName
 
-    override fun applyFix(project: Project, descriptor: ProblemDescriptor) /*= runInNewTransaction(project, quickFixName)*/ {
+    override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
         val psiClass = PsiTreeUtil.getParentOfType(descriptor.psiElement, PsiClass::class.java)
                 ?: throw Ensure.newFail("No class found to add @ClassId. Element: '{}'.", descriptor.psiElement)
         if (psiClass.hasAnnotation(IScoutRuntimeTypes.ClassId)) {
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/derived/impl/DerivedResourceManagerImplementor.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/derived/impl/DerivedResourceManagerImplementor.kt
index 359cc98..0a14e7d 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/derived/impl/DerivedResourceManagerImplementor.kt
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/derived/impl/DerivedResourceManagerImplementor.kt
@@ -31,6 +31,7 @@
 import org.eclipse.scout.sdk.core.s.environment.IProgress
 import org.eclipse.scout.sdk.core.s.environment.SdkFuture
 import org.eclipse.scout.sdk.core.util.JavaTypes
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
 import org.eclipse.scout.sdk.s2i.derived.DerivedResourceHandlerFactory
 import org.eclipse.scout.sdk.s2i.derived.DerivedResourceManager
 import org.eclipse.scout.sdk.s2i.environment.IdeaEnvironment
@@ -155,7 +156,7 @@
         return union
     }
 
-    private fun performUpdateAsync(scope: SearchScope): IFuture<Unit> = callInIdeaEnvironment(project, "Update derived resources") { env, progress ->
+    private fun performUpdateAsync(scope: SearchScope): IFuture<Unit> = callInIdeaEnvironment(project, message("update.derived.resources")) { env, progress ->
         SdkLog.debug("Check for derived resource updates in scope $scope")
         val start = System.currentTimeMillis()
         val factories = synchronized(m_updateHandlerFactories) { ArrayList(m_updateHandlerFactories.values) }
@@ -187,7 +188,7 @@
         val transaction = TransactionManager.current()
         val runningFileWrites = ConcurrentLinkedQueue<IFuture<*>>()
         val indicator = progress.indicator
-        progress.init(handlers.size * workForHandler, "Update derived resources")
+        progress.init(handlers.size * workForHandler, message("update.derived.resources"))
 
         handlers.forEach {
             try {
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/CompilationUnitWriteOperation.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/CompilationUnitWriteOperation.kt
index a64c7fd..01b3fc5 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/CompilationUnitWriteOperation.kt
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/CompilationUnitWriteOperation.kt
@@ -25,6 +25,7 @@
 import org.eclipse.scout.sdk.core.s.environment.IFuture
 import org.eclipse.scout.sdk.core.s.environment.SdkFuture
 import org.eclipse.scout.sdk.core.util.SdkException
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
 import org.eclipse.scout.sdk.s2i.environment.IdeaEnvironment.Factory.toIdeaProgress
 import java.io.File
 import java.nio.file.Path
@@ -39,12 +40,12 @@
     }
 
     fun schedule(resultSupplier: () -> IType?): IFuture<IType?> {
-        val task = OperationTask("Write " + cuPath.fileName(), project, TransactionManager.current(), this::doWriteCompilationUnit)
+        val task = OperationTask(message("write.cu.x", cuPath.fileName()), project, TransactionManager.current(), this::doWriteCompilationUnit)
         return task.schedule(resultSupplier, hidden = true)
     }
 
     protected fun doWriteCompilationUnit(progress: IdeaProgress) {
-        progress.init(3, "Write {}", cuPath.fileName())
+        progress.init(3, message("write.cu.x", cuPath.fileName()))
 
         // create in memory file
         val newPsi = PsiFileFactory.getInstance(project)
@@ -85,7 +86,7 @@
             override fun file() = targetFile
 
             override fun commit(progress: IdeaProgress): Boolean {
-                progress.init(2, "Write compilation unit {}", psi.name)
+                progress.init(2, message("write.cu.x", psi.name))
 
                 val targetDirectory = targetFile.parent
                 val dir = DirectoryUtil.mkdirs(PsiManager.getInstance(psi.project), targetDirectory.toString().replace(File.separatorChar, '/'))
@@ -103,7 +104,7 @@
                 return true
             }
 
-            override fun toString() = "Write compilation unit $targetFile"
+            override fun toString() = message("write.cu.x", targetFile)
         }
     }
 }
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/FileWriter.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/FileWriter.kt
index 4b95f30..175d157 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/FileWriter.kt
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/FileWriter.kt
@@ -14,6 +14,7 @@
 import com.intellij.openapi.project.Project
 import com.intellij.openapi.vfs.VfsUtil
 import org.eclipse.scout.sdk.core.log.SdkLog
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
 import org.eclipse.scout.sdk.s2i.toVirtualFile
 import java.nio.file.Path
 
@@ -22,7 +23,7 @@
     override fun file(): Path = targetFile
 
     override fun commit(progress: IdeaProgress): Boolean {
-        progress.init(4, "Write file {}", targetFile)
+        progress.init(4, toString())
 
         var existingFile = targetFile.toVirtualFile()
         progress.worked(1)
@@ -40,7 +41,6 @@
         }
         progress.setWorkRemaining(1)
 
-
         val documentManager = FileDocumentManager.getInstance()
         val document = documentManager.getDocument(existingFile)
         if (document == null) {
@@ -52,5 +52,5 @@
         return true
     }
 
-    override fun toString() = "Write file $targetFile"
+    override fun toString() = message("write.file.x", targetFile)
 }
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/IdeaEnvironment.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/IdeaEnvironment.kt
index dc5c58e..13a0125 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/IdeaEnvironment.kt
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/IdeaEnvironment.kt
@@ -10,7 +10,12 @@
  */
 package org.eclipse.scout.sdk.s2i.environment
 
+import com.intellij.openapi.application.ApplicationManager
 import com.intellij.openapi.module.Module
+import com.intellij.openapi.progress.EmptyProgressIndicator
+import com.intellij.openapi.progress.ProgressIndicator
+import com.intellij.openapi.progress.util.ProgressIndicatorUtils
+import com.intellij.openapi.progress.util.ProgressIndicatorUtils.runWithWriteActionPriority
 import com.intellij.openapi.project.Project
 import com.intellij.openapi.roots.ProjectRootManager
 import com.intellij.openapi.vfs.VirtualFile
@@ -34,6 +39,7 @@
 import org.eclipse.scout.sdk.core.util.CoreUtils.toStringIfOverwritten
 import org.eclipse.scout.sdk.core.util.Ensure.newFail
 import org.eclipse.scout.sdk.s2i.*
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
 import org.eclipse.scout.sdk.s2i.environment.TransactionManager.Companion.repeatUntilPassesWithIndex
 import org.eclipse.scout.sdk.s2i.environment.model.JavaEnvironmentWithIdea
 import org.jetbrains.jps.model.serialization.PathMacroUtil
@@ -60,7 +66,7 @@
 
         fun <T> callInIdeaEnvironment(project: Project, title: String, task: (IdeaEnvironment, IdeaProgress) -> T): IFuture<T> {
             val result = FinalValue<T>()
-            val name = Strings.notBlank(title).orElseGet { toStringIfOverwritten(task).orElse("Unnamed Task: $task") }
+            val name = Strings.notBlank(title).orElseGet { toStringIfOverwritten(task).orElse(message("unnamed.task.x", task)) }
             val job = OperationTask(name, project) { pr ->
                 callInIdeaEnvironmentSync(project, pr) { e, p ->
                     result.setIfAbsent(task.invoke(e, p))
@@ -71,6 +77,24 @@
 
         fun <T> computeInReadAction(project: Project, callable: () -> T): T = repeatUntilPassesWithIndex(project, true, callable)
 
+        fun <T> computeInLongReadAction(project: Project, progressIndicator: ProgressIndicator, callable: () -> T): T {
+            if (ApplicationManager.getApplication().isReadAccessAllowed) {
+                return callable.invoke()
+            }
+
+            val result = FinalValue<T>()
+            var success = false
+            while (!success && !progressIndicator.isCanceled) {
+                /* do not pass outer progress indicator. otherwise it might be canceled inside but then it is not possible to perform a retry (it is canceled already) */
+                /* therefore use a fresh indicator for each retry and use the outer indicator to stop retrying */
+                success = runWithWriteActionPriority({ result.set(computeInReadAction(project, callable)) }, EmptyProgressIndicator())
+                if (!success) {
+                    ProgressIndicatorUtils.yieldToPendingWriteActions()
+                }
+            }
+            return result.get()
+        }
+
         fun toIdeaProgress(progress: IProgress?): IdeaProgress = progress?.toIdea() ?: IdeaProgress(null)
     }
 
@@ -85,11 +109,18 @@
             .mapNotNull { it.toScoutType(this) }
             .stream()
 
-    override fun findJavaEnvironment(root: Path?): Optional<IJavaEnvironment> =
-            Optional.ofNullable(
-                    root?.toVirtualFile()
-                            ?.containingModule(project)
-                            ?.let { toScoutJavaEnvironment(it) })
+    override fun findJavaEnvironment(root: Path?): Optional<IJavaEnvironment> {
+        var path = root
+        while (path != null) {
+            val env = path.toVirtualFile()?.containingModule(project)?.let { toScoutJavaEnvironment(it) }
+            if (env != null) {
+                return Optional.of(env)
+            }
+            path = path.parent
+        }
+        return Optional.empty()
+    }
+
 
     override fun rootOfJavaEnvironment(environment: IJavaEnvironment?): Path {
         val spi = environment?.unwrap() ?: throw newFail("Java environment must not be null")
@@ -98,7 +129,6 @@
         return Paths.get(moduleDirPath)
     }
 
-
     fun toScoutJavaEnvironment(module: Module?): IJavaEnvironment? =
             module
                     ?.takeIf { it.isJavaModule() }
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/TransactionManager.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/TransactionManager.kt
index 11b8881..2fdc24b 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/TransactionManager.kt
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/environment/TransactionManager.kt
@@ -20,15 +20,19 @@
 import com.intellij.openapi.project.IndexNotReadyException
 import com.intellij.openapi.project.Project
 import com.intellij.openapi.vfs.ReadonlyStatusHandler
+import com.intellij.openapi.vfs.VirtualFile
 import com.intellij.psi.PsiDocumentManager
+import com.intellij.util.FileContentUtilCore
 import org.eclipse.scout.sdk.core.log.SdkLog
 import org.eclipse.scout.sdk.core.log.SdkLog.onTrace
 import org.eclipse.scout.sdk.core.util.CoreUtils.callInContext
 import org.eclipse.scout.sdk.core.util.Ensure
 import org.eclipse.scout.sdk.core.util.FinalValue
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
 import org.eclipse.scout.sdk.s2i.toVirtualFile
 import java.lang.reflect.Method
 import java.nio.file.Path
+import java.util.Collections.singletonList
 
 class TransactionManager constructor(val project: Project, val transactionName: String? = null) {
 
@@ -237,80 +241,110 @@
 
     private fun commitAllInUiThread(progress: IdeaProgress): Boolean {
         val workForEnsureWritable = 1
-        progress.init(size() + workForEnsureWritable, "Starting to commit transaction. Number of members: {}", size())
+        progress.init(size() + workForEnsureWritable, message("starting.commit.transaction.x", size()))
+
+        // map member path to virtual files
+        val files = m_members.keys.associateWith { it.toVirtualFile() }.toMap()
 
         // make file writable
-        val files = m_members.keys.mapNotNull(Path::toVirtualFile)
-        val status = ReadonlyStatusHandler.getInstance(project).ensureFilesWritable(files)
+        val status = ReadonlyStatusHandler.getInstance(project).ensureFilesWritable(files.values.filterNotNull())
         if (status.hasReadonlyFiles()) {
             SdkLog.info("Unable to make all resources writable. Transaction will be discarded. Message: ${status.readonlyFilesMessage}")
             return false
         }
         progress.worked(workForEnsureWritable)
 
-        // validate documents
+        // map to documents
         val documentManager = FileDocumentManager.getInstance()
+        val documents = files
+                .map { pair -> pair.key to pair.value?.let { Pair(it, documentManager.getDocument(it)) } }
+                .toMap(HashMap())
+
+        // validate documents and prepare for modification
         val psiDocumentManager = PsiDocumentManager.getInstance(project)
-        val documents = files.map { file -> documentManager.getDocument(file) }
-        val documentsReady = documents.all { documentReady(it, documentManager, psiDocumentManager) }
+        val documentsReady = documents.values.filterNotNull().all { documentReady(it.second, psiDocumentManager) }
         if (!documentsReady) {
             SdkLog.warning("Cannot commit all transaction members because at least one document cannot be written.")
             return false
         }
 
-        // commit
-        return commitDocuments(documents.filterNotNull(), documentManager, progress)
+        // commit transaction members
+        return commitTransaction(documents, documentManager, psiDocumentManager, progress)
     }
 
     @Suppress("MissingRecentApi")
-    private fun commitDocuments(documents: List<Document>, documentManager: FileDocumentManager, progress: IdeaProgress): Boolean {
+    private fun commitTransaction(documentMappings: MutableMap<Path, Pair<VirtualFile, Document?>?>, documentManager: FileDocumentManager, psiDocumentManager: PsiDocumentManager, progress: IdeaProgress): Boolean {
         val setInBulkUpdate = setInBulkUpdateMethod() // Can be removed if the supported min. IJ version is 2019.3
-        val useBulkMode = setInBulkUpdate != null && documents.size >= 100
+        val useBulkMode = setInBulkUpdate != null && documentMappings.size >= 100
+        val documents = documentMappings.values.filterNotNull().mapNotNull { it.second }
         try {
             if (useBulkMode) {
-                documents.forEach { doc -> setInBulkUpdate!!.invoke(doc, true) }
+                documents.forEach { setInBulkUpdate!!.invoke(it, true) }
             }
-            val success = commitMembers(documents, progress)
+            val success = commitAllMembers(documentMappings, documentManager, progress)
             if (success) {
-                documents.forEach { documentManager.saveDocument(it) }
+                // here the documents of the new files have been added. therefore don't use documentMappings
+                documentMappings.values.filterNotNull().mapNotNull { it.second }.forEach { commitDocument(it, documentManager, psiDocumentManager) }
             }
             return success
         } finally {
             if (useBulkMode) {
-                documents.forEach { doc -> setInBulkUpdate!!.invoke(doc, false) }
+                documents.forEach { setInBulkUpdate!!.invoke(it, false) }
             }
         }
     }
 
-    private fun commitMembers(documents: List<Document>, progress: IdeaProgress): Boolean {
-        val psiDocumentManager = PsiDocumentManager.getInstance(project)
-        try {
-            return m_members.values
-                    .flatten()
-                    .map { commitMember(it, progress.newChild(1)) }
-                    .all { committed -> committed }
-        } finally {
-            documents.forEach {
-                psiDocumentManager.doPostponedOperationsAndUnblockDocument(it)
-                psiDocumentManager.commitDocument(it)
-            }
+    private fun commitDocument(document: Document, documentManager: FileDocumentManager, psiDocumentManager: PsiDocumentManager) {
+        if (psiDocumentManager.isDocumentBlockedByPsi(document)) {
+            psiDocumentManager.doPostponedOperationsAndUnblockDocument(document)
         }
+        if (psiDocumentManager.isUncommited(document)) {
+            psiDocumentManager.commitDocument(document)
+        }
+        documentManager.saveDocument(document)
     }
 
-    private fun documentReady(document: Document?, documentManager: FileDocumentManager, psiDocumentManager: PsiDocumentManager): Boolean {
+    private fun commitAllMembers(documentMappings: MutableMap<Path, Pair<VirtualFile, Document?>?>, documentManager: FileDocumentManager, progress: IdeaProgress): Boolean {
+        return m_members.entries
+                .map { commitMembers(it.key, it.value, documentMappings, documentManager, progress) }
+                .min() ?: false
+    }
+
+    private fun commitMembers(path: Path, members: List<TransactionMember>, fileMappings: MutableMap<Path, Pair<VirtualFile, Document?>?>, documentManager: FileDocumentManager, progress: IdeaProgress): Boolean {
+        val success = members
+                .map { member -> commitMember(member, progress.newChild(1)) }
+                .min() ?: false
+        if (success) {
+            val mapping = fileMappings[path]
+            var vFile = mapping?.first
+            if (vFile == null) {
+                vFile = path.toVirtualFile()
+            }
+            // IDEA might change the file settings on modifications.
+            // This is e.g. done for .properties files where the encoding might be changed in the project settings.
+            // After this the indices are no longer valid and throw exceptions. To solve this: re-parse the file in case something changed.
+            // this must be executed right after the transaction members of that file and before the psi or document is saved!
+            FileContentUtilCore.reparseFiles(singletonList(vFile))
+            var doc = mapping?.second
+            if (doc == null && vFile != null) {
+                doc = documentManager.getDocument(vFile)
+            }
+            if (vFile != null) {
+                fileMappings[path] = Pair(vFile, doc)
+            }
+        }
+        return success
+    }
+
+    private fun documentReady(document: Document?, psiDocumentManager: PsiDocumentManager): Boolean {
         if (document == null) {
-            return false
+            return true// no document exists yet: new file
         }
         if (!document.isWritable) {
             return false
         }
-        if (psiDocumentManager.isUncommited(document)) {
-            // commit before overwriting to ensure the psi can be modified (it is not allowed to modify a psi of an uncommited document).
-            psiDocumentManager.commitDocument(document)
-        }
-        if (documentManager.isDocumentUnsaved(document)) {
-            // save before overwriting to ensure there are no conflicts afterwards
-            documentManager.saveDocument(document)
+        if (psiDocumentManager.isDocumentBlockedByPsi(document)) {
+            psiDocumentManager.doPostponedOperationsAndUnblockDocument(document)
         }
         return true
     }
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/IdeaTranslationStoreSupplier.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/IdeaTranslationStoreSupplier.kt
index bc7f416..b4c9731 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/IdeaTranslationStoreSupplier.kt
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/IdeaTranslationStoreSupplier.kt
@@ -30,9 +30,9 @@
 import org.eclipse.scout.sdk.core.s.nls.ITranslationStoreSupplier
 import org.eclipse.scout.sdk.core.s.nls.TranslationStores
 import org.eclipse.scout.sdk.core.s.nls.properties.*
-import org.eclipse.scout.sdk.core.s.nls.properties.AbstractTranslationPropertiesFile.parseFromFileNameOrThrow
-import org.eclipse.scout.sdk.core.s.nls.properties.PropertiesTextProviderService.resourceMatchesPrefix
+import org.eclipse.scout.sdk.core.s.nls.properties.AbstractTranslationPropertiesFile.parseLanguageFromFileName
 import org.eclipse.scout.sdk.s2i.*
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
 import org.eclipse.scout.sdk.s2i.environment.IdeaEnvironment
 import org.eclipse.scout.sdk.s2i.environment.IdeaProgress
 import java.nio.file.Path
@@ -51,7 +51,7 @@
     override fun single(textService: IType, progress: IProgress): Optional<ITranslationStore> {
         val psi = textService.resolvePsi() ?: return Optional.empty()
         val module = psi.containingModule() ?: return Optional.empty()
-        progress.init(1, "Load text provider service")
+        progress.init(1, message("load.text.service"))
         return createTranslationStore(textService, psi, module, progress.newChild(1))
     }
 
@@ -63,7 +63,7 @@
     }
 
     protected fun findTranslationStoresVisibleIn(module: Module, env: IdeaEnvironment, progress: IdeaProgress): Stream<ITranslationStore> {
-        progress.init(20, "Search properties text provider services.")
+        progress.init(20, message("search.text.services"))
 
         val moduleScope = module.getModuleWithDependenciesAndLibrariesScope(false)
         val javaEnv: IJavaEnvironment = env.toScoutJavaEnvironment(module) ?: return Stream.empty()
@@ -80,7 +80,7 @@
                 .filter { it.scoutType != null }
                 .toList()
 
-        val progressForLoad = progress.worked(10).newChild(10).init(types.size, "Load properties file contents")
+        val progressForLoad = progress.worked(10).newChild(10).init(types.size, message("load.properties.content"))
         val result = types.mapNotNull { createTranslationStore(it.scoutType!!, it.psiClass, module, progressForLoad).orElse(null) }
 
         SdkLog.debug("Found translation stores on Java classpath of module '{}': {}", module.name, result)
@@ -96,17 +96,16 @@
 
     protected fun loadTranslationFiles(module: Module, psiClass: PsiClass, store: PropertiesTranslationStore, progress: IProgress): Boolean {
         val rootType = if (psiClass.isWritable) OrderRootType.SOURCES else OrderRootType.CLASSES
-        val root = findRootDirectories(module, psiClass, rootType) ?: return false
+        val roots = findRootDirectories(module, psiClass, rootType) ?: return false
 
         val prefix = store.service().filePrefix()
         val folder = store.service().folder()
-        val translationFiles = root
+        val translationFiles = roots
                 .mapNotNull { it.findFileByRelativePath(folder) }
                 .filter { it.isDirectory }
                 .flatMap { it.children.asIterable() }
                 .filter { !it.isDirectory }
-                .filter { resourceMatchesPrefix(it.name, prefix) }
-                .map { toTranslationPropertiesFile(it, psiClass.isWritable) }
+                .mapNotNull { toTranslationPropertiesFile(it, prefix, psiClass.isWritable) }
         store.load(translationFiles, progress)
         return true
     }
@@ -125,11 +124,12 @@
                 ?.asList()
     }
 
-    protected fun toTranslationPropertiesFile(file: VirtualFile, isEditable: Boolean): ITranslationPropertiesFile {
+    protected fun toTranslationPropertiesFile(file: VirtualFile, prefix: String, isEditable: Boolean): ITranslationPropertiesFile? {
+        val language = parseLanguageFromFileName(file.name, prefix).orElse(null) ?: return null
         if (isEditable) {
-            return EditableTranslationFile(file.toNioPath())
+            return EditableTranslationFile(file.toNioPath(), language)
         }
-        return ReadOnlyTranslationFile(Supplier { file.inputStream }, parseFromFileNameOrThrow(file.name))
+        return ReadOnlyTranslationFile(Supplier { file.inputStream }, language, file)
     }
 
     private data class TypeMapping(val scoutType: IType?, val psiClass: PsiClass)
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/NlsFileType.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/NlsFileType.kt
new file mode 100644
index 0000000..4c10701
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/NlsFileType.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls
+
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.fileTypes.FileType
+import com.intellij.openapi.vfs.VirtualFile
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
+import java.nio.charset.StandardCharsets
+import javax.swing.Icon
+
+class NlsFileType private constructor() : FileType {
+
+    companion object {
+        @JvmField
+        val INSTANCE: NlsFileType = NlsFileType()
+    }
+
+    override fun getDefaultExtension() = "nls"
+
+    override fun getIcon(): Icon = AllIcons.Nodes.ResourceBundle
+
+    override fun getCharset(file: VirtualFile, content: ByteArray): String = StandardCharsets.UTF_8.name()
+
+    override fun getName() = message("nls.file.desc")
+
+    override fun getDescription() = message("nls.file.desc")
+
+    override fun isBinary() = false
+
+    override fun isReadOnly() = false
+
+}
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/PsiNlsPatterns.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/PsiNlsPatterns.kt
new file mode 100644
index 0000000..c6bf36d
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/PsiNlsPatterns.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls
+
+import com.intellij.openapi.util.text.StringUtil.startsWith
+import com.intellij.patterns.*
+import com.intellij.patterns.PsiJavaPatterns.literalExpression
+import com.intellij.patterns.PsiJavaPatterns.psiMethod
+import com.intellij.patterns.StandardPatterns.or
+import com.intellij.patterns.StandardPatterns.string
+import com.intellij.patterns.XmlPatterns.xmlAttribute
+import com.intellij.patterns.XmlPatterns.xmlTag
+import com.intellij.psi.PsiElement
+import com.intellij.psi.xml.XmlAttributeValue
+import com.intellij.util.ProcessingContext
+import org.eclipse.scout.sdk.core.s.IScoutRuntimeTypes
+import org.eclipse.scout.sdk.core.util.Strings.fromStringLiteral
+import org.eclipse.scout.sdk.core.util.Strings.withoutQuotes
+import java.util.*
+
+class PsiNlsPatterns {
+    /**
+     * The JS patterns are in [org.eclipse.scout.sdk.s2i.nls.completion.NlsCompletionContributorForJs] because the dependency to the JS module is optional!
+     */
+    companion object {
+
+        fun startsWithIgnoringQuotes(s: CharSequence, unescape: Boolean): StringPattern {
+            return string().with(object : PatternCondition<String>("startsWithIgnoringQuotes=$s") {
+                override fun accepts(str: String, context: ProcessingContext): Boolean {
+                    val text = if (unescape) fromStringLiteral(str) else withoutQuotes(str)
+                    return startsWith(text, s)
+                }
+            })
+        }
+
+        fun keyPattern(): PsiElementPattern.Capture<PsiElement> = PlatformPatterns.psiElement()
+                .withParent(XmlAttributeValue::class.java)
+                .withSuperParent(2, xmlAttribute("key"))
+                .withSuperParent(3, xmlTag().withName("scout:message"))
+
+        fun textsGetPattern(): PsiJavaElementPattern.Capture<out PsiElement> {
+            val stringFqn = String::class.java.name
+            val localeFqn = Locale::class.java.name
+            val wildcardArgument = ".."
+            val getWithoutLocale = psiMethod()
+                    .withName("get")
+                    .definedInClass(IScoutRuntimeTypes.TEXTS)
+                    .withParameters(stringFqn, wildcardArgument)
+            val getWithLocale = psiMethod()
+                    .withName("get")
+                    .definedInClass(IScoutRuntimeTypes.TEXTS)
+                    .withParameters(localeFqn, stringFqn, wildcardArgument)
+            val getWithFallbackWithoutLocale = psiMethod()
+                    .withName("getWithFallback")
+                    .definedInClass(IScoutRuntimeTypes.TEXTS)
+                    .withParameters(stringFqn, stringFqn, wildcardArgument)
+            val getWithFallbackWithLocale = psiMethod()
+                    .withName("getWithFallback")
+                    .definedInClass(IScoutRuntimeTypes.TEXTS)
+                    .withParameters(localeFqn, stringFqn, stringFqn, wildcardArgument)
+            val oneOfTextsGetOverloads = or(
+                    literalExpression().methodCallParameter(0, getWithoutLocale),
+                    literalExpression().methodCallParameter(1, getWithLocale),
+                    literalExpression().methodCallParameter(0, getWithFallbackWithoutLocale),
+                    literalExpression().methodCallParameter(1, getWithFallbackWithoutLocale),
+                    literalExpression().methodCallParameter(1, getWithFallbackWithLocale),
+                    literalExpression().methodCallParameter(2, getWithFallbackWithLocale)
+            )
+            return PsiJavaPatterns.psiElement().withParent(oneOfTextsGetOverloads)
+        }
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/TranslationStoreStackLoader.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/TranslationStoreStackLoader.kt
new file mode 100644
index 0000000..08c100b
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/TranslationStoreStackLoader.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls
+
+import com.intellij.openapi.module.Module
+import com.intellij.openapi.progress.*
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.VirtualFile
+import org.eclipse.scout.sdk.core.log.SdkLog
+import org.eclipse.scout.sdk.core.s.nls.ITranslationStore
+import org.eclipse.scout.sdk.core.s.nls.NlsFile
+import org.eclipse.scout.sdk.core.s.nls.TranslationStoreStack
+import org.eclipse.scout.sdk.core.s.nls.TranslationStores.createStack
+import org.eclipse.scout.sdk.core.util.Ensure.newFail
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
+import org.eclipse.scout.sdk.s2i.containingModule
+import org.eclipse.scout.sdk.s2i.environment.IdeaEnvironment.Factory.callInIdeaEnvironmentSync
+import org.eclipse.scout.sdk.s2i.environment.IdeaEnvironment.Factory.computeInLongReadAction
+import org.eclipse.scout.sdk.s2i.moduleDirPath
+import org.eclipse.scout.sdk.s2i.toNioPath
+import org.eclipse.scout.sdk.s2i.toScoutProgress
+
+class TranslationStoreStackLoader private constructor() {
+
+    data class TranslationStoreStackLoaderResult(val stack: TranslationStoreStack, val primaryStore: ITranslationStore?, val nlsFile: VirtualFile?, val module: Module)
+
+    class ModalLoader(val module: Module, val nlsFile: VirtualFile?, title: String?) : Task.Modal(module.project, title ?: message("loading.translations"), true) {
+
+        private var m_errorHandler: ((Throwable) -> Unit)? = null
+        private var m_stackCreatedHandler: ((TranslationStoreStackLoaderResult?) -> Unit)? = null
+
+        fun withErrorHandler(handler: ((Throwable) -> Unit)?): ModalLoader {
+            m_errorHandler = handler
+            return this
+        }
+
+        fun withStackCreatedHandler(handler: ((TranslationStoreStackLoaderResult?) -> Unit)?): ModalLoader {
+            m_stackCreatedHandler = handler
+            return this
+        }
+
+        override fun run(indicator: ProgressIndicator) {
+            m_stackCreatedHandler?.invoke(createStack(module, nlsFile, indicator))
+        }
+
+        override fun onThrowable(error: Throwable) {
+            val handler = m_errorHandler ?: { SdkLog.error("Error computing texts for module '{}'.", module.name, it) }
+            handler.invoke(error)
+        }
+    }
+
+    companion object {
+
+        fun createModalLoader(module: Module, nlsFile: VirtualFile? = null, title: String? = null) = ModalLoader(module, nlsFile, title)
+
+        fun createModalLoader(nlsFile: VirtualFile, project: Project, title: String? = null): ModalLoader {
+            val module = nlsFile.containingModule(project) ?: throw newFail("Module of file '{}' not found.", nlsFile)
+            return createModalLoader(module, nlsFile, title)
+        }
+
+        fun createStack(module: Module) = createStack(module, null)?.stack
+
+        fun createStack(module: Module, nlsFile: VirtualFile? = null): TranslationStoreStackLoaderResult? {
+            val start = System.currentTimeMillis()
+            return try {
+                val indicator = ProgressManager.getInstance().progressIndicator ?: EmptyProgressIndicator()
+                return createStack(module, nlsFile, indicator)
+            } catch (e: ProcessCanceledException) {
+                throw e
+            } catch (e: RuntimeException) {
+                SdkLog.error("Error computing texts for module '{}'.", module.name, e)
+                null
+            } finally {
+                SdkLog.debug("Translation stack creation took {}ms", System.currentTimeMillis() - start)
+            }
+        }
+
+        private fun createStack(module: Module, nlsFile: VirtualFile?, indicator: ProgressIndicator) = callInIdeaEnvironmentSync(module.project, indicator.toScoutProgress()) { e, p ->
+            return@callInIdeaEnvironmentSync computeInLongReadAction(module.project, p.indicator) {
+                return@computeInLongReadAction createStack(module.moduleDirPath(), e, p)
+                        .map { stack -> TranslationStoreStackLoaderResult(stack, nlsFile?.let { findPrimaryStore(it, stack) }, nlsFile, module) }
+                        .orElse(null)
+            }
+        }
+
+        private fun findPrimaryStore(nlsFile: VirtualFile, stack: TranslationStoreStack): ITranslationStore {
+            val nlsFilePath = nlsFile.toNioPath()
+            return NlsFile(nlsFilePath)
+                    .findMatchingStoreIn(stack)
+                    .orElseGet {
+                        stack.allStores()
+                                .findFirst()
+                                .orElseThrow { newFail("No translation stores found for nls file '{}'.", nlsFilePath) }
+                    }
+        }
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/completion/NlsCompletionContributorForHtml.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/completion/NlsCompletionContributorForHtml.kt
new file mode 100644
index 0000000..7a2f0e9
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/completion/NlsCompletionContributorForHtml.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.completion
+
+import com.intellij.codeInsight.completion.CompletionContributor
+import com.intellij.codeInsight.completion.CompletionType
+import org.eclipse.scout.sdk.s2i.nls.PsiNlsPatterns.Companion.keyPattern
+
+class NlsCompletionContributorForHtml : CompletionContributor() {
+    init {
+        extend(CompletionType.BASIC, keyPattern(), NlsCompletionContributorForJava.DefaultNlsCompletionProvider())
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/completion/NlsCompletionContributorForJava.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/completion/NlsCompletionContributorForJava.kt
new file mode 100644
index 0000000..a157f60
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/completion/NlsCompletionContributorForJava.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.completion
+
+import com.intellij.codeInsight.completion.*
+import com.intellij.util.ProcessingContext
+import org.eclipse.scout.sdk.s2i.containingModule
+import org.eclipse.scout.sdk.s2i.nls.PsiNlsPatterns.Companion.textsGetPattern
+import org.eclipse.scout.sdk.s2i.nls.completion.NlsCompletionHelper.Companion.computeLookupElements
+import kotlin.streams.toList
+
+class NlsCompletionContributorForJava : CompletionContributor() {
+
+    init {
+        extend(CompletionType.BASIC, textsGetPattern(), DefaultNlsCompletionProvider())
+    }
+
+    internal class DefaultNlsCompletionProvider : CompletionProvider<CompletionParameters>() {
+        override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) {
+            val module = parameters.position.containingModule() ?: return
+            result.addAllElements(computeLookupElements(module).toList())
+            result.stopHere()
+        }
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/completion/NlsCompletionContributorForJs.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/completion/NlsCompletionContributorForJs.kt
new file mode 100644
index 0000000..67be591
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/completion/NlsCompletionContributorForJs.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.completion
+
+import com.intellij.codeInsight.completion.*
+import com.intellij.lang.javascript.patterns.JSPatterns.*
+import com.intellij.lang.javascript.psi.JSCallExpression
+import com.intellij.lang.javascript.psi.JSReferenceExpression
+import com.intellij.patterns.ElementPattern
+import com.intellij.patterns.PatternCondition
+import com.intellij.patterns.StandardPatterns.and
+import com.intellij.patterns.StandardPatterns.or
+import com.intellij.psi.PsiElement
+import com.intellij.util.ProcessingContext
+import org.eclipse.scout.sdk.core.s.nls.query.TranslationPatterns
+import org.eclipse.scout.sdk.s2i.containingModule
+import org.eclipse.scout.sdk.s2i.nls.PsiNlsPatterns.Companion.startsWithIgnoringQuotes
+import kotlin.streams.toList
+
+class NlsCompletionContributorForJs : CompletionContributor() {
+
+    init {
+        extend(CompletionType.BASIC, sessionTextPattern(), NlsCompletionContributorForJava.DefaultNlsCompletionProvider())
+        extend(CompletionType.BASIC, textKeyPattern(), NlsCompletionProviderForTextKey())
+    }
+
+    private class NlsCompletionProviderForTextKey : CompletionProvider<CompletionParameters>() {
+        override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) {
+            val module = parameters.position.containingModule() ?: return
+            val elements = NlsCompletionHelper.computeLookupElements(module) {
+                TranslationPatterns.JsonTextKeySearch.JSON_TEXT_KEY_PREFIX + it.key() + TranslationPatterns.JsonTextKeySearch.JSON_TEXT_KEY_SUFFIX
+            }.toList()
+            result.addAllElements(elements)
+            result.stopHere()
+        }
+    }
+
+
+    /**
+     * These patterns are here and not in [org.eclipse.scout.sdk.s2i.nls.PsiNlsPatterns] because the dependency to the JS module is optional!
+     */
+    companion object {
+        fun sessionTextPattern() = psiElement().withParent(isJsCallWithLiteralArgument("session", "text", 0))
+
+        fun textKeyPattern() = psiElement().withParent(jsLiteralExpression().withText(startsWithIgnoringQuotes(TranslationPatterns.JsonTextKeySearch.JSON_TEXT_KEY_PREFIX, true)))
+
+        fun allNlsPatternsInJs() = or(sessionTextPattern(), textKeyPattern())
+
+        fun isJsCallWithLiteralArgument(qualifierName: String, functionName: String, argumentIndex: Int): ElementPattern<PsiElement> {
+            return and(
+                    jsLiteralExpression(),
+                    jsArgument(functionName, argumentIndex),
+                    psiElement().withSuperParent(2, jsCallExpression().with(MethodExpressionQualifier(qualifierName)))
+            )
+        }
+
+        private class MethodExpressionQualifier(val methodQualifier: String) : PatternCondition<JSCallExpression>("methodQualifier=$methodQualifier") {
+            override fun accepts(t: JSCallExpression, context: ProcessingContext): Boolean {
+                val expression = t.methodExpression
+                return expression is JSReferenceExpression && jsReferenceExpression().withReferenceName(methodQualifier).accepts(expression.qualifier)
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/completion/NlsCompletionHelper.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/completion/NlsCompletionHelper.kt
new file mode 100644
index 0000000..a23b460
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/completion/NlsCompletionHelper.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.completion
+
+import com.intellij.codeInsight.lookup.LookupElement
+import com.intellij.codeInsight.lookup.LookupElementBuilder
+import com.intellij.codeInsight.lookup.LookupElementPresentation
+import com.intellij.codeInsight.lookup.LookupElementRenderer
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.module.Module
+import org.eclipse.scout.sdk.core.s.nls.ITranslationEntry
+import org.eclipse.scout.sdk.core.s.nls.Language
+import org.eclipse.scout.sdk.s2i.nls.TranslationStoreStackLoader.Companion.createStack
+import java.util.stream.Stream
+
+class NlsCompletionHelper private constructor() {
+
+    companion object {
+
+        private val RENDERER = object : LookupElementRenderer<LookupElement>() {
+            override fun renderElement(element: LookupElement, presentation: LookupElementPresentation) {
+                renderLookupElement(element, presentation)
+            }
+        }
+
+        fun computeLookupElements(module: Module, lookupStringProvider: (ITranslationEntry) -> String = { it.key() }) =
+                createStack(module)
+                        ?.allEntries()
+                        ?.map { lookupElementFor(it, lookupStringProvider) } ?: Stream.empty()
+
+        private fun lookupElementFor(translation: ITranslationEntry, lookupStringProvider: (ITranslationEntry) -> String) =
+                LookupElementBuilder.create(translation, lookupStringProvider.invoke(translation))
+                        .withCaseSensitivity(false)
+                        .withRenderer(RENDERER)
+
+        private fun renderLookupElement(element: LookupElement, presentation: LookupElementPresentation) {
+            val translation = element.getObject() as ITranslationEntry
+            val store = translation.store()
+            val isReadOnly = !store.isEditable
+            val serviceSuffix = "TextProviderService"
+
+            presentation.itemText = translation.key()
+            presentation.isItemTextItalic = isReadOnly
+            presentation.icon = AllIcons.Nodes.ResourceBundle
+
+            presentation.appendTailText("=" + translation.text(Language.LANGUAGE_DEFAULT).get(), true)
+
+            var storeName = store.service().type().elementName()
+            if (storeName.endsWith(serviceSuffix)) {
+                storeName = storeName.substring(0, storeName.length - serviceSuffix.length)
+            }
+            presentation.typeText = storeName
+        }
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/doc/AbstractNlsDocumentationProvider.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/doc/AbstractNlsDocumentationProvider.kt
new file mode 100644
index 0000000..fbd6575
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/doc/AbstractNlsDocumentationProvider.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.doc
+
+import com.intellij.lang.documentation.AbstractDocumentationProvider
+import com.intellij.lang.documentation.DocumentationMarkup
+import com.intellij.openapi.editor.Editor
+import com.intellij.psi.PsiElement
+import com.intellij.psi.PsiFile
+import org.eclipse.scout.sdk.core.s.nls.ITranslationEntry
+import org.eclipse.scout.sdk.core.util.Strings.escapeHtml
+import org.eclipse.scout.sdk.s2i.containingModule
+import org.eclipse.scout.sdk.s2i.nls.TranslationStoreStackLoader.Companion.createStack
+
+abstract class AbstractNlsDocumentationProvider : AbstractDocumentationProvider() {
+    override fun getCustomDocumentationElement(editor: Editor, file: PsiFile, contextElement: PsiElement?): PsiElement? {
+        if (accept(contextElement)) {
+            return contextElement
+        }
+        return null
+    }
+
+    override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
+        if (element == null || !accept(element)) {
+            return null
+        }
+        val translation = findTranslationFor(element) ?: return null
+        return generateDoc(translation)
+    }
+
+    private fun findTranslationFor(element: PsiElement): ITranslationEntry? {
+        val module = element.containingModule() ?: return null
+        val stack = createStack(module) ?: return null
+        val key = psiElementToKey(element) ?: return null
+        return stack.translation(key).orElse(null)
+    }
+
+    protected abstract fun psiElementToKey(element: PsiElement): String?
+
+    private fun generateDoc(translation: ITranslationEntry): String {
+        val doc = StringBuilder()
+                .append(DocumentationMarkup.CONTENT_START)
+                .append(escapeHtml("'${translation.key()}' defined in '${translation.store().service().type().elementName()}'"))
+                .append(DocumentationMarkup.CONTENT_END)
+                .append("<font size='1'>&nbsp;</font>")
+                .append(DocumentationMarkup.SECTIONS_START)
+        translation.texts().entries.forEach {
+            doc.append(DocumentationMarkup.SECTION_HEADER_START).append(escapeHtml(it.key.displayName() + ":")).append("</p>")
+                    .append(DocumentationMarkup.SECTION_SEPARATOR).append(escapeHtml(it.value.trim())).append(DocumentationMarkup.SECTION_END).append("</tr>")
+        }
+        doc.append(DocumentationMarkup.SECTIONS_END)
+        return doc.toString()
+    }
+
+    protected abstract fun accept(element: PsiElement?): Boolean
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/doc/NlsDocumentationProviderForHtml.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/doc/NlsDocumentationProviderForHtml.kt
new file mode 100644
index 0000000..657842c
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/doc/NlsDocumentationProviderForHtml.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.doc
+
+import com.intellij.psi.PsiElement
+import org.eclipse.scout.sdk.s2i.nls.PsiNlsPatterns.Companion.keyPattern
+
+open class NlsDocumentationProviderForHtml : AbstractNlsDocumentationProvider() {
+
+    override fun accept(element: PsiElement?) = keyPattern().accepts(element)
+
+    override fun psiElementToKey(element: PsiElement): String = element.text
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/doc/NlsDocumentationProviderForJava.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/doc/NlsDocumentationProviderForJava.kt
new file mode 100644
index 0000000..7da0e0e
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/doc/NlsDocumentationProviderForJava.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.doc
+
+import com.intellij.psi.PsiElement
+import org.eclipse.scout.sdk.core.util.Strings.withoutQuotes
+import org.eclipse.scout.sdk.s2i.nls.PsiNlsPatterns.Companion.textsGetPattern
+
+open class NlsDocumentationProviderForJava : AbstractNlsDocumentationProvider() {
+
+    override fun accept(element: PsiElement?) = textsGetPattern().accepts(element)
+
+    override fun psiElementToKey(element: PsiElement) = withoutQuotes(element.text).toString()
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/doc/NlsDocumentationProviderForJs.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/doc/NlsDocumentationProviderForJs.kt
new file mode 100644
index 0000000..26baf5c
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/doc/NlsDocumentationProviderForJs.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.doc
+
+import com.intellij.psi.PsiElement
+import org.eclipse.scout.sdk.core.s.nls.query.TranslationPatterns
+import org.eclipse.scout.sdk.core.util.Strings.withoutQuotes
+import org.eclipse.scout.sdk.s2i.nls.completion.NlsCompletionContributorForJs.Companion.allNlsPatternsInJs
+
+class NlsDocumentationProviderForJs : AbstractNlsDocumentationProvider() {
+    override fun accept(element: PsiElement?) = allNlsPatternsInJs().accepts(element)
+
+    override fun psiElementToKey(element: PsiElement): String? {
+        var text = withoutQuotes(element.text).toString()
+        val jsonPrefix = TranslationPatterns.JsonTextKeySearch.JSON_TEXT_KEY_PREFIX
+        val jsonSuffix = TranslationPatterns.JsonTextKeySearch.JSON_TEXT_KEY_SUFFIX
+        if (text.startsWith(jsonPrefix) && text.endsWith(jsonSuffix)) {
+            text = text.substring(jsonPrefix.length, text.length - jsonSuffix.length)
+        }
+        return text
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/LanguageNewDialog.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/LanguageNewDialog.kt
new file mode 100644
index 0000000..ac2e810
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/LanguageNewDialog.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.editor
+
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.ComboBox
+import com.intellij.openapi.ui.DialogPanel
+import com.intellij.openapi.ui.DialogWrapper
+import org.eclipse.scout.sdk.core.s.nls.ITranslationStore
+import org.eclipse.scout.sdk.core.s.nls.Language
+import org.eclipse.scout.sdk.core.s.nls.TranslationStoreStack
+import org.eclipse.scout.sdk.core.util.Strings
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
+import java.util.*
+import javax.swing.JComponent
+
+class LanguageNewDialog(val project: Project, val store: ITranslationStore, val stack: TranslationStoreStack) : DialogWrapper(project, true, IdeModalityType.PROJECT) {
+
+    private var m_comboBox: ComboBox<Language>? = null
+
+    init {
+        title = message("create.new.language")
+        init()
+        setResizable(false)
+    }
+
+    override fun createCenterPanel(): JComponent {
+        val rootPanel = DialogPanel() // do not use applyCallbacks. the API is different in newer IJ versions
+
+        val allLanguages = Locale.getAvailableLocales()
+                .map { Language(it) }
+                .filter { !store.containsLanguage(it) }
+                .sorted()
+                .toTypedArray()
+        val comboBox = ComboBox<Language>(allLanguages)
+        rootPanel.add(comboBox)
+        m_comboBox = comboBox
+        return rootPanel
+    }
+
+    fun languagesBox() = m_comboBox!!
+
+    override fun doOKAction() {
+        if (!okAction.isEnabled) {
+            return
+        }
+        doOk()
+        close(OK_EXIT_CODE)
+    }
+
+    private fun doOk() {
+        val selectedItem = languagesBox().selectedItem
+        if (selectedItem is Language && Strings.hasText(selectedItem.displayName())) {
+            stack.addNewLanguage(selectedItem, store)
+        }
+    }
+
+    override fun getPreferredFocusedComponent() = languagesBox()
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsEditor.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsEditor.kt
new file mode 100644
index 0000000..7fb2740
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsEditor.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.editor
+
+import com.intellij.codeHighlighting.BackgroundEditorHighlighter
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.fileEditor.FileEditor
+import com.intellij.openapi.fileEditor.FileEditorLocation
+import com.intellij.openapi.fileEditor.FileEditorState
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.UserDataHolderBase
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.ui.components.JBLabel
+import org.eclipse.scout.sdk.core.util.Strings
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
+import org.eclipse.scout.sdk.s2i.nls.TranslationStoreStackLoader
+import java.awt.BorderLayout
+import java.awt.Graphics
+import java.beans.PropertyChangeListener
+import javax.swing.JPanel
+
+class NlsEditor(val project: Project, private val vFile: VirtualFile) : UserDataHolderBase(), FileEditor {
+
+    private val m_root: JPanel = object : JPanel(BorderLayout()) {
+        private var m_first = true
+
+        override fun paint(g: Graphics) {
+            if (m_first) {
+                m_first = false
+                // do not schedule directly. instead schedule after this paint. otherwise there might be ArrayIndexOutOfBoundsExceptions in swing.
+                ApplicationManager.getApplication().invokeLater {
+                    FileDocumentManager.getInstance().saveAllDocuments() // ensures all changes are visible to the loader.
+                    TranslationStoreStackLoader.createModalLoader(vFile, project)
+                            .withErrorHandler { onLoadError(it) }
+                            .withStackCreatedHandler { onStackCreated(it) }
+                            .queue()
+                }
+            }
+            super.paint(g)
+        }
+    }
+    private var m_content: NlsEditorContent? = null
+
+    private fun onStackCreated(result: TranslationStoreStackLoader.TranslationStoreStackLoaderResult?) {
+        if (result?.primaryStore == null) {
+            ApplicationManager.getApplication().invokeLater { m_root.add(JBLabel(message("no.translations.found"))) }
+            return
+        }
+
+        ApplicationManager.getApplication().invokeLater {
+            val content = NlsEditorContent(project, result.stack, result.primaryStore)
+            m_content = content
+            m_root.add(content)
+            content.textFilterField().requestFocus()
+        }
+    }
+
+    private fun onLoadError(e: Throwable) = ApplicationManager.getApplication().invokeLater {
+        m_root.add(JBLabel(toLabelString(e)))
+    }
+
+    private fun toLabelString(e: Throwable): String {
+        val msg = Strings.escapeHtml(e.message)
+        val stackTrace = Strings.fromThrowable(e).replace("\r", "").replace("\n", "<br>")
+        return "<html>$msg<br>$stackTrace</html>"
+    }
+
+    override fun getFile() = vFile
+
+    override fun isModified() = false
+
+    override fun getName() = "Scout NLS"
+
+    override fun isValid() = file.isValid
+
+    override fun getComponent() = m_root
+
+    override fun getPreferredFocusedComponent() = m_root
+
+    override fun setState(state: FileEditorState) {
+    }
+
+    override fun selectNotify() {
+    }
+
+    override fun deselectNotify() {
+    }
+
+    override fun getCurrentLocation(): FileEditorLocation? = null
+
+    override fun getBackgroundHighlighter(): BackgroundEditorHighlighter? = null
+
+    override fun removePropertyChangeListener(listener: PropertyChangeListener) {
+    }
+
+    override fun addPropertyChangeListener(listener: PropertyChangeListener) {
+    }
+
+    override fun dispose() {
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsEditorContent.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsEditorContent.kt
new file mode 100644
index 0000000..be82140
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsEditorContent.kt
@@ -0,0 +1,469 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.editor
+
+import com.intellij.find.impl.RegExHelpPopup
+import com.intellij.icons.AllIcons
+import com.intellij.lang.properties.psi.PropertiesFile
+import com.intellij.openapi.actionSystem.*
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.progress.ProgressIndicator
+import com.intellij.openapi.progress.Task
+import com.intellij.openapi.project.DumbAwareAction
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.MessageType
+import com.intellij.openapi.ui.popup.Balloon
+import com.intellij.openapi.ui.popup.JBPopupFactory
+import com.intellij.openapi.util.registry.Registry
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.psi.PsiManager
+import com.intellij.ui.BalloonImpl
+import com.intellij.ui.DocumentAdapter
+import com.intellij.ui.awt.RelativePoint
+import com.intellij.ui.components.JBCheckBox
+import com.intellij.ui.components.JBLabel
+import com.intellij.ui.components.JBPanel
+import org.apache.commons.csv.CSVFormat
+import org.eclipse.scout.sdk.core.log.SdkLog
+import org.eclipse.scout.sdk.core.log.SdkLog.onTrace
+import org.eclipse.scout.sdk.core.s.nls.*
+import org.eclipse.scout.sdk.core.s.nls.properties.EditableTranslationFile
+import org.eclipse.scout.sdk.core.s.nls.properties.PropertiesTranslationStore
+import org.eclipse.scout.sdk.core.s.nls.properties.ReadOnlyTranslationFile
+import org.eclipse.scout.sdk.core.util.CoreUtils
+import org.eclipse.scout.sdk.core.util.Strings
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
+import org.eclipse.scout.sdk.s2i.resolvePsi
+import org.eclipse.scout.sdk.s2i.toScoutProgress
+import org.eclipse.scout.sdk.s2i.toVirtualFile
+import org.eclipse.scout.sdk.s2i.ui.IndexedFocusTraversalPolicy
+import org.eclipse.scout.sdk.s2i.ui.TextFieldWithMaxLen
+import java.awt.GridBagConstraints
+import java.awt.GridBagLayout
+import java.awt.Insets
+import java.awt.Point
+import java.io.StringReader
+import java.util.concurrent.TimeUnit
+import java.util.function.Predicate
+import java.util.regex.Pattern
+import javax.swing.Icon
+import javax.swing.JComponent
+import javax.swing.event.DocumentEvent
+import kotlin.streams.toList
+
+class NlsEditorContent(val project: Project, val stack: TranslationStoreStack, val primaryStore: ITranslationStore) : JBPanel<NlsEditorContent>(GridBagLayout()) {
+
+    private val m_table = NlsTable(stack, project)
+    private val m_textFilter = TextFieldWithMaxLen(maxLength = 2000)
+    private val m_regexHelpButton = RegExHelpPopup.createRegExLink("<html><body><b>?</b></body></html>", this, null)
+    private val m_hideReadOnly = JBCheckBox(message("hide.readonly.rows"), true)
+    private val m_hideInherited = JBCheckBox(message("hide.inherited.rows"), true)
+
+    private var m_searchPattern: Predicate<String>? = null
+
+    init {
+        val typeFilterLayout = GridBagConstraints()
+        typeFilterLayout.gridx = 0
+        typeFilterLayout.gridy = 0
+        typeFilterLayout.gridwidth = 1
+        typeFilterLayout.gridheight = 1
+        typeFilterLayout.fill = GridBagConstraints.HORIZONTAL
+        typeFilterLayout.insets = Insets(15, 7, 0, 0)
+        add(TranslationFilterPanel(), typeFilterLayout)
+
+        val tableLayout = GridBagConstraints()
+        tableLayout.gridx = 0
+        tableLayout.gridy = 1
+        tableLayout.gridwidth = 1
+        tableLayout.gridheight = 1
+        tableLayout.fill = GridBagConstraints.BOTH
+        tableLayout.insets = Insets(8, 8, 0, 0)
+        tableLayout.weightx = 1.0
+        tableLayout.weighty = 1.0
+        add(m_table, tableLayout)
+
+        val actionsLayout = GridBagConstraints()
+        actionsLayout.gridx = 1
+        actionsLayout.gridy = 1
+        actionsLayout.gridwidth = 1
+        actionsLayout.gridheight = 1
+        actionsLayout.fill = GridBagConstraints.VERTICAL
+        actionsLayout.insets = Insets(8, 0, 0, 0)
+        val toolbar = createToolbar()
+        add(toolbar, actionsLayout)
+
+        isFocusTraversalPolicyProvider = true
+        isFocusCycleRoot = true
+        val focusPolicy = IndexedFocusTraversalPolicy()
+        focusPolicy.addComponent(m_textFilter)
+        focusPolicy.addComponent(m_regexHelpButton)
+        focusPolicy.addComponent(m_hideReadOnly)
+        focusPolicy.addComponent(m_hideInherited)
+        focusTraversalPolicy = focusPolicy
+
+        filterChanged()
+    }
+
+    fun textFilterField() = m_textFilter
+
+    private fun filterChanged() {
+        m_searchPattern = toPredicate(m_textFilter.text)
+        m_table.setFilter(Predicate { acceptTranslation(it) })
+    }
+
+    private fun acceptTranslation(candidate: ITranslationEntry): Boolean {
+        val isHideReadOnlyRows = m_hideReadOnly.isSelected
+        if (isHideReadOnlyRows && !candidate.store().isEditable) {
+            return false
+        }
+
+        val isHideInheritedRows = m_hideInherited.isSelected
+        if (isHideInheritedRows && candidate.store() != primaryStore) {
+            return false
+        }
+
+        val textFilter = m_searchPattern ?: return true
+        if (textFilter.test(candidate.key())) {
+            return true
+        }
+        return candidate.texts().values.any { textFilter.test(it) }
+    }
+
+    private fun toPredicate(searchText: String): Predicate<String>? {
+        if (Strings.isBlank(searchText)) {
+            return null
+        }
+
+        return try {
+            Pattern.compile(searchText, Pattern.CASE_INSENSITIVE)
+        } catch (e: Exception) {
+            Pattern.compile(Pattern.quote(searchText), Pattern.CASE_INSENSITIVE)
+        }.asPredicate()
+    }
+
+    private fun createToolbar(): JComponent {
+        return ActionManager.getInstance()
+                .createActionToolbar(ActionPlaces.EDITOR_TOOLBAR, createActionGroup(), false)
+                .component
+    }
+
+    private fun createActionGroup(): ActionGroup {
+        val result = DefaultActionGroup()
+        result.add(TranslationNewActionGroup())
+        result.add(RemoveTranslationsAction())
+        result.addSeparator()
+        result.add(TranslationLocateActionGroup())
+        result.add(ReloadAction())
+        result.addSeparator()
+        result.add(LanguageNewAction())
+        result.addSeparator()
+        result.add(ImportFromClipboardAction())
+        result.add(ExportToClipboardAction())
+        return result
+    }
+
+    private fun showBalloon(text: String, severity: MessageType) {
+        val lbl = JBLabel(text)
+        val balloon = JBPopupFactory.getInstance()
+                .createBalloonBuilder(lbl)
+                .setShowCallout(false)
+                .setAnimationCycle(Registry.intValue("ide.tooltip.animationCycle"))
+                .setBlockClicksThroughBalloon(true)
+                .setFillColor(severity.popupBackground)
+                .setBorderColor(severity.borderColor)
+                .createBalloon()
+        if (balloon is BalloonImpl) {
+            balloon.startSmartFadeoutTimer(TimeUnit.SECONDS.toMillis(30).toInt())
+        }
+        balloon.show(RelativePoint(m_table, Point(m_table.visibleRect.width / 2, 0)), Balloon.Position.above)
+    }
+
+    private inner class TranslationNewActionGroup : AbstractEditableStoresAction(message("create.new.translation"), message("create.new.translation.in"), AllIcons.General.Add, {
+        TranslationNewDialogOpenAction(it)
+    })
+
+    private inner class TranslationNewDialogOpenAction(private val store: ITranslationStore) : DumbAwareAction(store.service().type().elementName()) {
+        override fun actionPerformed(e: AnActionEvent) {
+            val dialog = TranslationNewDialog(project, store, stack)
+            val ok = dialog.showAndGet()
+            if (ok) {
+                val createdTranslation = dialog.createdTranslation() ?: return
+                m_table.selectTranslation(createdTranslation)
+            }
+        }
+    }
+
+    private inner class RemoveTranslationsAction : DumbAwareAction(message("remove.selected.rows"), null, AllIcons.General.Remove) {
+        override fun update(e: AnActionEvent) {
+            val selectedTranslations = m_table.selectedTranslations()
+            e.presentation.isEnabled = selectedTranslations.isNotEmpty()
+                    && selectedTranslations.map { it.store() }.all { it.isEditable }
+        }
+
+        override fun actionPerformed(e: AnActionEvent) {
+            val toDelete = m_table.selectedTranslations()
+                    .map { it.key() }
+                    .stream()
+            stack.removeTranslations(toDelete)
+        }
+    }
+
+    private inner class TranslationLocateActionGroup : DumbAwareAction(message("jump.to.declaration"), null, AllIcons.General.Locate) {
+        override fun update(e: AnActionEvent) {
+            e.presentation.isEnabled = m_table.selectedTranslations().size == 1
+        }
+
+        override fun actionPerformed(e: AnActionEvent) {
+            val selection = m_table.selectedTranslations()
+            if (selection.size != 1) {
+                return
+            }
+            val selectedTranslation = selection[0]
+            val selectedLanguages = m_table.selectedLanguages()
+            if (selectedLanguages.size == 1) {
+                // open chooser: jump to service or property?
+                val group = DefaultActionGroup(listOf(TranslationServiceLocateAction(selectedTranslation), TranslationTextLocateAction(selectedTranslation, selectedLanguages[0])))
+                val popup = JBPopupFactory.getInstance().createActionGroupPopup(templatePresentation.text, group, e.dataContext, JBPopupFactory.ActionSelectionAid.NUMBERING, false)
+                popup.showUnderneathOf(e.inputEvent.component)
+            } else {
+                TranslationServiceLocateAction(selectedTranslation).actionPerformed(e)
+            }
+        }
+    }
+
+    private inner class TranslationTextLocateAction(val translation: ITranslationEntry, val language: Language) : DumbAwareAction(message("jump.to.property"), null, AllIcons.Nodes.ResourceBundle) {
+        override fun actionPerformed(e: AnActionEvent) {
+            val store = translation.store()
+            if (store !is PropertiesTranslationStore) {
+                return
+            }
+
+            val file = store.files()[language] ?: return
+            if (file is EditableTranslationFile) {
+                // in project
+                val origin = file.path().toVirtualFile() ?: return
+                open(origin)
+            } else if (file is ReadOnlyTranslationFile) {
+                // in libraries
+                val source = file.source()
+                if (source is VirtualFile) {
+                    open(source)
+                }
+            }
+        }
+
+        private fun open(file: VirtualFile) {
+            val psi = PsiManager.getInstance(project).findFile(file)
+            if (psi !is PropertiesFile) {
+                return
+            }
+            psi.findPropertyByKey(translation.key())?.navigate(true)
+        }
+    }
+
+    private inner class TranslationServiceLocateAction(val translation: ITranslationEntry) : DumbAwareAction(message("jump.to.text.service"), null, AllIcons.Nodes.Services) {
+        override fun actionPerformed(e: AnActionEvent) {
+            translation.store().service().type().resolvePsi()?.navigate(true)
+        }
+    }
+
+    private inner class LanguageNewAction : DumbAwareAction(message("add.new.language"), null, AllIcons.ToolbarDecorator.AddLink) {
+        override fun actionPerformed(e: AnActionEvent) {
+            LanguageNewDialog(project, primaryStore, stack).show()
+        }
+    }
+
+    private inner class ReloadAction : DumbAwareAction(message("reload.from.filesystem"), null, AllIcons.Actions.Refresh) {
+        override fun actionPerformed(e: AnActionEvent) {
+            FileDocumentManager.getInstance().saveAllDocuments()
+            object : Task.Modal(project, message("loading.translations"), true) {
+                override fun run(indicator: ProgressIndicator) {
+                    stack.reload(indicator.toScoutProgress())
+                }
+            }.queue()
+        }
+    }
+
+    private inner class ImportFromClipboardAction : DumbAwareAction(message("import.translations.from.clipboard"), null, AllIcons.ToolbarDecorator.Import) {
+        override fun actionPerformed(e: AnActionEvent) {
+            val clipboardContent: String? = CoreUtils.getTextFromClipboard()
+            if (clipboardContent == null) {
+                showBalloon(message("clipboard.no.text.content"), MessageType.ERROR)
+                return
+            }
+            val data = parseCsv(clipboardContent)
+            if (data == null) {
+                showBalloon(message("clipboard.no.valid.content"), MessageType.ERROR)
+                return
+            }
+            handleResult(stack.importTranslations(data, NlsTableModel.KEY_COLUMN_HEADER_NAME, primaryStore))
+        }
+
+        fun parseCsv(content: String): List<List<String>>? {
+            return listOf(CSVFormat.TDF, CSVFormat.EXCEL, CSVFormat.DEFAULT, CSVFormat.RFC4180, CSVFormat.POSTGRESQL_CSV, CSVFormat.POSTGRESQL_TEXT, CSVFormat.MYSQL, CSVFormat.ORACLE)
+                    .mapNotNull { tryParseUsing(it, content) }
+                    .firstOrNull()
+        }
+
+        private fun tryParseUsing(format: CSVFormat, content: String): List<List<String>>? {
+            try {
+                val lines = format.parse(StringReader(content)).records
+                if (lines.size < 2) {
+                    return null
+                }
+                val result = ArrayList<ArrayList<String>>(lines.size)
+                for (record in lines) {
+                    val row = ArrayList<String>(record.size())
+                    row.addAll(record)
+                    result.add(row)
+                }
+                return result
+            } catch (e: Exception) {
+                SdkLog.debug("Unable to parse clipboard content as csv using format '{}'.", format, onTrace(e))
+                return null
+            }
+        }
+
+        private fun handleResult(importInfo: ITranslationImportInfo) {
+            val result = importInfo.result()
+            if (result < 1) {
+                showBalloon(message("clipboard.content.no.mapping"), MessageType.ERROR)
+                return
+            }
+            val listSeparator = ", "
+            val listPrefix = "["
+            val listPostfix = "]"
+            val balloonMessages = ArrayList<String>()
+            val logMessages = ArrayList<String>()
+            val duplicateKeys = importInfo.duplicateKeys()
+            val maxNumItemsInBalloon = 3
+            balloonMessages.add(message("import.successful.x.rows", result))
+            if (duplicateKeys.isNotEmpty()) {
+                val balloonList = duplicateKeys.joinToString(listSeparator, listPrefix, listPostfix, maxNumItemsInBalloon)
+                balloonMessages.add(message("import.duplicate.keys", balloonList))
+                logMessages.add(message("import.duplicate.keys", duplicateKeys))
+            }
+            val ignoredColumns = importInfo.ignoredColumns()
+            if (ignoredColumns.isNotEmpty()) {
+                val messages = ignoredColumns.entries.map { message("column.x", it.key + 1) + if (Strings.hasText(it.value)) "=" + it.value else "" }
+                val balloonList = messages.joinToString(listSeparator, listPrefix, listPostfix, maxNumItemsInBalloon)
+                balloonMessages.add(message("import.columns.not.mapped", balloonList))
+                logMessages.add(message("import.columns.not.mapped", messages))
+            }
+            val invalidRows = importInfo.invalidRowIndices()
+                    .map { it + 1 } // convert to row number
+                    .toList()
+            if (invalidRows.isNotEmpty()) {
+                val balloonList = invalidRows.joinToString(listSeparator, listPrefix, listPostfix, maxNumItemsInBalloon)
+                balloonMessages.add(message("import.rows.invalid", balloonList))
+                logMessages.add(message("import.rows.invalid", invalidRows))
+            }
+
+            val balloonMessage = balloonMessages.joinToString("<br>", "<html>", "</html>", transform = Strings::escapeHtml)
+            val hasWarnings = logMessages.isNotEmpty()
+            if (hasWarnings) {
+                showBalloon(balloonMessage, MessageType.WARNING)
+                logMessages.forEach { SdkLog.warning(it) }
+            } else {
+                showBalloon(balloonMessage, MessageType.INFO)
+            }
+        }
+    }
+
+    private inner class ExportToClipboardAction : DumbAwareAction(message("export.table.to.clipboard"), null, AllIcons.ToolbarDecorator.Export) {
+        override fun actionPerformed(e: AnActionEvent) {
+            val tableData = m_table.visibleData()
+            val table = toTable(tableData)
+            if (CoreUtils.setTextToClipboard(table)) {
+                showBalloon(message("table.content.copied.to.clipboard"), MessageType.INFO)
+            }
+        }
+
+        fun toTable(data: List<List<String>>): String {
+            val prefix = "<html><head><meta charset=\"utf-8\"></head><body><table>\n"
+            val postfix = "</table></body></html>\n"
+            return data.indices
+                    .associateWith { data[it] }
+                    .map { encodeRow(it.key, it.value) }
+                    .joinToString("\n", prefix, postfix)
+        }
+
+        fun encodeRow(index: Int, row: List<String>): String {
+            val tag = if (index == 0) "th" else "td"
+            return row
+                    .map { Strings.escapeHtml(it) }
+                    .map { Strings.replaceEach(it, arrayOf("\r", "\n", " "), arrayOf("", "<br style=\"mso-data-placement:same-cell;\"/>", "&#32;")) }
+                    .joinToString("", "<tr>", "</tr>") {
+                        "<$tag>$it</$tag>"
+                    }
+        }
+    }
+
+    private abstract inner class AbstractEditableStoresAction(text: String, val groupTitle: String, icon: Icon?, val task: (ITranslationStore) -> AnAction) : DumbAwareAction(text, null, icon) {
+        override fun actionPerformed(e: AnActionEvent) {
+            val stores = stack.allEditableStores().toList()
+            if (stores.isEmpty()) {
+                return
+            }
+            if (stores.size == 1) {
+                stores[0]?.let { task.invoke(it).actionPerformed(e) }
+            } else {
+                val popupActions = stores.map { task.invoke(it) }
+                val group = DefaultActionGroup(popupActions)
+                val popup = JBPopupFactory.getInstance().createActionGroupPopup(groupTitle, group, e.dataContext, JBPopupFactory.ActionSelectionAid.ALPHA_NUMBERING, false)
+                popup.showUnderneathOf(e.inputEvent.component)
+            }
+        }
+    }
+
+    private inner class TranslationFilterPanel : JBPanel<TranslationFilterPanel>(GridBagLayout()) {
+        init {
+            val filterLayout = GridBagConstraints()
+            filterLayout.gridx = 0
+            filterLayout.gridy = 0
+            filterLayout.fill = GridBagConstraints.HORIZONTAL
+            filterLayout.weightx = 1.0
+            filterLayout.insets = Insets(0, 0, 0, 0)
+            m_textFilter.document.addDocumentListener(object : DocumentAdapter() {
+                override fun textChanged(e: DocumentEvent) {
+                    filterChanged()
+                }
+            })
+            m_textFilter.isFocusable = true
+            add(m_textFilter, filterLayout)
+
+            val regexHelpLayout = GridBagConstraints()
+            regexHelpLayout.gridx = 1
+            regexHelpLayout.gridy = 0
+            regexHelpLayout.insets = Insets(0, 4, 0, 0)
+            m_regexHelpButton.isFocusable = true
+            add(m_regexHelpButton, regexHelpLayout)
+
+            val readOnlyLayout = GridBagConstraints()
+            readOnlyLayout.gridx = 2
+            readOnlyLayout.gridy = 0
+            readOnlyLayout.insets = Insets(0, 24, 0, 0)
+            m_hideReadOnly.addActionListener { filterChanged() }
+            m_hideReadOnly.toolTipText = message("hide.readonly.rows.desc")
+            m_hideReadOnly.isFocusable = true
+            add(m_hideReadOnly, readOnlyLayout)
+
+            val inheritedLayout = GridBagConstraints()
+            inheritedLayout.gridx = 3
+            inheritedLayout.gridy = 0
+            inheritedLayout.insets = Insets(0, 16, 0, 4)
+            m_hideInherited.addActionListener { filterChanged() }
+            m_hideInherited.toolTipText = message("hide.inherited.rows.desc", primaryStore.service().type().name())
+            m_hideInherited.isFocusable = true
+            add(m_hideInherited, inheritedLayout)
+        }
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsFileEditorProvider.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsFileEditorProvider.kt
new file mode 100644
index 0000000..17db947
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsFileEditorProvider.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.editor
+
+import com.intellij.openapi.fileEditor.AsyncFileEditorProvider
+import com.intellij.openapi.fileEditor.FileEditor
+import com.intellij.openapi.fileEditor.FileEditorPolicy
+import com.intellij.openapi.fileEditor.FileEditorProvider
+import com.intellij.openapi.project.DumbAware
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.VirtualFile
+import org.eclipse.scout.sdk.s2i.containingModule
+import org.eclipse.scout.sdk.s2i.nls.NlsFileType
+
+class NlsFileEditorProvider : FileEditorProvider, AsyncFileEditorProvider, DumbAware {
+
+    override fun getEditorTypeId(): String = "Scout.nls"
+
+    override fun accept(project: Project, file: VirtualFile) =
+            file.isValid
+                    && !file.isDirectory
+                    && file.exists()
+                    && project.isInitialized
+                    && file.containingModule(project) != null
+                    && file.fileType == NlsFileType.INSTANCE
+
+    override fun createEditorAsync(project: Project, file: VirtualFile): AsyncFileEditorProvider.Builder = object : AsyncFileEditorProvider.Builder() {
+        override fun build(): FileEditor {
+            return NlsEditor(project, file)
+        }
+    }
+
+    override fun createEditor(project: Project, file: VirtualFile): FileEditor = createEditorAsync(project, file).build()
+
+    override fun getPolicy() = FileEditorPolicy.PLACE_BEFORE_DEFAULT_EDITOR
+}
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsTable.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsTable.kt
new file mode 100644
index 0000000..4318caf
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsTable.kt
@@ -0,0 +1,433 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.editor
+
+import com.intellij.icons.AllIcons
+import com.intellij.ide.DataManager
+import com.intellij.openapi.actionSystem.ActionPlaces
+import com.intellij.openapi.actionSystem.AnAction
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.actionSystem.DataContext
+import com.intellij.openapi.actionSystem.ex.ActionButtonLook
+import com.intellij.openapi.actionSystem.impl.ActionButton
+import com.intellij.openapi.keymap.KeymapUtil.getKeystrokeText
+import com.intellij.openapi.project.DumbAwareAction
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.MessageType
+import com.intellij.openapi.ui.popup.Balloon
+import com.intellij.openapi.ui.popup.JBPopupFactory
+import com.intellij.openapi.ui.popup.JBPopupListener
+import com.intellij.openapi.ui.popup.LightweightWindowEvent
+import com.intellij.openapi.util.registry.Registry
+import com.intellij.ui.DocumentAdapter
+import com.intellij.ui.JBColor
+import com.intellij.ui.awt.RelativePoint
+import com.intellij.ui.components.JBLabel
+import com.intellij.ui.components.JBPanel
+import com.intellij.ui.components.JBScrollPane
+import com.intellij.ui.components.JBTextArea
+import com.intellij.util.ui.JBDimension
+import com.intellij.util.ui.PositionTracker
+import org.eclipse.scout.sdk.core.s.nls.ITranslationEntry
+import org.eclipse.scout.sdk.core.s.nls.TranslationStoreStack
+import org.eclipse.scout.sdk.core.s.nls.TranslationValidator.*
+import org.eclipse.scout.sdk.core.util.Strings
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
+import org.eclipse.scout.sdk.s2i.nls.editor.NlsTableModel.Companion.KEY_COLUMN_INDEX
+import org.eclipse.scout.sdk.s2i.nls.editor.NlsTableModel.Companion.NUM_ADDITIONAL_COLUMNS
+import org.eclipse.scout.sdk.s2i.ui.TablePreservingSelection
+import org.eclipse.scout.sdk.s2i.ui.TextAreaWithContentSize
+import java.awt.*
+import java.awt.event.*
+import java.util.*
+import java.util.function.Predicate
+import javax.swing.*
+import javax.swing.KeyStroke.getKeyStroke
+import javax.swing.event.DocumentEvent
+import javax.swing.event.TableModelEvent
+import javax.swing.plaf.UIResource
+import javax.swing.table.TableCellEditor
+import javax.swing.table.TableCellRenderer
+import javax.swing.table.TableRowSorter
+import javax.swing.text.DefaultEditorKit
+
+class NlsTable(stack: TranslationStoreStack, project: Project) : JBScrollPane() {
+
+    private val m_model: NlsTableModel = NlsTableModel(stack, project)
+    private val m_table: TablePreservingSelection
+    private val m_tableSorterFilter = TableRowSorter(m_model)
+    private val m_cellMargin = Insets(1, 4, 2, 2)
+    private val m_editStartEvent = EventObject(this)
+
+    private var m_balloon: Balloon? = null
+    private var m_balloonContent: JBLabel? = null
+
+    companion object {
+        const val TEXT_COLUMN_WIDTH = 350
+        const val KEY_COLUMN_WIDTH = 250
+    }
+
+    init {
+        m_table = TablePreservingSelection(m_model, { index -> m_model.translationForRow(index) }, { row -> m_model.rowForTranslation(row as ITranslationEntry) })
+        m_table.tableColumnsChangedCallback = { adjustView() }
+        m_table.tableChangedCallback = { adjustRowHeights(it) }
+        m_table.fillsViewportHeight = true
+        m_table.autoResizeMode = JTable.AUTO_RESIZE_OFF
+        m_table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION)
+        m_table.rowSelectionAllowed = true
+        m_table.columnSelectionAllowed = false
+        m_table.cellSelectionEnabled = true
+        m_table.setEnableAntialiasing(true)
+        m_table.putClientProperty("terminateEditOnFocusLost", true)
+        m_table.setShowGrid(true)
+        m_table.gridColor = JBColor.border()
+        m_table.tableHeader.reorderingAllowed = false
+        m_table.addKeyListener(TableKeyListener())
+        m_table.addPropertyChangeListener {
+            // reset row height on cell editor cancel
+            if ("tableCellEditor" == it.propertyName && it.newValue == null && it.oldValue != null) {
+                adjustEditingRowHeight()
+                hideBalloon()
+            }
+        }
+        m_table.rowSorter = m_tableSorterFilter
+        m_tableSorterFilter.sortKeys = listOf(RowSorter.SortKey(0, SortOrder.ASCENDING), RowSorter.SortKey(1, SortOrder.ASCENDING))
+
+        border = null
+        setViewportView(m_table)
+    }
+
+    private fun adjustView() {
+        val columnModel = m_table.columnModel
+        for (i in 0 until columnModel.columnCount) {
+            val column = columnModel.getColumn(i)
+            if (i == KEY_COLUMN_INDEX) {
+                column.preferredWidth = KEY_COLUMN_WIDTH
+            } else {
+                column.preferredWidth = TEXT_COLUMN_WIDTH
+            }
+            column.cellRenderer = MultiLineTextCellRenderer()
+            column.cellEditor = MultiLineTextCellEditor(i != KEY_COLUMN_INDEX)
+        }
+        adjustRowHeights()
+    }
+
+    private fun adjustRowHeights(e: TableModelEvent?) {
+        val event = e ?: return
+        if (event.type == TableModelEvent.DELETE) {
+            return
+        }
+        val fontHeight = fontHeight()
+        (event.firstRow..event.lastRow).forEach {
+            val viewIndex = m_table.convertRowIndexToView(it)
+            if (viewIndex >= 0) {
+                adjustRowHeight(viewIndex, fontHeight, null)
+            }
+        }
+    }
+
+    private fun adjustRowHeights() {
+        val fontHeight = fontHeight()
+        for (i in 0 until m_model.rowCount) {
+            val viewIndex = m_table.convertRowIndexToView(i)
+            if (viewIndex >= 0) {
+                adjustRowHeight(viewIndex, fontHeight)
+            }
+        }
+    }
+
+    private fun fontHeight() = getFontMetrics(m_table.font).height
+
+    private fun adjustRowHeight(rowIndex: Int, fontHeight: Int, additionalText: String? = null) {
+        val rowsRequired = maxLinesForRow(rowIndex, additionalText)
+        val height = (rowsRequired * fontHeight) + 6
+        m_table.setRowHeight(rowIndex, height)
+    }
+
+    private fun adjustEditingRowHeight(additionalText: String? = null) {
+        val editingRowIndexView = m_table.editingRow
+        if (editingRowIndexView >= 0) {
+            adjustRowHeight(editingRowIndexView, fontHeight(), additionalText)
+        }
+    }
+
+    private fun maxLinesForRow(rowIndex: Int, additionalText: String? = null): Int {
+        val entry = m_model.translationForRow(m_table.convertRowIndexToModel(rowIndex))
+        val add = additionalText ?: ""
+        return (sequenceOf(add) + entry.texts().values.asSequence())
+                .map { Strings.countMatches(it, "\n") + 1 }
+                .max() ?: 1
+    }
+
+    fun selectedLanguages() = m_table.selectedColumns
+            .filter { it >= NUM_ADDITIONAL_COLUMNS }
+            .map { m_table.convertColumnIndexToModel(it) }
+            .map { m_model.languageForColumn(it) }
+
+    fun selectedTranslations() = m_table.selectedRows
+            .map { m_table.convertRowIndexToModel(it) }
+            .map { m_model.translationForRow(it) }
+
+    fun visibleData(): List<List<String>> {
+        val numAdditionalRows = 1 // header row
+        val data = ArrayList<List<String>>(m_table.rowCount + numAdditionalRows)
+
+        // header
+        val headerRow = ArrayList<String>(m_table.columnCount)
+        headerRow.add(NlsTableModel.KEY_COLUMN_HEADER_NAME)
+        for (lang in m_model.languages()) {
+            headerRow.add(lang.locale().toString())
+        }
+        data.add(headerRow)
+
+        // data
+        for (row in 0 until m_table.rowCount) {
+            val dataRow = ArrayList<String>(m_table.columnCount)
+            for (col in 0 until m_table.columnCount) {
+                dataRow.add(m_table.getValueAt(row, col).toString())
+            }
+            data.add(dataRow)
+        }
+        return data
+    }
+
+    fun selectTranslation(translation: ITranslationEntry) {
+        val row = m_table.convertRowIndexToView(m_model.rowForTranslation(translation))
+        if (row >= 0) {
+            m_table.setRowSelectionInterval(row, row)
+            m_table.scrollToSelection()
+        }
+    }
+
+    fun setFilter(newFilter: Predicate<ITranslationEntry>?) {
+        val filter = object : RowFilter<NlsTableModel, Int>() {
+            override fun include(entry: Entry<out NlsTableModel, out Int>): Boolean {
+                return newFilter?.test(m_model.translationForRow(entry.identifier)) ?: true
+            }
+        }
+        m_tableSorterFilter.rowFilter = filter
+        val tableStructureChanged = m_model.setFilter(newFilter)
+        if (!tableStructureChanged) {
+            adjustView() // only if the structure did not change. Because on structure change it is done anyway
+        }
+        m_table.scrollToSelection()
+    }
+
+    private fun showBalloon(message: String, owner: Component, severity: MessageType) {
+        val existingBalloonContent = m_balloonContent
+        if (existingBalloonContent != null) {
+            existingBalloonContent.text = message
+            m_balloon?.revalidate() // adapt balloon size to new text
+            return
+        }
+
+        val lbl = JBLabel(message)
+        lbl.putClientProperty("html.disable", true)
+        val builder = JBPopupFactory.getInstance().createBalloonBuilder(lbl)
+        val balloon = builder
+                .setFillColor(severity.popupBackground)
+                .setBorderColor(severity.borderColor)
+                .setHideOnAction(false)
+                .setHideOnKeyOutside(false)
+                .setAnimationCycle(Registry.intValue("ide.tooltip.animationCycle"))
+                .setBlockClicksThroughBalloon(true)
+                .createBalloon()
+        balloon.show(object : PositionTracker<Balloon>(owner) {
+            override fun recalculateLocation(element: Balloon): RelativePoint {
+                return RelativePoint(owner, Point((owner.size.width * 0.75).toInt(), -4))
+            }
+        }, Balloon.Position.above)
+        balloon.addListener(object : JBPopupListener {
+            override fun onClosed(event: LightweightWindowEvent) {
+                hideBalloon()
+            }
+        })
+        m_balloon = balloon
+        m_balloonContent = lbl
+    }
+
+    private fun hideBalloon() {
+        m_balloon?.hide()
+        m_balloon = null
+        m_balloonContent = null
+    }
+
+    private inner class TableKeyListener : KeyAdapter() {
+        override fun keyPressed(e: KeyEvent) {
+            if (e.keyCode == KeyEvent.VK_F2) {
+                val editStarted = m_table.editCellAt(m_table.selectedRow, m_table.selectedColumn, m_editStartEvent)
+                if (editStarted) {
+                    e.consume()
+                    val cellEditor = m_table.cellEditor
+                    if (cellEditor is MultiLineTextCellEditor) {
+                        cellEditor.focus()
+                    }
+                }
+            }
+        }
+    }
+
+    private inner class MultiLineTextCellRenderer : TableCellRenderer {
+        override fun getTableCellRendererComponent(table: JTable, value: Any?, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component {
+            val text = value.toString()
+            val txt = TextAreaWithContentSize(m_table.font, text)
+            txt.margin = m_cellMargin
+
+            if (isSelected) {
+                txt.foreground = table.selectionForeground
+                txt.background = table.selectionBackground
+            } else {
+                var background = table.background
+                if (background == null || background is UIResource) {
+                    val alternateColor = UIManager.getColor("Table.alternateRowColor")
+                    if (alternateColor != null && row % 2 != 0) {
+                        background = alternateColor
+                    }
+                }
+                if (m_model.translationForRow(m_table.convertRowIndexToModel(row)).store().isEditable) {
+                    txt.foreground = table.foreground
+                } else {
+                    txt.foreground = UIManager.getColor("Button.disabledText")
+                }
+                txt.background = background
+            }
+            return txt
+        }
+    }
+
+    private inner class NewLineAction(private val txt: JBTextArea) : DumbAwareAction(null, message("insert.new.line.x",
+            getKeystrokeText(getKeyStroke(KeyEvent.VK_ENTER, InputEvent.ALT_DOWN_MASK))), AllIcons.Actions.SearchNewLine) {
+        init {
+            templatePresentation.hoveredIcon = AllIcons.Actions.SearchNewLineHover
+        }
+
+        override fun actionPerformed(e: AnActionEvent) {
+            DefaultEditorKit.InsertBreakAction().actionPerformed(ActionEvent(txt, 0, "action"))
+        }
+    }
+
+    private inner class MultiLineTextCellEditor(supportMultiLine: Boolean) : AbstractCellEditor(), TableCellEditor {
+
+        private val m_cellContentPanel = JBPanel<JBPanel<*>>()
+        private val m_txt = TextAreaWithContentSize(m_table.font)
+        private val m_scrollPane = JBScrollPane(m_txt, ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED)
+
+        init {
+            val borderWidth = 1
+            m_cellContentPanel.border = BorderFactory.createLineBorder(JBColor.border(), borderWidth)
+            m_cellContentPanel.background = m_txt.background
+            m_scrollPane.border = null
+            m_txt.margin = Insets(m_cellMargin.top - borderWidth, m_cellMargin.left - borderWidth, m_cellMargin.bottom, m_cellMargin.right)
+            m_txt.document.addDocumentListener(object : DocumentAdapter() {
+                override fun textChanged(e: DocumentEvent) {
+                    if (supportMultiLine) {
+                        adjustEditingRowHeight(m_txt.text)
+                    }
+                    val editingRowViewIndex = m_table.editingRow
+                    val editingColumnViewIndex = m_table.editingColumn
+                    if (editingRowViewIndex >= 0 && editingColumnViewIndex >= 0) {
+                        validateEdit(m_txt.text, editingRowViewIndex, editingColumnViewIndex)
+                    }
+                }
+            })
+            m_txt.addKeyListener(object : KeyAdapter() {
+                override fun keyPressed(e: KeyEvent) {
+                    if (supportMultiLine && e.keyCode == KeyEvent.VK_ENTER && (e.isAltDown || e.isAltGraphDown)) {
+                        m_txt.insert(System.lineSeparator(), m_txt.caretPosition)
+                        e.consume()
+                    } else if (e.keyCode == KeyEvent.VK_ENTER || e.keyCode == KeyEvent.VK_TAB) {
+                        stopCellEditing()
+                        e.consume()
+                    }
+                }
+            })
+            if (!supportMultiLine) {
+                m_txt.document.putProperty("filterNewlines", true)
+            }
+
+            m_cellContentPanel.layout = GridBagLayout()
+            m_cellContentPanel.add(m_scrollPane, GridBagConstraints(0, 0, 1, 1, 1.0, 1.0, GridBagConstraints.FIRST_LINE_START,
+                    GridBagConstraints.BOTH, Insets(0, 0, 0, 0), 0, 0))
+
+            if (supportMultiLine) {
+                val newLineHelpButton = createButton(NewLineAction(m_txt))
+                m_cellContentPanel.add(newLineHelpButton, GridBagConstraints(1, 0, 1, 1, 0.0, 0.0, GridBagConstraints.FIRST_LINE_END,
+                        GridBagConstraints.NONE, Insets(0, 0, 0, 0), 0, 0))
+            }
+        }
+
+        fun focus() {
+            m_txt.requestFocus()
+        }
+
+        fun validateEdit(aValue: Any?, rowIndex: Int, columnIndex: Int) {
+            val table = m_table
+            val validationResult = m_model.validate(aValue, table.convertRowIndexToModel(rowIndex), table.convertColumnIndexToModel(columnIndex))
+            showValidationResult(validationResult)
+        }
+
+        private fun showValidationResult(result: Int) {
+            val severity = if (OK == result) {
+                MessageType.INFO
+            } else if (!isForbidden(result)) {
+                MessageType.WARNING
+            } else {
+                MessageType.ERROR
+            }
+            val borderColor = if (OK == result) JBColor.border() else severity.popupBackground
+            val msg = when (result) {
+                OK -> ""
+                DEFAULT_TRANSLATION_MISSING_ERROR -> message("default.text.mandatory")
+                DEFAULT_TRANSLATION_EMPTY_ERROR -> message("default.text.mandatory")
+                KEY_EMPTY_ERROR -> message("please.specify.key")
+                KEY_ALREADY_EXISTS_ERROR -> message("key.already.exists")
+                KEY_OVERRIDES_OTHER_STORE_WARNING -> message("key.would.override")
+                KEY_IS_OVERRIDDEN_BY_OTHER_STORE_WARNING -> message("key.would.be.overridden")
+                KEY_OVERRIDES_AND_IS_OVERRIDDEN_WARNING -> message("key.overrides.and.is.overridden")
+                else -> message("key.contains.invalid.chars")
+            }
+
+            if (Strings.isBlank(msg)) {
+                hideBalloon()
+            } else {
+                showBalloon(msg, m_txt, severity)
+            }
+            m_cellContentPanel.border = BorderFactory.createLineBorder(borderColor)
+        }
+
+        override fun getTableCellEditorComponent(table: JTable, value: Any?, isSelected: Boolean, row: Int, column: Int): Component {
+            m_txt.text = value.toString()
+            validateEdit(value, row, column)
+            return m_cellContentPanel
+        }
+
+        private fun createButton(action: AnAction): ActionButton {
+            val presentation = action.templatePresentation
+            val d = JBDimension(16, 16)
+            val button = object : ActionButton(action, presentation, ActionPlaces.UNKNOWN, d) {
+                override fun getDataContext(): DataContext {
+                    return DataManager.getInstance().getDataContext(this)
+                }
+            }
+            button.setLook(ActionButtonLook.INPLACE_LOOK)
+            button.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
+            button.updateIcon()
+            return button
+        }
+
+        override fun isCellEditable(e: EventObject?): Boolean {
+            // edit mode only on our own event from the key listener or on double click
+            return e == m_editStartEvent || (e is MouseEvent && e.clickCount > 1)
+        }
+
+        override fun getCellEditorValue(): Any? = m_txt.text
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsTableModel.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsTableModel.kt
new file mode 100644
index 0000000..0267398
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/NlsTableModel.kt
@@ -0,0 +1,211 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.editor
+
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.project.Project
+import org.eclipse.scout.sdk.core.log.SdkLog
+import org.eclipse.scout.sdk.core.s.nls.*
+import org.eclipse.scout.sdk.core.s.nls.TranslationValidator.*
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle
+import org.eclipse.scout.sdk.s2i.environment.IdeaEnvironment.Factory.callInIdeaEnvironment
+import java.util.Collections.singleton
+import java.util.function.Predicate
+import java.util.stream.Collectors.toList
+import java.util.stream.Stream
+import javax.swing.table.AbstractTableModel
+import kotlin.streams.toList
+
+class NlsTableModel(val stack: TranslationStoreStack, val project: Project) : AbstractTableModel() {
+
+    private var m_filter: Predicate<ITranslationEntry>? = null
+    private var m_translations: MutableList<ITranslationEntry>? = null
+    private var m_languages: MutableList<Language>? = null
+
+    companion object {
+        val KEY_COLUMN_HEADER_NAME = EclipseScoutBundle.message("key")
+        const val NUM_ADDITIONAL_COLUMNS = 1
+        const val KEY_COLUMN_INDEX = 0
+        const val DEFAULT_LANGUAGE_COLUMN_INDEX = 1
+    }
+
+    init {
+        stack.addListener(StackListener())
+        buildCache()
+    }
+
+    fun translations() = m_translations!!
+
+    fun languages() = m_languages!!
+
+    override fun getRowCount() = translations().size
+
+    override fun getColumnCount() = NUM_ADDITIONAL_COLUMNS + languages().size
+
+    fun setFilter(newFilter: Predicate<ITranslationEntry>?): Boolean {
+        m_filter = newFilter
+        return buildCache()
+    }
+
+    fun languageForColumn(columnIndex: Int) = languages()[columnIndex - NUM_ADDITIONAL_COLUMNS]
+
+    fun translationForRow(rowIndex: Int) = translations()[rowIndex]
+
+    fun rowForTranslation(translation: ITranslationEntry) = translations().indexOf(translation)
+
+    private fun acceptFilter(candidate: ITranslationEntry) = m_filter?.test(candidate) ?: true
+
+    private fun buildCache(forceReload: Boolean = false): Boolean {
+        val newTranslations = stack.allEntries().collect(toList())
+        val newLanguages = newTranslations.stream()
+                .filter { acceptFilter(it) }
+                .flatMap { it.store().languages() }
+                .distinct()
+                .sorted()
+                .collect(toList())
+
+        if (forceReload || m_translations == null || m_languages != newLanguages) {
+            m_translations = newTranslations
+            m_languages = newLanguages
+            fireTableStructureChanged()
+            return true
+        }
+        return false
+    }
+
+    private fun saveStack() = callInIdeaEnvironment(project, EclipseScoutBundle.message("saving.translations")) { env, progress ->
+        stack.flush(env, progress)
+    }
+
+    override fun getColumnName(column: Int): String {
+        if (KEY_COLUMN_INDEX == column) {
+            return KEY_COLUMN_HEADER_NAME
+        }
+        return languageForColumn(column).displayName()
+    }
+
+    override fun getColumnClass(c: Int) = String::class.java
+
+    override fun getValueAt(rowIndex: Int, columnIndex: Int): String {
+        val entry = translationForRow(rowIndex)
+        if (KEY_COLUMN_INDEX == columnIndex) {
+            return entry.key()
+        }
+        val lang = languageForColumn(columnIndex)
+        return entry.text(lang).orElse("")
+    }
+
+    override fun isCellEditable(rowIndex: Int, columnIndex: Int): Boolean {
+        val entry = translationForRow(rowIndex)
+        return entry.store().isEditable
+    }
+
+    override fun setValueAt(aValue: Any?, rowIndex: Int, columnIndex: Int) {
+        val validationResult = validate(aValue, rowIndex, columnIndex)
+        if (isForbidden(validationResult)) {
+            return
+        }
+
+        val text = aValue.toString()
+        val toUpdate = translationForRow(rowIndex)
+        if (KEY_COLUMN_INDEX == columnIndex) {
+            val newKey = text.trim()
+            if (newKey == toUpdate.key()) {
+                // no save necessary
+                return
+            }
+            stack.changeKey(toUpdate.key(), newKey)
+        } else {
+            val lang = languageForColumn(columnIndex)
+            val updated = Translation(toUpdate)
+            updated.putText(lang, text)
+            stack.updateTranslation(updated)
+        }
+    }
+
+    fun validate(aValue: Any?, rowIndex: Int, columnIndex: Int): Int {
+        if (columnIndex == KEY_COLUMN_INDEX) {
+            val selectedTranslation = translationForRow(rowIndex)
+            val key = aValue?.toString()?.trim()
+            return validateKey(stack, selectedTranslation.store(), key, singleton(selectedTranslation.key()))
+        }
+        if (columnIndex == DEFAULT_LANGUAGE_COLUMN_INDEX) {
+            return validateDefaultText(aValue?.toString())
+        }
+        return OK
+    }
+
+    private inner class StackListener : ITranslationStoreStackListener {
+
+        override fun stackChanged(events: Stream<TranslationStoreStackEvent>) {
+            val allEvents = events.toList()
+            val application = ApplicationManager.getApplication()
+            if (application.isDispatchThread) {
+                handleEvents(allEvents)
+            } else {
+                // Run in EDT. This is necessary e.g. for reload events which might come from a worker thread.
+                // Do not wait here for the events to be handled (deadlock)
+                ApplicationManager.getApplication().invokeLater {
+                    handleEvents(allEvents)
+                }
+            }
+        }
+
+        private fun handleEvents(events: List<TranslationStoreStackEvent>) {
+            val containsReloadEvent = events.map { it.type() }.any { it == TranslationStoreStackEvent.TYPE_RELOAD }
+            if (containsReloadEvent) {
+                buildCache(true)
+                return
+            }
+
+            events.forEach { handleEvent(it) }
+            val needsSave = events.map { it.type() }.any {
+                it == TranslationStoreStackEvent.TYPE_REMOVE_TRANSLATION
+                        || it == TranslationStoreStackEvent.TYPE_NEW_TRANSLATION
+                        || it == TranslationStoreStackEvent.TYPE_KEY_CHANGED
+                        || it == TranslationStoreStackEvent.TYPE_UPDATE_TRANSLATION
+                        || it == TranslationStoreStackEvent.TYPE_NEW_LANGUAGE
+            }
+            if (needsSave) {
+                SdkLog.debug("About to save translation store stack.")
+                saveStack()
+            }
+        }
+
+        private fun handleEvent(event: TranslationStoreStackEvent) {
+            when (event.type()) {
+                TranslationStoreStackEvent.TYPE_REMOVE_TRANSLATION -> translationsRemoved(event)
+                TranslationStoreStackEvent.TYPE_NEW_TRANSLATION -> translationsAdded(event)
+                TranslationStoreStackEvent.TYPE_KEY_CHANGED -> translationsUpdated(event)
+                TranslationStoreStackEvent.TYPE_UPDATE_TRANSLATION -> translationsUpdated(event)
+                TranslationStoreStackEvent.TYPE_NEW_LANGUAGE -> buildCache()
+            }
+        }
+
+        private fun translationsUpdated(event: TranslationStoreStackEvent) = event.entry().ifPresent {
+            val index = rowForTranslation(it)
+            fireTableRowsUpdated(index, index)
+        }
+
+        private fun translationsAdded(event: TranslationStoreStackEvent) = event.entry().ifPresent {
+            val translations = translations()
+            translations.add(it)
+            val index = translations.size - 1
+            fireTableRowsInserted(index, index)
+        }
+
+        private fun translationsRemoved(event: TranslationStoreStackEvent) = event.entry().ifPresent {
+            val index = rowForTranslation(it)
+            translations().removeAt(index)
+            fireTableRowsDeleted(index, index)
+        }
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/TranslationNewDialog.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/TranslationNewDialog.kt
new file mode 100644
index 0000000..9cd931a
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/editor/TranslationNewDialog.kt
@@ -0,0 +1,247 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.editor
+
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.DialogPanel
+import com.intellij.openapi.ui.DialogWrapper
+import com.intellij.openapi.ui.MessageType
+import com.intellij.openapi.ui.ValidationInfo
+import com.intellij.ui.DocumentAdapter
+import com.intellij.ui.JBColor
+import com.intellij.ui.components.*
+import org.eclipse.scout.sdk.core.s.nls.*
+import org.eclipse.scout.sdk.core.s.nls.TranslationValidator.*
+import org.eclipse.scout.sdk.core.util.CoreUtils
+import org.eclipse.scout.sdk.core.util.Strings
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
+import org.eclipse.scout.sdk.s2i.ui.IndexedFocusTraversalPolicy
+import org.eclipse.scout.sdk.s2i.ui.TextFieldWithMaxLen
+import java.awt.*
+import java.awt.event.KeyEvent
+import javax.swing.*
+import javax.swing.event.DocumentEvent
+
+class TranslationNewDialog(val project: Project, val store: ITranslationStore, val stack: TranslationStoreStack, val initialKey: String = "") : DialogWrapper(project, true, IdeModalityType.PROJECT) {
+
+    private val m_dimensionKey = "scout.nls.newTranslationDialog"
+    private val m_languageTextFields = HashMap<Language, JBTextArea>()
+    private var m_keyTextField: JBTextField? = null
+    private var m_copyToClipboardField: JBCheckBox? = null
+    private var m_errorStatusField: JBLabel? = null
+    private var m_createdTranslation: ITranslationEntry? = null
+
+    init {
+        title = message("create.new.translation.in.x", store.service().type().elementName())
+        init()
+    }
+
+    override fun createCenterPanel(): JComponent {
+        val rootPanel = DialogPanel(GridBagLayout()) // do not use applyCallbacks. the API is different in newer IJ versions
+        rootPanel.preferredSize = Dimension(600, 300)
+
+        // Key
+        rootPanel.add(JBLabel(NlsTableModel.KEY_COLUMN_HEADER_NAME), GridBagConstraints(0, 0, 1, 1, 0.0, 0.0, GridBagConstraints.LINE_START,
+                GridBagConstraints.NONE, Insets(4, 0, 0, 15), 0, 0))
+        val keyField = TextFieldWithMaxLen(maxLength = 200)
+        keyField.isFocusable = true
+        keyField.text = initialKey
+        m_keyTextField = keyField
+        rootPanel.add(keyField, GridBagConstraints(1, 0, 1, 1, 1.0, 0.0, GridBagConstraints.CENTER,
+                GridBagConstraints.HORIZONTAL, Insets(4, 0, 0, 15), 0, 0))
+
+        // Copy to clipboard
+        val copyToClipboardField = JBCheckBox(message("copy.key.to.clipboard"), false)
+        copyToClipboardField.toolTipText = message("copy.key.to.clipboard.desc")
+        copyToClipboardField.isFocusable = true
+        m_copyToClipboardField = copyToClipboardField
+        rootPanel.add(copyToClipboardField, GridBagConstraints(2, 0, 1, 1, 0.0, 0.0, GridBagConstraints.LINE_END,
+                GridBagConstraints.NONE, Insets(4, 0, 0, 0), 0, 0))
+
+        // Tab pane
+        val tabPane = createTabPane()
+        tabPane.isFocusable = true
+        rootPanel.add(tabPane, GridBagConstraints(0, 1, 3, 1, 1.0, 1.0, GridBagConstraints.PAGE_START,
+                GridBagConstraints.BOTH, Insets(10, 0, 0, 0), 0, 0))
+
+        // validation label
+        val statusLabel = JBLabel()
+        statusLabel.border = null
+        statusLabel.preferredSize = Dimension(600, 40)
+        statusLabel.verticalAlignment = JLabel.TOP
+        m_errorStatusField = statusLabel
+        rootPanel.add(statusLabel, GridBagConstraints(0, 2, 3, 1, 1.0, 0.0, GridBagConstraints.LINE_START,
+                GridBagConstraints.HORIZONTAL, Insets(5, 7, 0, 0), 0, 0))
+
+        isOKActionEnabled = false
+        installValidation()
+
+        defaultLanguageTextField().document.addDocumentListener(KeyAutoGenerator())
+
+        rootPanel.isFocusTraversalPolicyProvider = true
+        rootPanel.isFocusCycleRoot = true
+        val focusPolicy = IndexedFocusTraversalPolicy()
+        m_languageTextFields.values.forEach { focusPolicy.addComponent(it) }
+        focusPolicy.addComponent(keyTextField())
+        focusPolicy.addComponent(copyToClipboardField)
+        focusPolicy.addComponent(tabPane)
+        rootPanel.focusTraversalPolicy = focusPolicy
+
+        return rootPanel
+    }
+
+    private fun createTabPane(): JBTabbedPane {
+        val tabPane = JBTabbedPane(SwingConstants.TOP, JTabbedPane.SCROLL_TAB_LAYOUT)
+        store.languages().forEach {
+            val txt = JBTextArea()
+            txt.font = keyTextField().font
+            txt.margin = Insets(5, 7, 5, 5)
+            txt.setFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS, setOf(KeyStroke.getKeyStroke(KeyEvent.VK_TAB, 0)))
+            txt.setFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS, setOf(KeyStroke.getKeyStroke(KeyEvent.VK_TAB, KeyEvent.SHIFT_DOWN_MASK)))
+            m_languageTextFields[it] = txt
+
+            val panel = JBScrollPane(txt, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED)
+            panel.border = BorderFactory.createLineBorder(JBColor.border())
+
+            tabPane.addTab(it.displayName(), panel)
+        }
+        return tabPane
+    }
+
+    private fun installValidation() {
+        val triggerValidation = object : DocumentAdapter() {
+            override fun textChanged(e: DocumentEvent) {
+                setErrorStatus(listOfNotNull(validateKeyField(), validateDefaultTextField()))
+            }
+        }
+        keyTextField().document.addDocumentListener(triggerValidation)
+        m_languageTextFields.values.forEach { it.document.addDocumentListener(triggerValidation) }
+    }
+
+    private fun setErrorStatus(infos: List<ValidationInfo>) {
+        val field = errorStatusField()
+        field.text = ""
+        isOKActionEnabled = infos.all { it.okEnabled }
+        if (infos.isEmpty()) {
+            return
+        }
+
+        val builder = StringBuilder("<html>")
+        val iterator = infos.iterator()
+        var info = iterator.next()
+        builder.append(validationInfoToHtml(info))
+        while (iterator.hasNext()) {
+            info = iterator.next()
+            builder.append("<br>").append(validationInfoToHtml(info))
+        }
+        builder.append("</html>")
+        field.text = builder.toString()
+    }
+
+    private fun validationInfoToHtml(info: ValidationInfo): String {
+        val color = htmlColorString(if (info.warning) MessageType.WARNING.borderColor else ERROR_FOREGROUND_COLOR)
+        val message = Strings.escapeHtml(info.message)
+        return "<font color=\"${color}\">$message</font>"
+    }
+
+    fun htmlColorString(color: Color): String? {
+        val red = Integer.toHexString(color.red)
+        val green = Integer.toHexString(color.green)
+        val blue = Integer.toHexString(color.blue)
+        return "#" +
+                (if (red.length == 1) "0$red" else red) +
+                (if (green.length == 1) "0$green" else green) +
+                if (blue.length == 1) "0$blue" else blue
+    }
+
+    private fun validateKeyField(): ValidationInfo? {
+        val key = keyTextField().text ?: ""
+        return toValidationInfo(validateKey(stack, store, key))
+    }
+
+    private fun validateDefaultTextField(): ValidationInfo? {
+        val defaultText = defaultLanguageTextField().text
+        return toValidationInfo(validateDefaultText(defaultText))
+    }
+
+    private fun toValidationInfo(errorCode: Int): ValidationInfo? {
+        val defaultLangMissing = ValidationInfo(message("please.provide.text.for.lang.x", Language.LANGUAGE_DEFAULT.displayName()))
+        val invalidKey = ValidationInfo(message("please.specify.translation.key.with.desc"))
+        val info = when (errorCode) {
+            OK -> null
+            DEFAULT_TRANSLATION_MISSING_ERROR -> defaultLangMissing
+            DEFAULT_TRANSLATION_EMPTY_ERROR -> defaultLangMissing
+            KEY_EMPTY_ERROR -> invalidKey
+            KEY_ALREADY_EXISTS_ERROR -> ValidationInfo(message("key.already.exists.in.service"))
+            KEY_OVERRIDES_OTHER_STORE_WARNING -> ValidationInfo(message("key.would.override.desc"))
+            KEY_IS_OVERRIDDEN_BY_OTHER_STORE_WARNING -> ValidationInfo(message("key.would.be.overridden.desc"))
+            KEY_OVERRIDES_AND_IS_OVERRIDDEN_WARNING -> ValidationInfo(message("key.overrides.and.is.overridden.desc"))
+            else -> invalidKey
+        }
+        if (info == null || isForbidden(errorCode)) {
+            return info
+        }
+        return info.asWarning().withOKEnabled()
+    }
+
+    fun defaultLanguageTextField() = m_languageTextFields[Language.LANGUAGE_DEFAULT]!!
+
+    fun keyTextField() = m_keyTextField!!
+
+    fun errorStatusField() = m_errorStatusField!!
+
+    fun createdTranslation() = m_createdTranslation
+
+    override fun doOKAction() {
+        if (!okAction.isEnabled) {
+            return
+        }
+        doOk()
+        close(OK_EXIT_CODE)
+    }
+
+    private fun doOk() {
+        val key = keyTextField().text
+        val newTranslation = Translation(key)
+        m_languageTextFields.entries
+                .filter { Strings.hasText(it.value.text) }
+                .forEach { newTranslation.putText(it.key, it.value.text) }
+        m_createdTranslation = stack.addNewTranslation(newTranslation, store)
+
+        if (m_copyToClipboardField?.isSelected == true) {
+            CoreUtils.setTextToClipboard(key)
+        }
+    }
+
+    override fun getPreferredFocusedComponent(): JComponent? {
+        return defaultLanguageTextField()
+    }
+
+    override fun getDimensionServiceKey() = m_dimensionKey
+
+    private inner class KeyAutoGenerator : DocumentAdapter() {
+        private val m_defaultLangTextField = defaultLanguageTextField()
+        private var m_lastDefaultLangText: String? = null
+
+        override fun textChanged(e: DocumentEvent) {
+            val defaultLangText = m_defaultLangTextField.text
+            if (Strings.isBlank(defaultLangText)) {
+                return
+            }
+            val oldKey = stack.generateNewKey(m_lastDefaultLangText)
+            val curKey = Strings.notBlank(keyTextField().text).orElse("")
+            if (curKey == oldKey) {
+                keyTextField().text = stack.generateNewKey(defaultLangText)
+            }
+            m_lastDefaultLangText = defaultLangText
+        }
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/inspection/AddMissingTranslationQuickFix.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/inspection/AddMissingTranslationQuickFix.kt
new file mode 100644
index 0000000..e8efd8a
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/inspection/AddMissingTranslationQuickFix.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.nls.inspection
+
+import com.intellij.codeInspection.LocalQuickFix
+import com.intellij.codeInspection.ProblemDescriptor
+import com.intellij.icons.AllIcons
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.fileEditor.FileEditorManager
+import com.intellij.openapi.fileEditor.TextEditor
+import com.intellij.openapi.module.Module
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.popup.JBPopupFactory
+import com.intellij.openapi.ui.popup.PopupStep
+import com.intellij.openapi.ui.popup.util.BaseListPopupStep
+import com.intellij.psi.PsiFile
+import org.eclipse.scout.sdk.core.log.SdkLog
+import org.eclipse.scout.sdk.core.s.nls.ITranslationStore
+import org.eclipse.scout.sdk.core.s.nls.TranslationStoreStack
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
+import org.eclipse.scout.sdk.s2i.containingModule
+import org.eclipse.scout.sdk.s2i.environment.IdeaEnvironment.Factory.callInIdeaEnvironment
+import org.eclipse.scout.sdk.s2i.nls.TranslationStoreStackLoader
+import org.eclipse.scout.sdk.s2i.nls.editor.TranslationNewDialog
+import java.util.stream.Collectors.toList
+
+class AddMissingTranslationQuickFix(val key: CharSequence) : LocalQuickFix {
+
+    val quickFixName = message("add.missing.translation")
+
+    override fun getFamilyName(): String = quickFixName
+
+    override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
+        val module = descriptor.psiElement.containingModule() ?: return
+        val psiFile = descriptor.psiElement.containingFile
+        TranslationStoreStackLoader.createModalLoader(module, null, message("searching.text.provider.services"))
+                .withStackCreatedHandler { ApplicationManager.getApplication().invokeLater { showStoreChooser(module, psiFile, it?.stack) } }
+                .queue()
+    }
+
+    private fun showStoreChooser(module: Module, psiFile: PsiFile, stack: TranslationStoreStack?) {
+        val stores = stack?.allEditableStores()?.collect(toList())
+        if (stores == null || stores.isEmpty()) {
+            SdkLog.warning("Cannot create missing translation because no editable text provider service could be found in module '{}'.", module.name)
+            return
+        }
+
+        val project = module.project
+        if (stores.size == 1) {
+            openDialog(project, stores[0], stack)
+            return
+        }
+
+        val fileEditor = FileEditorManager.getInstance(project).getSelectedEditor(psiFile.virtualFile)
+        val editor = if (fileEditor is TextEditor) fileEditor.editor else null
+        val popup = JBPopupFactory.getInstance().createListPopup(TranslationStorePopupStep(project, stack, stores), 10)
+        if (editor != null) {
+            popup.showInBestPositionFor(editor)
+        } else {
+            popup.showCenteredInCurrentWindow(project)
+        }
+    }
+
+    private fun openDialog(project: Project, store: ITranslationStore, stack: TranslationStoreStack) {
+        val dialog = TranslationNewDialog(project, store, stack, stack.generateNewKey(key.toString()))
+        val ok = dialog.showAndGet()
+        if (ok) {
+            callInIdeaEnvironment(project, message("store.new.translation")) { env, progress -> stack.flush(env, progress) }
+            // no need to call DaemonCodeAnalyzer.getInstance(project).restart() after flush because it is triggered automatically
+        }
+    }
+
+    private inner class TranslationStorePopupStep(val project: Project, val stack: TranslationStoreStack, val stores: MutableList<ITranslationStore>) :
+            BaseListPopupStep<ITranslationStore>(message("create.new.translation.in"), stores, AllIcons.Nodes.Services) {
+
+        init {
+            defaultOptionIndex = 0
+        }
+
+        override fun getTextFor(value: ITranslationStore): String = value.service().type().name()
+
+        override fun onChosen(selectedValue: ITranslationStore, finalChoice: Boolean): PopupStep<*>? = doFinalStep {
+            openDialog(project, selectedValue, stack)
+        }
+
+        override fun isSpeedSearchEnabled() = stores.size > 3
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/MissingTranslationInspection.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/inspection/MissingTranslationInspection.kt
similarity index 87%
rename from org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/MissingTranslationInspection.kt
rename to org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/inspection/MissingTranslationInspection.kt
index f652839..8c83385 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/MissingTranslationInspection.kt
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/nls/inspection/MissingTranslationInspection.kt
@@ -8,11 +8,10 @@
  * Contributors:
  *     BSI Business Systems Integration AG - initial API and implementation
  */
-package org.eclipse.scout.sdk.s2i.nls
+package org.eclipse.scout.sdk.s2i.nls.inspection
 
 import com.intellij.codeInspection.*
 import com.intellij.openapi.module.Module
-import com.intellij.openapi.module.ModuleUtil
 import com.intellij.openapi.progress.ProgressManager
 import com.intellij.openapi.project.Project
 import com.intellij.psi.PsiFile
@@ -22,11 +21,12 @@
 import org.eclipse.scout.sdk.core.s.util.search.FileQueryInput
 import org.eclipse.scout.sdk.core.s.util.search.FileQueryMatch
 import org.eclipse.scout.sdk.core.s.util.search.IFileQuery
+import org.eclipse.scout.sdk.s2i.EclipseScoutBundle.Companion.message
 import org.eclipse.scout.sdk.s2i.containingModule
 import org.eclipse.scout.sdk.s2i.environment.IdeaEnvironment
+import org.eclipse.scout.sdk.s2i.moduleDirPath
 import org.eclipse.scout.sdk.s2i.toNioPath
 import org.eclipse.scout.sdk.s2i.toScoutProgress
-import java.nio.file.Paths
 import java.util.concurrent.ConcurrentHashMap
 import java.util.logging.Level
 
@@ -59,8 +59,7 @@
     fun checkFile(file: PsiFile, module: Module, query: IFileQuery, manager: InspectionManager, isOnTheFly: Boolean, environment: IdeaEnvironment, progress: IProgress): Array<ProblemDescriptor> {
         val start = System.currentTimeMillis()
         val path = file.virtualFile.toNioPath()
-        val modulePath = Paths.get(ModuleUtil.getModuleDirPath(module))
-        val queryInput = FileQueryInput(path, modulePath) { file.textToCharArray() }
+        val queryInput = FileQueryInput(path, module.moduleDirPath()) { file.textToCharArray() }
 
         query.searchIn(queryInput, environment, progress)
 
@@ -77,10 +76,11 @@
             val element = file.findElementAt(range.start()) ?: return@computeInReadAction null
             val type = julLevelToProblemHighlightType(range.severity())
             val msg = when (range.severity()) {
-                Level.INFO.intValue() -> "Possibly missing translation key. Check manually and suppress if valid."
-                else -> "Missing translation for key '${range.text()}'"
+                Level.INFO.intValue() -> message("possibly.missing.translation")
+                else -> message("missing.translation.for.key.x", range.text())
             }
-            return@computeInReadAction manager.createProblemDescriptor(element, msg, isOnTheFly, LocalQuickFix.EMPTY_ARRAY, type)
+            val fixes = if (isOnTheFly) arrayOf(AddMissingTranslationQuickFix(range.text())) else LocalQuickFix.EMPTY_ARRAY
+            return@computeInReadAction manager.createProblemDescriptor(element, msg, isOnTheFly, fixes, type)
         }
     }
 
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/ui/IndexedFocusTraversalPolicy.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/ui/IndexedFocusTraversalPolicy.kt
new file mode 100644
index 0000000..65bcba3
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/ui/IndexedFocusTraversalPolicy.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.ui
+
+import java.awt.Component
+import java.awt.Container
+import java.awt.FocusTraversalPolicy
+import java.util.*
+
+/**
+ * A [FocusTraversalPolicy] that allows to apply a tab index to a component.
+ */
+open class IndexedFocusTraversalPolicy : FocusTraversalPolicy() {
+
+    private val m_components = TreeMap<Int, Component>()
+
+    fun addComponent(component: Component) {
+        val highest = if (m_components.isEmpty()) -1 else m_components.lastKey()
+        addComponent(component, highest + 1)
+    }
+
+    fun addComponent(component: Component, position: Int) {
+        m_components[position] = component
+    }
+
+    override fun getComponentAfter(aContainer: Container?, aComponent: Component?): Component? = advanceToNextVisibleComponent(aComponent, true)
+
+    override fun getComponentBefore(aContainer: Container?, aComponent: Component?): Component? = advanceToNextVisibleComponent(aComponent, false)
+
+    private fun advanceToNextVisibleComponent(aComponent: Component?, forward: Boolean): Component? {
+        val start = m_components.entries
+                .filter { it.value == aComponent }
+                .map { it.key }.firstOrNull() ?: return null
+        var candidate: Component?
+        var position: Int = start
+        do {
+            var entry = if (forward) m_components.higherEntry(position) else m_components.lowerEntry(position)
+            if (entry == null) {
+                entry = if (forward) m_components.firstEntry() else m_components.lastEntry()
+            }
+            candidate = entry?.value
+            position = entry.key
+        } while (candidate != null && candidate != aComponent && (!candidate.isShowing || !candidate.isEnabled))
+        return candidate
+    }
+
+    override fun getDefaultComponent(aContainer: Container?): Component? = getFirstComponent(aContainer)
+
+    override fun getLastComponent(aContainer: Container?): Component? = m_components.lastEntry().value
+
+    override fun getFirstComponent(aContainer: Container?): Component? = m_components.firstEntry().value
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/ui/TablePreservingSelection.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/ui/TablePreservingSelection.kt
new file mode 100644
index 0000000..27f85ac
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/ui/TablePreservingSelection.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.ui
+
+import com.intellij.ui.table.JBTable
+import java.util.*
+import javax.swing.JViewport
+import javax.swing.RowSorter
+import javax.swing.SortOrder
+import javax.swing.event.TableModelEvent
+import javax.swing.table.TableColumn
+import javax.swing.table.TableModel
+
+/**
+ * A [JBTable] which tries to preserve selection and sort order when the table data or structure changes.
+ */
+open class TablePreservingSelection(model: TableModel, private val indexToRowMapper: (Int) -> Any, private val rowToIndexMapper: (Any) -> Int) : JBTable(model) {
+
+    private var m_selectionListenerArmed = true
+    private val m_selectedRows = ArrayList<Any>()
+    var tableColumnsChangedCallback: ((TableModelEvent?) -> Unit)? = null
+    var tableChangedCallback: ((TableModelEvent?) -> Unit)? = null
+
+    init {
+        selectionModel.addListSelectionListener { saveSelection() }
+    }
+
+    override fun tableChanged(e: TableModelEvent?) {
+        @Suppress("SENSELESS_COMPARISON")
+        if (m_selectedRows != null) { // may be null during constructor execution
+            val isTableDataChanged = e?.lastRow == Int.MAX_VALUE
+            val isTableStructureChanged = e == null || e.firstRow == TableModelEvent.HEADER_ROW
+            if (isTableStructureChanged || isTableDataChanged) {
+                runPreservingSelectionAndSorting {
+                    super.tableChanged(e)
+                    tableColumnsChangedCallback?.invoke(e)
+                }
+                return
+            }
+        }
+
+        super.tableChanged(e)
+        tableChangedCallback?.invoke(e)
+    }
+
+    private fun runPreservingSelectionAndSorting(runnable: () -> Unit) {
+        // backup column selection
+        val selectedHeaders = selectedColumns.map { columnModel.getColumn(it).headerValue }
+
+        // backup sort keys and order
+        val sorting = rowSorter.sortKeys.associate { columnModel.getColumn(it.column).headerValue to it.sortOrder }
+
+        m_selectionListenerArmed = false
+        try {
+            runnable.invoke()
+        } finally {
+            m_selectionListenerArmed = true
+        }
+
+        // restore sorting before the selection otherwise the view-model-index map in the JTable is wrong
+        restoreSorting(sorting)
+
+        // compute new indices
+        val newRowIndices = m_selectedRows
+                .map { rowToIndexMapper.invoke(it) }
+                .filter { it >= 0 }
+                .map { convertRowIndexToView(it) }
+        val newColIndices = selectedHeaders
+                .mapNotNull { findColumnWithHeaderValue(it) }
+                .map { it.modelIndex }
+                .map { convertColumnIndexToView(it) }
+
+        restoreSelection(newRowIndices, newColIndices)
+        revealSelection(newRowIndices, newColIndices)
+    }
+
+    fun scrollToSelection() {
+        revealSelection(selectedRows.toList(), selectedColumns.toList())
+    }
+
+    private fun revealSelection(rowIndices: List<Int>, colIndices: List<Int>) {
+        if (rowIndices.isEmpty() || colIndices.isEmpty()) {
+            return // nothing to reveal
+        }
+
+        val viewport = parent as JViewport
+        val rect = getCellRect(rowIndices[0], colIndices[0], true)
+        val viewRect = viewport.viewRect
+
+        rect.setLocation(rect.x - viewRect.x, rect.y - viewRect.y)
+        var centerX = (viewRect.width - rect.width) / 2
+        var centerY = (viewRect.height - rect.height) / 2
+        if (rect.x < centerX) {
+            centerX = -centerX
+        }
+        if (rect.y < centerY) {
+            centerY = -centerY
+        }
+        rect.translate(centerX, centerY)
+        viewport.scrollRectToVisible(rect)
+    }
+
+    private fun restoreSorting(sorting: Map<Any, SortOrder>) {
+        rowSorter.sortKeys = sorting.entries
+                .associate { findColumnWithHeaderValue(it.key) to it.value }
+                .filter { it.key != null }
+                .map { RowSorter.SortKey(it.key!!.modelIndex, it.value) }
+    }
+
+    private fun restoreSelection(rowIndices: List<Int>, colIndices: List<Int>) {
+        colIndices.forEach { addColumnSelectionInterval(it, it) }
+        rowIndices.forEach { addRowSelectionInterval(it, it) }
+    }
+
+    private fun findColumnWithHeaderValue(headerValue: Any): TableColumn? {
+        for (i in 0 until columnCount) {
+            val column = columnModel.getColumn(i)
+            if (column.headerValue == headerValue) {
+                return column
+            }
+        }
+        return null
+    }
+
+    private fun saveSelection() {
+        if (!m_selectionListenerArmed) {
+            return
+        }
+        m_selectedRows.clear()
+        m_selectedRows.addAll(
+                selectedRows
+                        .map { convertRowIndexToModel(it) }
+                        .map { indexToRowMapper.invoke(it) })
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/ui/TextAreaWithContentSize.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/ui/TextAreaWithContentSize.kt
new file mode 100644
index 0000000..596b0ca
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/ui/TextAreaWithContentSize.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.ui
+
+import com.intellij.ui.components.JBTextArea
+import java.awt.Dimension
+import java.awt.Font
+import kotlin.math.max
+
+/**
+ * A [JBTextArea] which calculates its preferred size based on the text content:
+ *
+ * Width: If the textarea is empty, the default width otherwise the with of the longest line
+ *
+ * Height: The number of lines
+ */
+open class TextAreaWithContentSize(font: Font, text: String = "") : JBTextArea(text) {
+
+    init {
+        this.font = font
+    }
+
+    override fun getPreferredSize(): Dimension {
+        val metrics = getFontMetrics(font)
+        val content = text
+        val lines = content.split("\n")
+        val inset = insets
+        val width: Int
+        if (content.isEmpty()) {
+            // default width if empty
+            width = super.getPreferredSize().width
+        } else {
+            var maxTextWidth = 0
+            for (line in lines) {
+                maxTextWidth = max(maxTextWidth, metrics.stringWidth(line))
+            }
+            width = maxTextWidth + inset.left + inset.right
+        }
+
+        return Dimension(width, lines.size * rowHeight + inset.top + inset.bottom)
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/ui/TextFieldWithMaxLen.kt b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/ui/TextFieldWithMaxLen.kt
new file mode 100644
index 0000000..9e1ca30
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/kotlin/org/eclipse/scout/sdk/s2i/ui/TextFieldWithMaxLen.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     BSI Business Systems Integration AG - initial API and implementation
+ */
+package org.eclipse.scout.sdk.s2i.ui
+
+import com.intellij.ui.components.JBTextField
+import org.eclipse.scout.sdk.core.util.FinalValue
+import javax.swing.text.AbstractDocument
+import javax.swing.text.AttributeSet
+import javax.swing.text.DocumentFilter
+
+/**
+ * A [JBTextField] that allows to specify a max length
+ */
+class TextFieldWithMaxLen(text: String = "", columns: Int = 0, maxLength: Int = UNLIMITED) : JBTextField(text, columns) {
+
+    companion object {
+        const val UNLIMITED = Int.MAX_VALUE
+    }
+
+    init {
+        setMaxLength(maxLength)
+    }
+
+    fun setMaxLength(maxLength: Int) = withDocument {
+        if (maxLength > 0) {
+            it.documentFilter = MaxLengthDocumentFilter(maxLength)
+        } else {
+            it.documentFilter = null
+        }
+    }
+
+    @Suppress("unused")
+    fun getMaxLength(): Int {
+        val length = FinalValue<Int>()
+        withDocument {
+            val filter = it.documentFilter
+            if (filter is MaxLengthDocumentFilter) {
+                length.set(filter.maxLength)
+            }
+        }
+        return length.opt().orElse(UNLIMITED)
+    }
+
+    private fun withDocument(runnable: (AbstractDocument) -> Unit) {
+        val myDocument = document
+        if (myDocument is AbstractDocument) {
+            return runnable.invoke(myDocument)
+        }
+    }
+
+    class MaxLengthDocumentFilter(val maxLength: Int) : DocumentFilter() {
+        override fun insertString(fb: FilterBypass, offset: Int, textToInsert: String, attr: AttributeSet?) {
+            replace(fb, offset, 0, textToInsert, attr)
+        }
+
+        override fun replace(fb: FilterBypass, offset: Int, lengthOfTextToDelete: Int, textToInsert: String, attrs: AttributeSet?) {
+            val currentLength = fb.document.length
+            val overLimit = currentLength - lengthOfTextToDelete + textToInsert.length - maxLength
+            var limitedTextToInsert = textToInsert
+            if (overLimit > 0) {
+                limitedTextToInsert = textToInsert.substring(0, textToInsert.length - overLimit)
+            }
+            super.replace(fb, offset, lengthOfTextToDelete, limitedTextToInsert, attrs)
+        }
+    }
+}
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/resources/META-INF/plugin.xml b/org.eclipse.scout.sdk.s2i/src/main/resources/META-INF/plugin.xml
index f689426..f134a29 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/resources/META-INF/plugin.xml
+++ b/org.eclipse.scout.sdk.s2i/src/main/resources/META-INF/plugin.xml
@@ -20,18 +20,24 @@
       Eclipse Scout integration for IntelliJ IDEA
       </p>
       <p>
-      Includes support for:
+      Includes:
         <br>
         <ul>
-          <li>Derived resource update for FormData & PageData</li>
-          <li>@ClassId validation inspections</li>
-          <li>NLS validation inspections</li>
+          <li>Derived resource update for FormData and PageData</li>
+          <li>@ClassId validation inspections (missing, duplicates)</li>
+          <li>Editor for Scout nls files</li>
+          <li>Inspection for missing translations</li>
+          <li>Code completion for Scout nls keys in Java, JavaScript and HTML</li>
         </ul>
         </p>
   ]]></description>
 
   <change-notes><![CDATA[
-        Includes fixes and performance improvements
+        <ul>
+          <li>Adds an editor for Scout nls files.</li>
+          <li>Adds a quick-fix for the 'Missing translation' inspection.</li>
+          <li>Adds code completion for Scout nls keys in Java, JavaScript and HTML.</li>
+        </ul>
   ]]></change-notes>
 
   <!-- please see http://www.jetbrains.org/intellij/sdk/docs/basics/getting_started/build_number_ranges.html for description -->
@@ -43,6 +49,7 @@
   <depends>org.jetbrains.idea.maven</depends>
   <depends>com.intellij.copyright</depends>
   <depends>com.intellij.properties</depends>
+  <depends optional="true" config-file="withJavaScript.xml">JavaScript</depends>
 
   <extensions defaultExtensionNs="com.intellij">
     <postStartupActivity implementation="org.eclipse.scout.sdk.s2i.IdeaLogger"/>
@@ -51,35 +58,25 @@
     <postStartupActivity implementation="org.eclipse.scout.sdk.s2i.IdeaMavenRunner"/>
     <postStartupActivity implementation="org.eclipse.scout.sdk.s2i.classid.AutoCreateClassIdStartup"/>
     <postStartupActivity implementation="org.eclipse.scout.sdk.s2i.EclipseScoutBundle"/>
-
     <projectService serviceInterface="org.eclipse.scout.sdk.s2i.derived.DerivedResourceManager"
                     serviceImplementation="org.eclipse.scout.sdk.s2i.derived.impl.DerivedResourceManagerImplementor"/>
     <projectService serviceInterface="org.eclipse.scout.sdk.s2i.classid.ClassIdCache"
                     serviceImplementation="org.eclipse.scout.sdk.s2i.classid.ClassIdCacheImplementor"/>
-
     <projectConfigurable groupId="language" displayName="Scout" id="preferences.ScoutSettings"
                          instance="org.eclipse.scout.sdk.s2i.settings.ScoutSettings"/>
     <macro implementation="org.eclipse.scout.sdk.s2i.IdeaHomePathMacro"/>
-    <localInspection language="JAVA"
-                     shortName="MissingClassId"
-                     groupName="Scout"
-                     enabledByDefault="false"
-                     level="ERROR"
-                     key="missing.classid.annotation"
+    <localInspection language="JAVA" shortName="MissingClassId" groupName="Scout" enabledByDefault="false" level="ERROR" key="missing.classid.annotation"
                      implementationClass="org.eclipse.scout.sdk.s2i.classid.MissingClassIdInspection"/>
-    <localInspection language="JAVA"
-                     shortName="DuplicateClassId"
-                     groupName="Scout"
-                     enabledByDefault="false"
-                     level="ERROR"
-                     key="duplicate.classid.inspection.displayName"
+    <localInspection language="JAVA" shortName="DuplicateClassId" groupName="Scout" enabledByDefault="false" level="ERROR" key="duplicate.classid.inspection.displayName"
                      implementationClass="org.eclipse.scout.sdk.s2i.classid.DuplicateClassIdInspection"/>
-    <localInspection shortName="MissingTranslation"
-                     groupName="Scout"
-                     enabledByDefault="true"
-                     level="WARNING"
-                     key="missing.translation.inspection.displayName"
-                     implementationClass="org.eclipse.scout.sdk.s2i.nls.MissingTranslationInspection"/>
+    <localInspection shortName="MissingTranslation" groupName="Scout" enabledByDefault="true" level="WARNING" key="missing.translation.inspection.displayName"
+                     implementationClass="org.eclipse.scout.sdk.s2i.nls.inspection.MissingTranslationInspection"/>
+    <fileType name="Scout National Language Support" extensions="nls" fieldName="INSTANCE" implementationClass="org.eclipse.scout.sdk.s2i.nls.NlsFileType"/>
+    <fileEditorProvider implementation="org.eclipse.scout.sdk.s2i.nls.editor.NlsFileEditorProvider"/>
+    <completion.contributor language="JAVA" implementationClass="org.eclipse.scout.sdk.s2i.nls.completion.NlsCompletionContributorForJava" id="scoutNlsCompletionJava" order="before propertiesCompletion"/>
+    <completion.contributor language="HTML" implementationClass="org.eclipse.scout.sdk.s2i.nls.completion.NlsCompletionContributorForHtml" id="scoutNlsCompletionHtml" order="before html"/>
+    <lang.documentationProvider language="JAVA" implementationClass="org.eclipse.scout.sdk.s2i.nls.doc.NlsDocumentationProviderForJava" id="scoutNlsKeyDocumentationJava" order="first"/>
+    <lang.documentationProvider language="HTML" implementationClass="org.eclipse.scout.sdk.s2i.nls.doc.NlsDocumentationProviderForHtml" id="scoutNlsKeyDocumentationHtml" order="first"/>
   </extensions>
 
   <actions>
diff --git a/org.eclipse.scout.sdk.s2i/src/main/resources/META-INF/withJavaScript.xml b/org.eclipse.scout.sdk.s2i/src/main/resources/META-INF/withJavaScript.xml
new file mode 100644
index 0000000..7e90f92
--- /dev/null
+++ b/org.eclipse.scout.sdk.s2i/src/main/resources/META-INF/withJavaScript.xml
@@ -0,0 +1,16 @@
+<!--
+  ~ Copyright (c) 2010-2020 BSI Business Systems Integration AG.
+  ~ All rights reserved. This program and the accompanying materials
+  ~ are made available under the terms of the Eclipse Public License v1.0
+  ~ which accompanies this distribution, and is available at
+  ~ http://www.eclipse.org/legal/epl-v10.html
+  ~
+  ~ Contributors:
+  ~     BSI Business Systems Integration AG - initial API and implementation
+  -->
+<idea-plugin allow-bundled-update="true">
+  <extensions defaultExtensionNs="com.intellij">
+    <completion.contributor language="JavaScript" implementationClass="org.eclipse.scout.sdk.s2i.nls.completion.NlsCompletionContributorForJs" id="scoutNlsCompletionJs"/>
+    <lang.documentationProvider language="JavaScript" implementationClass="org.eclipse.scout.sdk.s2i.nls.doc.NlsDocumentationProviderForJs" id="scoutNlsKeyDocumentationJs" order="first"/>
+  </extensions>
+</idea-plugin>
\ No newline at end of file
diff --git a/org.eclipse.scout.sdk.s2i/src/main/resources/inspectionDescriptions/DuplicateClassId.html b/org.eclipse.scout.sdk.s2i/src/main/resources/inspectionDescriptions/DuplicateClassId.html
index 43499ac..22c8c09 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/resources/inspectionDescriptions/DuplicateClassId.html
+++ b/org.eclipse.scout.sdk.s2i/src/main/resources/inspectionDescriptions/DuplicateClassId.html
@@ -8,7 +8,7 @@
   ~ Contributors:
   ~     BSI Business Systems Integration AG - initial API and implementation
   -->
-<html>
+<html lang="en">
 <body>
 This inspection reports duplicate @ClassId values.
 </body>
diff --git a/org.eclipse.scout.sdk.s2i/src/main/resources/inspectionDescriptions/MissingClassId.html b/org.eclipse.scout.sdk.s2i/src/main/resources/inspectionDescriptions/MissingClassId.html
index c04758a..7786ab6 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/resources/inspectionDescriptions/MissingClassId.html
+++ b/org.eclipse.scout.sdk.s2i/src/main/resources/inspectionDescriptions/MissingClassId.html
@@ -8,7 +8,7 @@
   ~ Contributors:
   ~     BSI Business Systems Integration AG - initial API and implementation
   -->
-<html>
+<html lang="en">
 <body>
 This inspection verifies that every Java class that supports Scout ClassIds (which is every type implementing
 ITypeWithClassId) actually has a @ClassId annotation.
diff --git a/org.eclipse.scout.sdk.s2i/src/main/resources/inspectionDescriptions/MissingTranslation.html b/org.eclipse.scout.sdk.s2i/src/main/resources/inspectionDescriptions/MissingTranslation.html
index 818084e..f2893ef 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/resources/inspectionDescriptions/MissingTranslation.html
+++ b/org.eclipse.scout.sdk.s2i/src/main/resources/inspectionDescriptions/MissingTranslation.html
@@ -8,7 +8,7 @@
   ~ Contributors:
   ~     BSI Business Systems Integration AG - initial API and implementation
   -->
-<html>
+<html lang="en">
 <body>
 This inspection reports code which refers to not existing translation keys.<br/><br/>
 Missing means that the key is neither part of a Scout TextProviderService on the Java classpath of the module nor is it registered in a Scout UiTextContributor to be accessible in the JavaScript code.
diff --git a/org.eclipse.scout.sdk.s2i/src/main/resources/messages/EclipseScoutBundle.properties b/org.eclipse.scout.sdk.s2i/src/main/resources/messages/EclipseScoutBundle.properties
index d5a2f1d..55e5eba 100644
--- a/org.eclipse.scout.sdk.s2i/src/main/resources/messages/EclipseScoutBundle.properties
+++ b/org.eclipse.scout.sdk.s2i/src/main/resources/messages/EclipseScoutBundle.properties
@@ -14,8 +14,67 @@
 update.with.new.classid=Update with new @ClassId Value
 missing.classid.annotation=Missing @ClassId annotation
 add.missing.classid.annotation=Add missing @ClassId Annotation
+add.missing.translation=Add missing translation
 duplicate.classid.inspection.displayName=Duplicate @ClassId values
 update.derived.resources=Update derived resources
 update.derived.resources.desc=Updates derived resources in scope.
 select.scope.to.update=Select Scope to Update
-missing.translation.inspection.displayName=Missing translation
\ No newline at end of file
+missing.translation.inspection.displayName=Missing translation
+idea.home.path.macro.desc=Installation Path of the running IntelliJ IDEA instance.
+unnamed.task.x=Unnamed Task: {0}
+nls.file.desc=Scout National Language Support
+create.new.language=Create new language
+loading.translations=Loading translations
+no.translations.found=No translations found.
+export.table.to.clipboard=Export table to clipboard
+table.content.copied.to.clipboard=<html>Table content has been copied to the clipboard.<br>You can paste it to a spreadsheet (like e.g. Microsoft Excel).</html>
+import.translations.from.clipboard=Import translations from clipboard
+clipboard.no.text.content=The clipboard does not contain text content. Did you copy text data?
+clipboard.no.valid.content=The clipboard does not contain valid data. Did you copy tabular data?
+clipboard.content.no.mapping=<html>The table data of the clipboard could not be mapped to translations.<br>Did you copy translations including a header row having at least a key and default language column?</html>
+import.successful.x.rows=Successfully imported {0} rows.
+import.duplicate.keys=The import data contains duplicate keys: {0}. Only the last occurrence for each key as been imported.
+column.x=column {0}
+import.columns.not.mapped=Some columns could not be mapped to a valid language. The data of these columns has been ignored: {0}.
+import.rows.invalid=The following rows contain invalid translations and have been skipped: {0}. Ensure each row contains a valid key and a default translation.
+jump.to.text.service=Jump to text provider service
+jump.to.property=Jump to property
+add.new.language=Add a new language
+hide.inherited.rows=Hide inherited rows
+hide.inherited.rows.desc=Activate to only show translations of the text provider service ''{0}''.
+hide.readonly.rows=Hide read-only rows
+hide.readonly.rows.desc=Activate to hide read-only translations from binary dependencies.\nSuch translations are available to be used in the application but cannot be modified.
+reload.from.filesystem=Reload from file system
+remove.selected.rows=Remove selected rows
+jump.to.declaration=Jump to declaration
+create.new.translation=Create new translation
+create.new.translation.in=Create new translation in
+default.text.mandatory=The default text must be set.
+please.specify.key=Please specify a key.
+key.already.exists=The key already exists.
+key.would.override=This key would override an existing key of an inherited service.
+key.would.be.overridden=This key would be overridden by another service.
+key.overrides.and.is.overridden=This key overrides another key and is itself overridden by a key.
+key.contains.invalid.chars=The key contains invalid characters.
+insert.new.line.x=Insert new line ({0})
+saving.translations=Saving translations
+copy.key.to.clipboard=Copy key to clipboard
+copy.key.to.clipboard.desc=Stores the key in the system clipboard when the dialog is closed.
+please.provide.text.for.lang.x=Please provide text for the ''{0}'' language. All other languages may be empty.
+please.specify.translation.key.with.desc=Please specify a translation key. All alpha-numeric and the following special characters are allowed: underline (_), dot (.) and hyphen (-).
+key.already.exists.in.service=This key already exists in this service. Please choose another value.
+key.would.override.desc=This key is already used in an inherited text service. If created, it will override the existing one.
+key.would.be.overridden.desc=This key is already used in another service with lower order. If created, it will be overridden by the existing one.
+key.overrides.and.is.overridden.desc=This key is already used in services with lower and higher orders. If created, it will override the one with higher order but will itself be overridden by the other one.
+create.new.translation.in.x=Create New Translation in {0}
+searching.text.provider.services=Searching text provider services...
+store.new.translation=Store new translation
+possibly.missing.translation=Possibly missing translation key. Check manually and suppress if valid.
+missing.translation.for.key.x=Missing translation for key ''{0}''
+key=Key
+write.cu.x=Write compilation unit {0}
+write.file.x=Write file {0}
+load.text.service=Load text provider service
+search.text.services=Search text provider services
+load.properties.content=Load properties file contents
+starting.commit.transaction.x=Starting to commit transaction. Number of members: {0}
\ No newline at end of file