diff --git a/tools/i18n-plugin/.classpath b/tools/i18n-plugin/.classpath
new file mode 100644
index 00000000000..0fb79cfe69b
--- /dev/null
+++ b/tools/i18n-plugin/.classpath
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/i18n-plugin/.project b/tools/i18n-plugin/.project
new file mode 100644
index 00000000000..5cae2ad7208
--- /dev/null
+++ b/tools/i18n-plugin/.project
@@ -0,0 +1,23 @@
+
+
+ i18n-maven-plugin
+
+
+
+
+
+ org.eclipse.jdt.core.javabuilder
+
+
+
+
+ org.eclipse.m2e.core.maven2Builder
+
+
+
+
+
+ org.eclipse.jdt.core.javanature
+ org.eclipse.m2e.core.maven2Nature
+
+
diff --git a/tools/i18n-plugin/pom.xml b/tools/i18n-plugin/pom.xml
new file mode 100644
index 00000000000..43cc1a1e9e2
--- /dev/null
+++ b/tools/i18n-plugin/pom.xml
@@ -0,0 +1,111 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.core.tools
+ org.openhab.core.reactor.tools
+ 3.2.0-SNAPSHOT
+
+
+ i18n-maven-plugin
+
+ maven-plugin
+
+ Internationalization Maven Plugin
+ Generates translations files
+
+
+ 3.6.0
+ 3.6.0
+ 3.6.0
+ 3.6.0
+ 3.8.1
+
+
+
+
+ com.thoughtworks.xstream
+ xstream
+ 1.4.18
+
+
+ org.apache.maven
+ maven-plugin-api
+ ${maven.plugin.api.version}
+
+
+ org.apache.maven.plugin-tools
+ maven-plugin-annotations
+ ${maven.plugin.annotations.version}
+ provided
+
+
+ org.apache.maven.plugins
+ maven-plugin-plugin
+ ${maven.plugin.plugin.version}
+
+
+ org.eclipse.jdt
+ org.eclipse.jdt.annotation
+ 2.2.100
+ provided
+
+
+ org.openhab.core.bundles
+ org.openhab.core.binding.xml
+ ${project.version}
+
+
+ org.openhab.core.bundles
+ org.openhab.core.config.xml
+ ${project.version}
+
+
+ org.openhab.core.bundles
+ org.openhab.core.thing.xml
+ ${project.version}
+
+
+ org.openhab.core.bom
+ org.openhab.core.bom.test
+ ${project.version}
+ pom
+ test
+
+
+ org.openhab.core.bom
+ org.openhab.core.bom.test-index
+ ${project.version}
+ pom
+ test
+
+
+
+
+
+
+ maven-plugin-plugin
+ ${maven.plugin.plugin.version}
+
+
+ default-addPluginArtifactMetadata
+
+ addPluginArtifactMetadata
+
+ package
+
+
+ default-descriptor
+
+ descriptor
+
+ process-classes
+
+
+
+
+
+
diff --git a/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/AbstractI18nMojo.java b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/AbstractI18nMojo.java
new file mode 100644
index 00000000000..b902dd48dad
--- /dev/null
+++ b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/AbstractI18nMojo.java
@@ -0,0 +1,50 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import java.io.File;
+import java.io.IOException;
+
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Base class for internationalization mojos using openHAB XML information.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public abstract class AbstractI18nMojo extends AbstractMojo {
+
+ /**
+ * The directory containing the bundle openHAB information
+ */
+ @Parameter(property = "i18n.ohinf.dir", defaultValue = "${project.basedir}/src/main/resources/OH-INF")
+ protected @NonNullByDefault({}) File ohinfDirectory;
+
+ protected BundleInfo bundleInfo = new BundleInfo();
+
+ protected boolean ohinfExists() {
+ return ohinfDirectory.exists();
+ }
+
+ protected void readAddonInfo() throws IOException {
+ BundleInfoReader bundleInfoReader = new BundleInfoReader(getLog());
+ bundleInfo = bundleInfoReader.readBundleInfo(ohinfDirectory.toPath());
+ }
+
+ void setOhinfDirectory(File ohinfDirectory) {
+ this.ohinfDirectory = ohinfDirectory;
+ }
+}
diff --git a/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/BundleInfo.java b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/BundleInfo.java
new file mode 100644
index 00000000000..facfc506ac5
--- /dev/null
+++ b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/BundleInfo.java
@@ -0,0 +1,95 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.binding.xml.internal.BindingInfoXmlResult;
+import org.openhab.core.config.core.ConfigDescription;
+import org.openhab.core.thing.xml.internal.ChannelGroupTypeXmlResult;
+import org.openhab.core.thing.xml.internal.ChannelTypeXmlResult;
+import org.openhab.core.thing.xml.internal.ThingTypeXmlResult;
+
+/**
+ * The bundle information provided by the openHAB XML files in the OH-INF
directory.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class BundleInfo {
+
+ private @Nullable BindingInfoXmlResult bindingInfoXml;
+ private List configDescriptions = new ArrayList<>(5);
+ private List channelGroupTypesXml = new ArrayList<>(5);
+ private List channelTypesXml = new ArrayList<>(5);
+ private List thingTypesXml = new ArrayList<>(5);
+
+ public @Nullable BindingInfoXmlResult getBindingInfoXml() {
+ return bindingInfoXml;
+ }
+
+ public void setBindingInfoXml(BindingInfoXmlResult bindingInfo) {
+ this.bindingInfoXml = bindingInfo;
+ }
+
+ public List getConfigDescriptions() {
+ return configDescriptions;
+ }
+
+ public void setConfigDescriptions(List configDescriptions) {
+ this.configDescriptions = configDescriptions;
+ }
+
+ public List getChannelGroupTypesXml() {
+ return channelGroupTypesXml;
+ }
+
+ public void setChannelGroupTypesXml(List channelGroupTypesXml) {
+ this.channelGroupTypesXml = channelGroupTypesXml;
+ }
+
+ public List getChannelTypesXml() {
+ return channelTypesXml;
+ }
+
+ public void setChannelTypesXml(List channelTypesXml) {
+ this.channelTypesXml = channelTypesXml;
+ }
+
+ public List getThingTypesXml() {
+ return thingTypesXml;
+ }
+
+ public void setThingTypesXml(List thingTypesXml) {
+ this.thingTypesXml = thingTypesXml;
+ }
+
+ public String getBindingId() {
+ BindingInfoXmlResult localBindingInfoXml = bindingInfoXml;
+ return localBindingInfoXml == null ? "" : localBindingInfoXml.getBindingInfo().getUID();
+ }
+
+ public Optional getConfigDescription(@Nullable URI uri) {
+ if (uri == null) {
+ return Optional.empty();
+ }
+
+ return configDescriptions.stream().filter(configDescription -> configDescription.getUID().equals(uri))
+ .findFirst();
+ }
+}
diff --git a/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/BundleInfoReader.java b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/BundleInfoReader.java
new file mode 100644
index 00000000000..c50e67a2266
--- /dev/null
+++ b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/BundleInfoReader.java
@@ -0,0 +1,117 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.apache.maven.plugin.logging.Log;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.binding.xml.internal.BindingInfoReader;
+import org.openhab.core.binding.xml.internal.BindingInfoXmlResult;
+import org.openhab.core.config.core.ConfigDescription;
+import org.openhab.core.config.xml.internal.ConfigDescriptionReader;
+import org.openhab.core.thing.xml.internal.ChannelGroupTypeXmlResult;
+import org.openhab.core.thing.xml.internal.ChannelTypeXmlResult;
+import org.openhab.core.thing.xml.internal.ThingDescriptionReader;
+import org.openhab.core.thing.xml.internal.ThingTypeXmlResult;
+
+import com.thoughtworks.xstream.converters.ConversionException;
+
+/**
+ * Reads all the bundle information provided by XML files in the OH-INF
directory to {@link BundleInfo}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class BundleInfoReader {
+
+ private final Log log;
+
+ public BundleInfoReader(Log log) {
+ this.log = log;
+ }
+
+ public BundleInfo readBundleInfo(Path ohinfPath) throws IOException {
+ BundleInfo bundleInfo = new BundleInfo();
+ readBindingInfo(ohinfPath, bundleInfo);
+ readConfigInfo(ohinfPath, bundleInfo);
+ readThingInfo(ohinfPath, bundleInfo);
+ return bundleInfo;
+ }
+
+ private Stream xmlPathStream(Path ohinfPath, String directory) throws IOException {
+ Path path = ohinfPath.resolve(directory);
+ return Files.exists(path)
+ ? Files.find(path, Integer.MAX_VALUE, (filePath, fileAttr) -> fileAttr.isRegularFile())
+ : Stream.of();
+ }
+
+ private void readBindingInfo(Path ohinfPath, BundleInfo bundleInfo) throws IOException {
+ BindingInfoReader reader = new BindingInfoReader();
+ xmlPathStream(ohinfPath, "binding").forEach(path -> {
+ log.info("Reading: " + path);
+ try {
+ BindingInfoXmlResult bindingInfoXml = reader.readFromXML(path.toUri().toURL());
+ if (bindingInfoXml != null) {
+ bundleInfo.setBindingInfoXml(bindingInfoXml);
+ }
+ } catch (ConversionException | MalformedURLException e) {
+ log.warn("Exception while reading binding info from: " + path, e);
+ }
+ });
+ }
+
+ private void readConfigInfo(Path ohinfPath, BundleInfo bundleInfo) throws IOException {
+ ConfigDescriptionReader reader = new ConfigDescriptionReader();
+ xmlPathStream(ohinfPath, "config").forEach(path -> {
+ log.info("Reading: " + path);
+ try {
+ List configDescriptions = reader.readFromXML(path.toUri().toURL());
+ if (configDescriptions != null) {
+ bundleInfo.getConfigDescriptions().addAll(configDescriptions);
+ }
+ } catch (ConversionException | MalformedURLException e) {
+ log.warn("Exception while reading config info from: " + path, e);
+ }
+ });
+ }
+
+ private void readThingInfo(Path ohinfPath, BundleInfo bundleInfo) throws IOException {
+ ThingDescriptionReader reader = new ThingDescriptionReader();
+ xmlPathStream(ohinfPath, "thing").forEach(path -> {
+ log.info("Reading: " + path);
+ try {
+ List> types = reader.readFromXML(path.toUri().toURL());
+ if (types == null) {
+ return;
+ }
+ for (Object type : types) {
+ if (type instanceof ThingTypeXmlResult) {
+ bundleInfo.getThingTypesXml().add((ThingTypeXmlResult) type);
+ } else if (type instanceof ChannelGroupTypeXmlResult) {
+ bundleInfo.getChannelGroupTypesXml().add((ChannelGroupTypeXmlResult) type);
+ } else if (type instanceof ChannelTypeXmlResult) {
+ bundleInfo.getChannelTypesXml().add((ChannelTypeXmlResult) type);
+ }
+ }
+ } catch (ConversionException | MalformedURLException e) {
+ log.warn("Exception while reading thing info from: " + path, e);
+ }
+ });
+ }
+}
diff --git a/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/DefaultTranslationsGenerationMode.java b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/DefaultTranslationsGenerationMode.java
new file mode 100644
index 00000000000..515530e7c2f
--- /dev/null
+++ b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/DefaultTranslationsGenerationMode.java
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Enumerates all the different modes for generating default translations.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public enum DefaultTranslationsGenerationMode {
+ /**
+ * Creates XML based default translations files only when these do not yet exist.
+ */
+ ADD_MISSING_FILES,
+
+ /**
+ * Same as {@link #ADD_MISSING_FILES} but also adds missing translations to existing default translations files.
+ */
+ ADD_MISSING_TRANSLATIONS,
+
+ /**
+ * Removes existing default translation files and regenerates them based on the XML based texts only.
+ */
+ REGENERATE_FILES
+}
diff --git a/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/GenerateDefaultTranslationsMojo.java b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/GenerateDefaultTranslationsMojo.java
new file mode 100644
index 00000000000..b31ce09ea2e
--- /dev/null
+++ b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/GenerateDefaultTranslationsMojo.java
@@ -0,0 +1,148 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import static java.nio.file.StandardOpenOption.*;
+import static org.openhab.core.tools.i18n.plugin.DefaultTranslationsGenerationMode.*;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.config.core.ConfigDescription;
+
+/**
+ * Generates the default translations properties file for a bundle based on the XML files in the OH-INF
+ * directory.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+@Mojo(name = "generate-default-translations", threadSafe = true)
+public class GenerateDefaultTranslationsMojo extends AbstractI18nMojo {
+
+ private static final Set ADDON_TYPES = Set.of("automation", "binding", "io", "persistence", "transform",
+ "voice");
+
+ /**
+ * The directory where the properties files will be generated
+ */
+ @Parameter(property = "i18n.target.dir", defaultValue = "${project.basedir}/src/main/resources/OH-INF/i18n")
+ private @NonNullByDefault({}) File targetDirectory;
+
+ @Parameter(property = "i18n.generation.mode", defaultValue = "ADD_MISSING_TRANSLATIONS")
+ private DefaultTranslationsGenerationMode generationMode = ADD_MISSING_TRANSLATIONS;
+
+ @Override
+ public void execute() throws MojoFailureException {
+ try {
+ if (ohinfExists()) {
+ readAddonInfo();
+
+ Path defaultTranslationsPath = ohinfDirectory.toPath()
+ .resolve(Path.of("i18n", propertiesFileName(bundleInfo)));
+
+ if (Files.exists(defaultTranslationsPath)) {
+ if (generationMode == ADD_MISSING_FILES) {
+ getLog().info("Skipped: " + defaultTranslationsPath);
+ return;
+ } else if (generationMode == REGENERATE_FILES) {
+ try {
+ Files.delete(defaultTranslationsPath);
+ getLog().info("Deleted: " + defaultTranslationsPath);
+ } catch (IOException e) {
+ throw new MojoFailureException(
+ "Failed to delete existing default translations: " + defaultTranslationsPath, e);
+ }
+ }
+ }
+
+ String translationsString = generateDefaultTranslations(defaultTranslationsPath);
+ if (!translationsString.isBlank()) {
+ writeDefaultTranslations(translationsString);
+ }
+ }
+ } catch (IOException e) {
+ throw new MojoFailureException("Failed to read OH-INF XML files", e);
+ }
+ }
+
+ private String propertiesFileName(BundleInfo bundleInfo) {
+ String name = bundleInfo.getBindingId();
+ if (name.isEmpty()) {
+ Optional optional = bundleInfo.getConfigDescriptions().stream().findFirst();
+ if (optional.isPresent()) {
+ ConfigDescription configDescription = optional.get();
+ String[] uid = configDescription.getUID().toString().split(":");
+ if (uid.length > 2 && ADDON_TYPES.contains(uid[1])) {
+ name = uid[2].toLowerCase();
+ } else {
+ name = uid[1].toLowerCase();
+ }
+ }
+ }
+
+ if (name.isBlank()) {
+ name = "unknown";
+ }
+
+ return name + ".properties";
+ }
+
+ protected String generateDefaultTranslations(Path defaultTranslationsPath) {
+ XmlToTranslationsConverter xmlConverter = new XmlToTranslationsConverter();
+ Translations generatedTranslations = xmlConverter.convert(bundleInfo);
+
+ PropertiesToTranslationsConverter propertiesConverter = new PropertiesToTranslationsConverter(getLog());
+ Translations existingTranslations = propertiesConverter.convert(defaultTranslationsPath);
+
+ TranslationsMerger translationsMerger = new TranslationsMerger();
+ translationsMerger.merge(generatedTranslations, existingTranslations);
+
+ return generatedTranslations.linesStream().collect(Collectors.joining(System.lineSeparator()));
+ }
+
+ private void writeDefaultTranslations(String translationsString) throws MojoFailureException {
+ Path translationsPath = targetDirectory.toPath().resolve(propertiesFileName(bundleInfo));
+
+ try {
+ Files.createDirectories(translationsPath.getParent());
+ } catch (IOException e) {
+ throw new MojoFailureException(
+ "Failed to create translations target directory: " + translationsPath.getParent(), e);
+ }
+
+ try {
+ getLog().info("Writing: " + translationsPath);
+ Files.writeString(translationsPath, translationsString, CREATE, TRUNCATE_EXISTING, WRITE);
+ } catch (IOException e) {
+ throw new MojoFailureException("Failed to write generated translations to: " + translationsPath, e);
+ }
+ }
+
+ void setTargetDirectory(File targetDirectory) {
+ this.targetDirectory = targetDirectory;
+ }
+
+ void setGenerationMode(DefaultTranslationsGenerationMode generationMode) {
+ this.generationMode = generationMode;
+ }
+}
diff --git a/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/PropertiesToTranslationsConverter.java b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/PropertiesToTranslationsConverter.java
new file mode 100644
index 00000000000..986b18c7afc
--- /dev/null
+++ b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/PropertiesToTranslationsConverter.java
@@ -0,0 +1,131 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import static org.openhab.core.tools.i18n.plugin.Translations.TranslationsEntry.entry;
+import static org.openhab.core.tools.i18n.plugin.Translations.TranslationsGroup.group;
+import static org.openhab.core.tools.i18n.plugin.Translations.TranslationsSection.section;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Stream;
+import java.util.stream.Stream.Builder;
+
+import org.apache.maven.plugin.logging.Log;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.tools.i18n.plugin.Translations.TranslationsEntry;
+import org.openhab.core.tools.i18n.plugin.Translations.TranslationsGroup;
+import org.openhab.core.tools.i18n.plugin.Translations.TranslationsSection;
+
+/**
+ * Converts the translation key/value pairs of properties files to {@link Translations}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class PropertiesToTranslationsConverter {
+
+ private static final String HASH = "#";
+
+ private final Log log;
+
+ public PropertiesToTranslationsConverter(Log log) {
+ this.log = log;
+ }
+
+ public Translations convert(Path propertiesPath) {
+ if (!Files.exists(propertiesPath)) {
+ log.debug("Properties file '" + propertiesPath + "' does not exist");
+ return Translations.translations();
+ }
+
+ List lines;
+ try {
+ lines = Files.readAllLines(propertiesPath);
+ } catch (IOException e) {
+ log.warn("Exception while converting properties to translations: " + e.getMessage());
+ return Translations.translations();
+ }
+
+ Builder sectionsBuilder = Stream.builder();
+ Builder groupsBuilder = null;
+ Builder entriesBuilder = null;
+
+ boolean appendHeader = false;
+
+ String header = "";
+
+ for (String line : lines) {
+ line = line.trim();
+ if (HASH.equals(line)) {
+ line = "";
+ }
+
+ if (line.startsWith(HASH)) {
+ if (!appendHeader) {
+ if (groupsBuilder != null) {
+ sectionsBuilder.add(section(header, groupsBuilder.build()));
+ }
+ header = "";
+ groupsBuilder = Stream.builder();
+ }
+
+ if (line.length() > 1) {
+ if (!header.isEmpty()) {
+ header += System.lineSeparator();
+ }
+ header += line.substring(1).trim().toLowerCase();
+ }
+ appendHeader = true;
+ continue;
+ }
+
+ appendHeader = false;
+
+ if (!line.isBlank()) {
+ int index = line.indexOf("=");
+ if (index == -1) {
+ log.warn("Ignoring invalid translation key/value pair: " + line);
+ } else {
+ if (entriesBuilder == null) {
+ entriesBuilder = Stream.builder();
+ }
+ String key = line.substring(0, index).trim();
+ String value = line.substring(index + 1).trim();
+ entriesBuilder.add(entry(key, value));
+ }
+ } else if (entriesBuilder != null) {
+ if (groupsBuilder == null) {
+ groupsBuilder = Stream.builder();
+ }
+ groupsBuilder.add(group(entriesBuilder.build()));
+ entriesBuilder = null;
+ }
+ }
+
+ if (entriesBuilder != null) {
+ if (groupsBuilder == null) {
+ groupsBuilder = Stream.builder();
+ }
+ groupsBuilder.add(group(entriesBuilder.build()));
+ }
+
+ if (groupsBuilder != null) {
+ sectionsBuilder.add(section(header, groupsBuilder.build()));
+ }
+
+ return Translations.translations(sectionsBuilder.build());
+ }
+}
diff --git a/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/Translations.java b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/Translations.java
new file mode 100644
index 00000000000..0792c8fa140
--- /dev/null
+++ b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/Translations.java
@@ -0,0 +1,207 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import java.util.stream.Stream.Builder;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link Translations} of a bundle consisting of {@link TranslationsSection}s having {@link TranslationsGroup}s of
+ * {@link TranslationsEntry}s (key/value pairs).
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class Translations {
+
+ public static class TranslationsEntry {
+ public final String key;
+ final String value;
+
+ public TranslationsEntry(String key, String value) {
+ this.key = key;
+ this.value = value;
+ }
+
+ public boolean hasTranslation() {
+ return !value.isBlank() && !value.startsWith("@text/");
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ public static TranslationsEntry entry(String key, @Nullable String value) {
+ return new TranslationsEntry(key, value == null ? "" : value);
+ }
+ }
+
+ public static class TranslationsGroup implements Comparable {
+ final List entries;
+
+ public TranslationsGroup(List entries) {
+ this.entries = entries;
+ }
+
+ @Override
+ public int compareTo(TranslationsGroup other) {
+ if (entries.isEmpty()) {
+ return -1;
+ } else if (other.entries.isEmpty()) {
+ return 1;
+ }
+
+ return entries.get(0).getKey().compareTo(other.entries.get(0).getKey());
+ }
+
+ public boolean hasTranslations() {
+ return !entries.isEmpty() && entries.stream().anyMatch(TranslationsEntry::hasTranslation);
+ }
+
+ public Stream keysStream() {
+ return entries.stream() //
+ .filter(TranslationsEntry::hasTranslation) //
+ .map(TranslationsEntry::getKey);
+ }
+
+ public Stream linesStream() {
+ return entries.stream() //
+ .filter(TranslationsEntry::hasTranslation) //
+ .map(entry -> String.format("%s = %s", entry.key, entry.value));
+ }
+
+ public void removeEntries(Predicate super TranslationsEntry> filter) {
+ entries.removeIf(filter);
+ }
+
+ public static TranslationsGroup group(Stream entries) {
+ return new TranslationsGroup(entries.collect(Collectors.toList()));
+ }
+
+ public static TranslationsGroup group(TranslationsEntry... entries) {
+ return group(Arrays.stream(entries));
+ }
+ }
+
+ public static class TranslationsSection {
+ final String header;
+ final List groups;
+
+ public TranslationsSection(List groups) {
+ this("", groups);
+ }
+
+ public TranslationsSection(String header, List groups) {
+ this.header = header;
+ this.groups = groups;
+ }
+
+ public boolean hasTranslations() {
+ return groups.stream().anyMatch(TranslationsGroup::hasTranslations);
+ }
+
+ public Stream keysStream() {
+ return groups.stream() //
+ .map(TranslationsGroup::keysStream) //
+ .flatMap(Function.identity());
+ }
+
+ public Stream linesStream() {
+ Builder builder = Stream.builder();
+ if (!header.isBlank()) {
+ Arrays.stream(header.split(System.lineSeparator())) //
+ .map(line -> "# " + line) //
+ .forEach(builder::add);
+ builder.add("");
+ }
+ groups.stream() //
+ .filter(TranslationsGroup::hasTranslations) //
+ .map(TranslationsGroup::linesStream) //
+ .flatMap(Function.identity()) //
+ .forEach(builder::add);
+ builder.add("");
+ return builder.build();
+ }
+
+ public void removeEntries(Predicate super TranslationsEntry> filter) {
+ groups.forEach(group -> group.removeEntries(filter));
+ }
+
+ public static TranslationsSection section(Stream groups) {
+ return section("", groups);
+ }
+
+ public static TranslationsSection section(String header, Stream groups) {
+ return new TranslationsSection(header, groups.sorted().collect(Collectors.toList()));
+ }
+
+ public static TranslationsSection section(TranslationsGroup... groups) {
+ return section("", groups);
+ }
+
+ public static TranslationsSection section(String header, TranslationsGroup... groups) {
+ return section(header, Arrays.stream(groups));
+ }
+ }
+
+ final List sections;
+
+ public Translations(List sections) {
+ this.sections = sections;
+ }
+
+ boolean hasTranslations() {
+ return sections.stream().anyMatch(TranslationsSection::hasTranslations);
+ }
+
+ public void addSection(TranslationsSection section) {
+ sections.add(section);
+ }
+
+ public Stream keysStream() {
+ return sections.stream() //
+ .map(TranslationsSection::keysStream) //
+ .flatMap(Function.identity());
+ }
+
+ public Stream linesStream() {
+ return sections.stream() //
+ .filter(TranslationsSection::hasTranslations) //
+ .map(TranslationsSection::linesStream) //
+ .flatMap(Function.identity());
+ }
+
+ public void removeEntries(Predicate super TranslationsEntry> filter) {
+ sections.forEach(section -> section.removeEntries(filter));
+ }
+
+ static Translations translations(Stream sections) {
+ return new Translations(sections.collect(Collectors.toList()));
+ }
+
+ static Translations translations(TranslationsSection... sections) {
+ return translations(Arrays.stream(sections));
+ }
+}
diff --git a/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/TranslationsMerger.java b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/TranslationsMerger.java
new file mode 100644
index 00000000000..aa7d10a5e85
--- /dev/null
+++ b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/TranslationsMerger.java
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.tools.i18n.plugin.Translations.TranslationsSection;
+
+/**
+ * Merges multiple {@link Translations} into one.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class TranslationsMerger {
+
+ /**
+ * Adds any missing translations from missingTranslations
to mainTranslations
.
+ */
+ public void merge(Translations mainTranslations, Translations missingTranslations) {
+ Set mainEntryKeys = mainTranslations.keysStream().collect(Collectors.toSet());
+ missingTranslations.removeEntries(entry -> mainEntryKeys.contains(entry.key));
+ missingTranslations.sections.stream() //
+ .filter(TranslationsSection::hasTranslations) //
+ .forEach(mainTranslations::addSection);
+ }
+}
diff --git a/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/XmlToTranslationsConverter.java b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/XmlToTranslationsConverter.java
new file mode 100644
index 00000000000..305082b3bf8
--- /dev/null
+++ b/tools/i18n-plugin/src/main/java/org/openhab/core/tools/i18n/plugin/XmlToTranslationsConverter.java
@@ -0,0 +1,361 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import static org.openhab.core.tools.i18n.plugin.Translations.TranslationsEntry.entry;
+import static org.openhab.core.tools.i18n.plugin.Translations.TranslationsGroup.group;
+import static org.openhab.core.tools.i18n.plugin.Translations.TranslationsSection.section;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Stream;
+import java.util.stream.Stream.Builder;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.binding.BindingInfo;
+import org.openhab.core.binding.xml.internal.BindingInfoXmlResult;
+import org.openhab.core.config.core.ConfigDescription;
+import org.openhab.core.config.core.ConfigDescriptionParameter;
+import org.openhab.core.config.core.ConfigDescriptionParameterGroup;
+import org.openhab.core.thing.type.ChannelDefinition;
+import org.openhab.core.thing.type.ChannelGroupDefinition;
+import org.openhab.core.thing.type.ChannelType;
+import org.openhab.core.thing.type.ThingType;
+import org.openhab.core.thing.xml.internal.ChannelGroupTypeXmlResult;
+import org.openhab.core.thing.xml.internal.ChannelTypeXmlResult;
+import org.openhab.core.thing.xml.internal.ThingTypeXmlResult;
+import org.openhab.core.tools.i18n.plugin.Translations.TranslationsEntry;
+import org.openhab.core.tools.i18n.plugin.Translations.TranslationsGroup;
+import org.openhab.core.tools.i18n.plugin.Translations.TranslationsSection;
+import org.openhab.core.types.CommandDescription;
+import org.openhab.core.types.StateDescription;
+
+/**
+ * Converts XML based {@link BundleInfo} to {@link Translations}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class XmlToTranslationsConverter {
+
+ public Translations convert(BundleInfo bundleInfo) {
+ BindingInfoXmlResult bindingInfoXml = bundleInfo.getBindingInfoXml();
+ return bindingInfoXml == null ? configTranslations(bundleInfo) : bindingTranslations(bundleInfo);
+ }
+
+ private Translations bindingTranslations(BundleInfo bundleInfo) {
+ return Translations.translations( //
+ bindingSection(bundleInfo), //
+ bindingConfigSection(bundleInfo), //
+ thingTypesSection(bundleInfo), //
+ thingTypesConfigSection(bundleInfo), //
+ channelGroupTypesSection(bundleInfo), //
+ channelTypesSection(bundleInfo), //
+ channelTypesConfigSection(bundleInfo));
+ }
+
+ private Translations configTranslations(BundleInfo bundleInfo) {
+ Builder groupsBuilder = Stream.builder();
+
+ bundleInfo.getConfigDescriptions().stream().map(configDescription -> {
+ Object[] uid = configDescription.getUID().toString().split(":");
+ String configKeyPrefix = String.format("%s.config" + ".%s".repeat(uid.length - 1), uid);
+ Builder streamBuilder = Stream.builder();
+ configDescriptionGroupParameters(configKeyPrefix, configDescription.getParameterGroups())
+ .forEach(streamBuilder::add);
+ configDescriptionParameters(configKeyPrefix, configDescription.getParameters()).forEach(streamBuilder::add);
+ return streamBuilder.build();
+ }).reduce(Stream::concat).orElseGet(Stream::empty).forEach(groupsBuilder::add);
+
+ return Translations.translations(section(groupsBuilder.build()));
+ }
+
+ private TranslationsSection bindingSection(BundleInfo bundleInfo) {
+ String header = "binding";
+ BindingInfoXmlResult bindingInfoXml = bundleInfo.getBindingInfoXml();
+ if (bindingInfoXml == null) {
+ return section(header);
+ }
+
+ BindingInfo bindingInfo = bindingInfoXml.getBindingInfo();
+ String bindingId = bundleInfo.getBindingId();
+
+ String keyPrefix = String.format("binding.%s", bindingId);
+
+ return section(header, group( //
+ entry(String.format("%s.name", keyPrefix), bindingInfo.getName()),
+ entry(String.format("%s.description", keyPrefix), bindingInfo.getDescription()) //
+ ));
+ }
+
+ private TranslationsSection bindingConfigSection(BundleInfo bundleInfo) {
+ String header = "binding config";
+ BindingInfoXmlResult bindingInfoXml = bundleInfo.getBindingInfoXml();
+ if (bindingInfoXml == null) {
+ return section(header);
+ }
+ ConfigDescription bindingConfig = bindingInfoXml.getConfigDescription();
+ if (bindingConfig == null) {
+ return section(header);
+ }
+
+ String keyPrefix = String.format("binding.config.%s", bundleInfo.getBindingId());
+ return section(header,
+ Stream.concat(configDescriptionGroupParameters(keyPrefix, bindingConfig.getParameterGroups()),
+ configDescriptionParameters(keyPrefix, bindingConfig.getParameters())));
+ }
+
+ private TranslationsSection thingTypesSection(BundleInfo bundleInfo) {
+ String header = "thing types";
+ List thingTypesXml = bundleInfo.getThingTypesXml();
+ if (thingTypesXml.isEmpty()) {
+ return section(header);
+ }
+
+ String bindingId = bundleInfo.getBindingId();
+ String keyPrefix = String.format("thing-type.%s", bindingId);
+
+ return section(header, thingTypesXml.stream().map(thingTypeXml -> {
+ ThingType thingType = thingTypeXml.toThingType();
+ String thingId = thingType.getUID().getId();
+
+ Builder entriesBuilder = Stream.builder();
+ entriesBuilder.add(entry(String.format("%s.%s.label", keyPrefix, thingId), thingType.getLabel()));
+ entriesBuilder
+ .add(entry(String.format("%s.%s.description", keyPrefix, thingId), thingType.getDescription()));
+
+ thingType.getChannelDefinitions().stream() //
+ .sorted(Comparator.comparing(ChannelDefinition::getId)) //
+ .forEach(channelDefinition -> {
+ String label = channelDefinition.getLabel();
+ if (label != null) {
+ entriesBuilder.add(entry(String.format("%s.%s.channel.%s.label", keyPrefix, thingId,
+ channelDefinition.getId()), label));
+ }
+
+ String description = channelDefinition.getDescription();
+ if (description != null) {
+ entriesBuilder.add(entry(String.format("%s.%s.channel.%s.description", keyPrefix, thingId,
+ channelDefinition.getId()), description));
+ }
+ });
+
+ thingType.getChannelGroupDefinitions().stream() //
+ .sorted(Comparator.comparing(ChannelGroupDefinition::getId)) //
+ .forEach(channelGroupDefinition -> {
+ String label = channelGroupDefinition.getLabel();
+ if (label != null) {
+ entriesBuilder.add(entry(String.format("%s.%s.group.%s.label", keyPrefix, thingId,
+ channelGroupDefinition.getId()), label));
+ }
+
+ String description = channelGroupDefinition.getDescription();
+ if (description != null) {
+ entriesBuilder.add(entry(String.format("%s.%s.group.%s.description", keyPrefix, thingId,
+ channelGroupDefinition.getId()), description));
+ }
+ });
+
+ return group(entriesBuilder.build());
+ }));
+ }
+
+ private TranslationsSection thingTypesConfigSection(BundleInfo bundleInfo) {
+ String header = "thing types config";
+ List thingTypesXml = bundleInfo.getThingTypesXml();
+ if (thingTypesXml.isEmpty()) {
+ return section(header);
+ }
+
+ Stream configDescriptionStream = Stream.concat( //
+ thingTypesXml.stream() //
+ .map(ThingTypeXmlResult::getConfigDescription) //
+ .filter(Objects::nonNull),
+ thingTypesXml.stream() //
+ .map(ThingTypeXmlResult::toThingType) //
+ .map(ThingType::getConfigDescriptionURI) //
+ .distinct() //
+ .map(bundleInfo::getConfigDescription) //
+ .filter(Optional::isPresent) //
+ .map(Optional::get));
+
+ Builder groupsBuilder = Stream.builder();
+
+ configDescriptionStream.map(configDescription -> {
+ String configKeyPrefix = String.format("%s.config.%s.%s",
+ (Object[]) configDescription.getUID().toString().split(":"));
+ Builder streamBuilder = Stream.builder();
+ configDescriptionGroupParameters(configKeyPrefix, configDescription.getParameterGroups())
+ .forEach(streamBuilder::add);
+ configDescriptionParameters(configKeyPrefix, configDescription.getParameters()).forEach(streamBuilder::add);
+ return streamBuilder.build();
+ }).reduce(Stream::concat).orElseGet(Stream::empty).forEach(groupsBuilder::add);
+
+ return section(header, groupsBuilder.build());
+ }
+
+ private TranslationsSection channelGroupTypesSection(BundleInfo bundleInfo) {
+ String header = "channel group types";
+ List channelGroupTypesXml = bundleInfo.getChannelGroupTypesXml();
+ if (channelGroupTypesXml.isEmpty()) {
+ return section(header);
+ }
+
+ String keyPrefix = String.format("channel-group-type.%s", bundleInfo.getBindingId());
+
+ return section(header, channelGroupTypesXml.stream().map(ChannelGroupTypeXmlResult::toChannelGroupType)
+ .map(channelGroupType -> {
+ String groupTypeKeyPrefix = String.format("%s.%s", keyPrefix,
+ channelGroupType.getUID().toString().split(":")[1]);
+
+ Builder entriesBuilder = Stream.builder();
+
+ entriesBuilder
+ .add(entry(String.format("%s.label", groupTypeKeyPrefix), channelGroupType.getLabel()));
+
+ entriesBuilder.add(entry(String.format("%s.description", groupTypeKeyPrefix),
+ channelGroupType.getDescription()));
+
+ channelGroupType.getChannelDefinitions().stream() //
+ .sorted(Comparator.comparing(ChannelDefinition::getId)) //
+ .forEach(channelDefinition -> {
+ String label = channelDefinition.getLabel();
+ if (label != null) {
+ entriesBuilder.add(entry(String.format("%s.channel.%s.label", groupTypeKeyPrefix,
+ channelDefinition.getId()), label));
+ }
+
+ String description = channelDefinition.getDescription();
+ if (description != null) {
+ entriesBuilder.add(entry(String.format("%s.channel.%s.description",
+ groupTypeKeyPrefix, channelDefinition.getId()), description));
+ }
+ });
+
+ return group(entriesBuilder.build());
+ }));
+ }
+
+ private TranslationsSection channelTypesSection(BundleInfo bundleInfo) {
+ String header = "channel types";
+ List channelTypesXml = bundleInfo.getChannelTypesXml();
+ if (channelTypesXml.isEmpty()) {
+ return section(header);
+ }
+
+ String keyPrefix = String.format("channel-type.%s", bundleInfo.getBindingId());
+
+ return section(header, channelTypesXml.stream().map(channelTypeXml -> {
+ ChannelType channelType = channelTypeXml.toChannelType();
+ String channelId = channelType.getUID().getId();
+
+ Builder entriesBuilder = Stream.builder();
+ entriesBuilder.add(entry(String.format("%s.%s.label", keyPrefix, channelId), channelType.getLabel()));
+ entriesBuilder
+ .add(entry(String.format("%s.%s.description", keyPrefix, channelId), channelType.getDescription()));
+
+ StateDescription stateDescription = channelType.getState();
+ if (stateDescription != null) {
+ stateDescription.getOptions().stream()
+ .map(option -> entry(
+ String.format("%s.%s.state.option.%s", keyPrefix, channelId, option.getValue()),
+ option.getLabel()))
+ .forEach(entriesBuilder::add);
+
+ if (stateDescription.getPattern() != null) {
+ String pattern = stateDescription.getPattern();
+ if (pattern != null && pattern.contains("%1$t")) {
+ entriesBuilder.add(entry(String.format("%s.%s.state.pattern", keyPrefix, channelId),
+ stateDescription.getPattern()));
+ }
+ }
+ }
+
+ CommandDescription commandDescription = channelType.getCommandDescription();
+ if (commandDescription != null) {
+ commandDescription.getCommandOptions().stream()
+ .map(option -> entry(
+ String.format("%s.%s.command.option.%s", keyPrefix, channelId, option.getCommand()),
+ option.getLabel()))
+ .forEach(entriesBuilder::add);
+ }
+
+ return group(entriesBuilder.build());
+ }));
+ }
+
+ private TranslationsSection channelTypesConfigSection(BundleInfo bundleInfo) {
+ String header = "channel types config";
+ List channelTypesXml = bundleInfo.getChannelTypesXml();
+ if (channelTypesXml.isEmpty()) {
+ return section(header);
+ }
+
+ Stream configDescriptionStream = Stream.concat(
+ channelTypesXml.stream().map(ChannelTypeXmlResult::getConfigDescription) //
+ .filter(Objects::nonNull),
+ channelTypesXml.stream().map(ChannelTypeXmlResult::toChannelType)
+ .map(ChannelType::getConfigDescriptionURI) //
+ .distinct() //
+ .map(bundleInfo::getConfigDescription) //
+ .filter(Optional::isPresent) //
+ .map(Optional::get));
+
+ Builder groupsBuilder = Stream.builder();
+
+ configDescriptionStream.map(configDescription -> {
+ String configKeyPrefix = String.format("%s.config.%s.%s",
+ (Object[]) configDescription.getUID().toString().split(":"));
+
+ Builder streamBuilder = Stream.builder();
+ configDescriptionGroupParameters(configKeyPrefix, configDescription.getParameterGroups())
+ .forEach(streamBuilder::add);
+ configDescriptionParameters(configKeyPrefix, configDescription.getParameters()).forEach(streamBuilder::add);
+
+ return streamBuilder.build();
+ }).reduce(Stream::concat).orElseGet(Stream::empty).forEach(groupsBuilder::add);
+
+ return section(header, groupsBuilder.build());
+ }
+
+ private Stream configDescriptionGroupParameters(String keyPrefix,
+ List parameterGroups) {
+ String groupKeyPrefix = String.format("%s.group", keyPrefix);
+ return parameterGroups.stream()
+ .map(parameterGroup -> group(
+ entry(String.format("%s.%s.label", groupKeyPrefix, parameterGroup.getName()),
+ parameterGroup.getLabel()),
+ entry(String.format("%s.%s.description", groupKeyPrefix, parameterGroup.getName()),
+ parameterGroup.getDescription())));
+ }
+
+ private Stream configDescriptionParameters(String keyPrefix,
+ List parameters) {
+ return parameters.stream().map(parameter -> {
+ String parameterKeyPrefix = String.format("%s.%s", keyPrefix, parameter.getName());
+
+ Builder entriesBuilder = Stream.builder();
+ entriesBuilder.add(entry(String.format("%s.label", parameterKeyPrefix), parameter.getLabel()));
+ entriesBuilder.add(entry(String.format("%s.description", parameterKeyPrefix), parameter.getDescription()));
+
+ parameter.getOptions().stream()
+ .map(option -> entry(String.format("%s.option.%s", parameterKeyPrefix, option.getValue()),
+ option.getLabel()))
+ .forEach(entriesBuilder::add);
+
+ return group(entriesBuilder.build());
+ });
+ }
+}
diff --git a/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/BundleInfoReaderTest.java b/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/BundleInfoReaderTest.java
new file mode 100644
index 00000000000..2bfe9da66d7
--- /dev/null
+++ b/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/BundleInfoReaderTest.java
@@ -0,0 +1,79 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+import org.apache.maven.plugin.logging.SystemStreamLog;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.core.binding.xml.internal.BindingInfoXmlResult;
+
+/**
+ * Tests {@link BundleInfoReader}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class BundleInfoReaderTest {
+
+ @Test
+ public void readBindingInfo() throws IOException {
+ BundleInfoReader reader = new BundleInfoReader(new SystemStreamLog());
+ BundleInfo bundleInfo = reader.readBundleInfo(Path.of("src/test/resources/acmeweather.bundle/OH-INF"));
+
+ BindingInfoXmlResult bindingInfoXml = bundleInfo.getBindingInfoXml();
+ assertThat(bindingInfoXml, is(notNullValue()));
+ if (bindingInfoXml != null) {
+ assertThat(bindingInfoXml.getBindingInfo().getName(), is("ACME Weather Binding"));
+ assertThat(bindingInfoXml.getBindingInfo().getDescription(),
+ is("ACME Weather - Current weather and forecasts in your city."));
+ }
+
+ assertThat(bundleInfo.getBindingId(), is("acmeweather"));
+ assertThat(bundleInfo.getChannelGroupTypesXml().size(), is(1));
+ assertThat(bundleInfo.getChannelTypesXml().size(), is(2));
+ assertThat(bundleInfo.getConfigDescriptions().size(), is(1));
+ assertThat(bundleInfo.getThingTypesXml().size(), is(2));
+ }
+
+ @Test
+ public void readGenericBundleInfo() throws IOException {
+ BundleInfoReader reader = new BundleInfoReader(new SystemStreamLog());
+ BundleInfo bundleInfo = reader.readBundleInfo(Path.of("src/test/resources/acmetts.bundle/OH-INF"));
+
+ assertThat(bundleInfo.getBindingInfoXml(), is(nullValue()));
+ assertThat(bundleInfo.getBindingId(), is(""));
+ assertThat(bundleInfo.getChannelGroupTypesXml().size(), is(0));
+ assertThat(bundleInfo.getChannelTypesXml().size(), is(0));
+ assertThat(bundleInfo.getConfigDescriptions().size(), is(1));
+ assertThat(bundleInfo.getThingTypesXml().size(), is(0));
+ }
+
+ @Test
+ public void readPathWithoutAnyInfo() throws IOException {
+ BundleInfoReader reader = new BundleInfoReader(new SystemStreamLog());
+ BundleInfo bundleInfo = reader.readBundleInfo(Path.of("src/test/resources/infoless.bundle/OH-INF"));
+
+ assertThat(bundleInfo.getBindingInfoXml(), is(nullValue()));
+ assertThat(bundleInfo.getBindingId(), is(""));
+ assertThat(bundleInfo.getChannelGroupTypesXml().size(), is(0));
+ assertThat(bundleInfo.getChannelTypesXml().size(), is(0));
+ assertThat(bundleInfo.getConfigDescriptions().size(), is(0));
+ assertThat(bundleInfo.getThingTypesXml().size(), is(0));
+ }
+}
diff --git a/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/GenerateDefaultTranslationsMojoTest.java b/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/GenerateDefaultTranslationsMojoTest.java
new file mode 100644
index 00000000000..a99f0da8d39
--- /dev/null
+++ b/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/GenerateDefaultTranslationsMojoTest.java
@@ -0,0 +1,237 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.openhab.core.tools.i18n.plugin.DefaultTranslationsGenerationMode.*;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugin.logging.SystemStreamLog;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Tests {@link GenerateDefaultTranslationsMojo}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class GenerateDefaultTranslationsMojoTest {
+
+ @TempDir
+ public @NonNullByDefault({}) Path tempPath;
+ public @NonNullByDefault({}) Path tempI18nPath;
+
+ public static final Path TTS_RESOURCES_PATH = Path.of("src/test/resources/acmetts.bundle");
+ public static final Path TTS_I18N_RESOURCES_PATH = TTS_RESOURCES_PATH.resolve("OH-INF/i18n");
+ public static final Path TTS_ALL_PROPERTIES_PATH = TTS_I18N_RESOURCES_PATH.resolve("acmetts.properties");
+ public static final Path TTS_ALL_DE_PROPERTIES_PATH = TTS_I18N_RESOURCES_PATH.resolve("acmetts_de.properties");
+ public static final Path TTS_GENERATED_PROPERTIES_PATH = TTS_I18N_RESOURCES_PATH
+ .resolve("acmetts.generated.properties");
+ public static final Path TTS_PARTIAL_PROPERTIES_PATH = TTS_I18N_RESOURCES_PATH
+ .resolve("acmetts.partial.properties");
+
+ public static final Path WEATHER_RESOURCES_PATH = Path.of("src/test/resources/acmeweather.bundle");
+ public static final Path WEATHER_I18N_RESOURCES_PATH = WEATHER_RESOURCES_PATH.resolve("OH-INF/i18n");
+ public static final Path WEATHER_ALL_PROPERTIES_PATH = WEATHER_I18N_RESOURCES_PATH
+ .resolve("acmeweather.properties");
+ public static final Path WEATHER_ALL_DE_PROPERTIES_PATH = WEATHER_I18N_RESOURCES_PATH
+ .resolve("acmeweather_de.properties");
+ public static final Path WEATHER_GENERATED_PROPERTIES_PATH = WEATHER_I18N_RESOURCES_PATH
+ .resolve("acmeweather.generated.properties");
+ public static final Path WEATHER_PARTIAL_PROPERTIES_PATH = WEATHER_I18N_RESOURCES_PATH
+ .resolve("acmeweather.partial.properties");
+
+ public static final Path INFOLESS_RESOURCES_PATH = Path.of("src/test/resources/infoless.bundle");
+
+ private @NonNullByDefault({}) GenerateDefaultTranslationsMojo mojo;
+
+ private void copyPath(Path src, Path dest) throws IOException {
+ try (Stream stream = Files.walk(src)) {
+ stream.forEach(source -> copy(source, dest.resolve(src.relativize(source))));
+ }
+ }
+
+ private void copy(Path source, Path dest) {
+ try {
+ Files.copy(source, dest, REPLACE_EXISTING);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e.getMessage(), e);
+ }
+ }
+
+ private void deleteTempI18nPath() throws IOException {
+ try (DirectoryStream entries = Files.newDirectoryStream(tempI18nPath)) {
+ for (Path entry : entries) {
+ Files.delete(entry);
+ }
+ }
+
+ Files.delete(tempI18nPath);
+ }
+
+ @BeforeEach
+ public void before() {
+ tempI18nPath = tempPath.resolve("OH-INF/i18n");
+
+ mojo = new GenerateDefaultTranslationsMojo();
+ mojo.setLog(new SystemStreamLog());
+ mojo.setOhinfDirectory(tempPath.resolve("OH-INF").toFile());
+ mojo.setTargetDirectory(tempI18nPath.toFile());
+ }
+
+ private void assertSameProperties(Path expectedPath, Path actualPath) throws IOException {
+ String expected = Files.readString(expectedPath);
+ String actual = Files.readString(actualPath);
+ assertThat(expected, equalTo(actual));
+ }
+
+ @Test
+ public void addMissingBindingTranslationsWithoutI18nPath() throws IOException, MojoFailureException {
+ copyPath(WEATHER_RESOURCES_PATH, tempPath);
+ deleteTempI18nPath();
+
+ mojo.setGenerationMode(ADD_MISSING_TRANSLATIONS);
+ mojo.execute();
+
+ assertSameProperties(WEATHER_GENERATED_PROPERTIES_PATH, tempI18nPath.resolve("acmeweather.properties"));
+ }
+
+ @Test
+ public void addMissingBindingTranslationsNoChanges() throws IOException, MojoFailureException {
+ copyPath(WEATHER_RESOURCES_PATH, tempPath);
+
+ mojo.setGenerationMode(ADD_MISSING_TRANSLATIONS);
+ mojo.execute();
+
+ assertSameProperties(WEATHER_ALL_PROPERTIES_PATH, tempI18nPath.resolve("acmeweather.properties"));
+ assertSameProperties(WEATHER_ALL_DE_PROPERTIES_PATH, tempI18nPath.resolve("acmeweather_de.properties"));
+ }
+
+ @Test
+ public void addMissingBindingTranslationsToPartialTranslation() throws IOException, MojoFailureException {
+ copyPath(WEATHER_RESOURCES_PATH, tempPath);
+ Files.move(tempI18nPath.resolve("acmeweather.partial.properties"),
+ tempI18nPath.resolve("acmeweather.properties"), REPLACE_EXISTING);
+
+ mojo.setGenerationMode(ADD_MISSING_TRANSLATIONS);
+ mojo.execute();
+
+ assertSameProperties(WEATHER_ALL_PROPERTIES_PATH, tempI18nPath.resolve("acmeweather.properties"));
+ assertSameProperties(WEATHER_ALL_DE_PROPERTIES_PATH, tempI18nPath.resolve("acmeweather_de.properties"));
+ }
+
+ @Test
+ public void skipTranslationsBindingTranslationsGeneratingWithExistingTranslations()
+ throws IOException, MojoFailureException {
+ copyPath(WEATHER_RESOURCES_PATH, tempPath);
+ Files.move(tempI18nPath.resolve("acmeweather.partial.properties"),
+ tempI18nPath.resolve("acmeweather.properties"), REPLACE_EXISTING);
+
+ mojo.setGenerationMode(ADD_MISSING_FILES);
+ mojo.execute();
+
+ assertSameProperties(WEATHER_PARTIAL_PROPERTIES_PATH, tempI18nPath.resolve("acmeweather.properties"));
+ assertSameProperties(WEATHER_ALL_DE_PROPERTIES_PATH, tempI18nPath.resolve("acmeweather_de.properties"));
+ }
+
+ @Test
+ public void regenerateBindingTranslations() throws IOException, MojoFailureException {
+ copyPath(WEATHER_RESOURCES_PATH, tempPath);
+
+ mojo.setGenerationMode(REGENERATE_FILES);
+ mojo.execute();
+
+ assertSameProperties(WEATHER_GENERATED_PROPERTIES_PATH, tempI18nPath.resolve("acmeweather.properties"));
+ assertSameProperties(WEATHER_ALL_DE_PROPERTIES_PATH, tempI18nPath.resolve("acmeweather_de.properties"));
+ }
+
+ @Test
+ public void addMissingGenericBundleTranslationsWithoutI18nPath() throws IOException, MojoFailureException {
+ copyPath(TTS_RESOURCES_PATH, tempPath);
+ deleteTempI18nPath();
+
+ mojo.setGenerationMode(ADD_MISSING_TRANSLATIONS);
+ mojo.execute();
+
+ assertSameProperties(TTS_GENERATED_PROPERTIES_PATH, tempI18nPath.resolve("acmetts.properties"));
+ }
+
+ @Test
+ public void addMissingGenericBundleTranslationsNoChanges() throws IOException, MojoFailureException {
+ copyPath(TTS_RESOURCES_PATH, tempPath);
+
+ mojo.setGenerationMode(ADD_MISSING_TRANSLATIONS);
+ mojo.execute();
+
+ assertSameProperties(TTS_ALL_PROPERTIES_PATH, tempI18nPath.resolve("acmetts.properties"));
+ assertSameProperties(TTS_ALL_DE_PROPERTIES_PATH, tempI18nPath.resolve("acmetts_de.properties"));
+ }
+
+ @Test
+ public void addMissingGenericBundleTranslationsToPartialTranslation() throws IOException, MojoFailureException {
+ copyPath(TTS_RESOURCES_PATH, tempPath);
+ Files.move(tempI18nPath.resolve("acmetts.partial.properties"), tempI18nPath.resolve("acmetts.properties"),
+ REPLACE_EXISTING);
+
+ mojo.setGenerationMode(ADD_MISSING_TRANSLATIONS);
+ mojo.execute();
+
+ assertSameProperties(TTS_ALL_PROPERTIES_PATH, tempI18nPath.resolve("acmetts.properties"));
+ assertSameProperties(TTS_ALL_DE_PROPERTIES_PATH, tempI18nPath.resolve("acmetts_de.properties"));
+ }
+
+ @Test
+ public void skipTranslationsGenericBundleTranslationsGeneratingWithExistingTranslations()
+ throws IOException, MojoFailureException {
+ copyPath(TTS_RESOURCES_PATH, tempPath);
+ Files.move(tempI18nPath.resolve("acmetts.partial.properties"), tempI18nPath.resolve("acmetts.properties"),
+ REPLACE_EXISTING);
+
+ mojo.setGenerationMode(ADD_MISSING_FILES);
+ mojo.execute();
+
+ assertSameProperties(TTS_PARTIAL_PROPERTIES_PATH, tempI18nPath.resolve("acmetts.properties"));
+ assertSameProperties(TTS_ALL_DE_PROPERTIES_PATH, tempI18nPath.resolve("acmetts_de.properties"));
+ }
+
+ @Test
+ public void regenerateGenericBundleTranslations() throws IOException, MojoFailureException {
+ copyPath(TTS_RESOURCES_PATH, tempPath);
+
+ mojo.setGenerationMode(REGENERATE_FILES);
+ mojo.execute();
+
+ assertSameProperties(TTS_GENERATED_PROPERTIES_PATH, tempI18nPath.resolve("acmetts.properties"));
+ assertSameProperties(TTS_ALL_DE_PROPERTIES_PATH, tempI18nPath.resolve("acmetts_de.properties"));
+ }
+
+ @Test
+ public void addMissingTranslationsWithoutOhInfPath() throws IOException, MojoFailureException {
+ copyPath(INFOLESS_RESOURCES_PATH, tempPath);
+
+ mojo.setGenerationMode(ADD_MISSING_TRANSLATIONS);
+ mojo.execute();
+ }
+}
diff --git a/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/PropertiesToTranslationsConverterTest.java b/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/PropertiesToTranslationsConverterTest.java
new file mode 100644
index 00000000000..813902bbc2a
--- /dev/null
+++ b/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/PropertiesToTranslationsConverterTest.java
@@ -0,0 +1,88 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.emptyString;
+
+import java.nio.file.Path;
+import java.util.stream.Collectors;
+
+import org.apache.maven.plugin.logging.SystemStreamLog;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link PropertiesToTranslationsConverter}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class PropertiesToTranslationsConverterTest {
+
+ @Test
+ public void readBindingInfo() {
+ PropertiesToTranslationsConverter converter = new PropertiesToTranslationsConverter(new SystemStreamLog());
+ Translations translations = converter
+ .convert(Path.of("src/test/resources/acmeweather.bundle/OH-INF/i18n/acmeweather.properties"));
+
+ assertThat(translations.hasTranslations(), is(true));
+ assertThat(translations.sections.size(), is(6));
+ assertThat(translations.keysStream().count(), is(31L));
+
+ String lines = translations.linesStream().collect(Collectors.joining(System.lineSeparator()));
+ assertThat(lines, containsString("# binding"));
+ assertThat(lines, containsString("binding.acmeweather.name = ACME Weather Binding"));
+ assertThat(lines, containsString(
+ "binding.acmeweather.description = ACME Weather - Current weather and forecasts in your city."));
+ assertThat(lines, containsString(
+ "channel-group-type.acmeweather.forecast.channel.minTemperature.description = Minimum forecasted temperature in degrees Celsius (metric) or Fahrenheit (imperial)."));
+ assertThat(lines, containsString(
+ "channel-type.acmeweather.time-stamp.state.pattern = %1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS"));
+ assertThat(lines, containsString("CUSTOM_KEY = Provides various weather data from the ACME weather service"));
+ }
+
+ @Test
+ public void readGenericBundleInfo() {
+ PropertiesToTranslationsConverter converter = new PropertiesToTranslationsConverter(new SystemStreamLog());
+ Translations translations = converter
+ .convert(Path.of("src/test/resources/acmetts.bundle/OH-INF/i18n/acmetts.properties"));
+
+ assertThat(translations.hasTranslations(), is(true));
+ assertThat(translations.sections.size(), is(2));
+ assertThat(translations.keysStream().count(), is(19L));
+
+ String lines = translations.linesStream().collect(Collectors.joining(System.lineSeparator()));
+
+ assertThat(lines, containsString("voice.config.acmetts.clientId.label = Client Id"));
+ assertThat(lines,
+ containsString("voice.config.acmetts.clientId.description = ACME Platform OAuth 2.0-Client Id."));
+ assertThat(lines, containsString("voice.config.acmetts.pitch.label = Pitch"));
+ assertThat(lines, containsString(
+ "voice.config.acmetts.pitch.description = Customize the pitch of your selected voice, up to 20 semitones more or less than the default output."));
+ }
+
+ @Test
+ public void readPathWithoutAnyInfo() {
+ PropertiesToTranslationsConverter converter = new PropertiesToTranslationsConverter(new SystemStreamLog());
+ Translations translations = converter
+ .convert(Path.of("src/test/resources/infoless.bundle/OH-INF/i18n/nonexisting.properties"));
+
+ assertThat(translations.hasTranslations(), is(false));
+ assertThat(translations.keysStream().count(), is(0L));
+
+ String lines = translations.linesStream().collect(Collectors.joining(System.lineSeparator()));
+ assertThat(lines, is(emptyString()));
+ }
+}
diff --git a/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/TranslationsMergerTest.java b/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/TranslationsMergerTest.java
new file mode 100644
index 00000000000..e477948c3a0
--- /dev/null
+++ b/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/TranslationsMergerTest.java
@@ -0,0 +1,86 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.openhab.core.tools.i18n.plugin.Translations.TranslationsEntry.entry;
+import static org.openhab.core.tools.i18n.plugin.Translations.TranslationsGroup.group;
+import static org.openhab.core.tools.i18n.plugin.Translations.TranslationsSection.section;
+import static org.openhab.core.tools.i18n.plugin.Translations.translations;
+
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link TranslationsMerger}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class TranslationsMergerTest {
+
+ @Test
+ public void mergeEmptyTranslations() {
+ Translations mainTranslations = translations();
+ Translations missingTranslations = translations();
+
+ TranslationsMerger merger = new TranslationsMerger();
+ merger.merge(mainTranslations, missingTranslations);
+
+ assertThat(mainTranslations.hasTranslations(), is(false));
+ assertThat(mainTranslations.keysStream().count(), is(0L));
+
+ assertThat(missingTranslations.hasTranslations(), is(false));
+ assertThat(missingTranslations.keysStream().count(), is(0L));
+ }
+
+ @Test
+ public void mergeDifferentTranslations() {
+ Translations mainTranslations = Translations.translations( //
+ section("main section 1", group( //
+ entry("key1", "mainValue1"), //
+ entry("key2", "mainValue2"))),
+ section("main section 2", group( //
+ entry("key3", "mainValue3"), //
+ entry("key4", "mainValue4"))));
+
+ Translations missingTranslations = Translations.translations( //
+ section("missing section 1", group( //
+ entry("key1", "missingValue1"), //
+ entry("key2", "missingValue2"))),
+ section("missing section 3", group( //
+ entry("key5", "missingValue5"), //
+ entry("key6", "missingValue6"))));
+
+ TranslationsMerger merger = new TranslationsMerger();
+ merger.merge(mainTranslations, missingTranslations);
+
+ assertThat(mainTranslations.hasTranslations(), is(true));
+ assertThat(mainTranslations.keysStream().count(), is(6L));
+ assertThat(mainTranslations.sections.size(), is(3));
+
+ String lines = mainTranslations.linesStream().collect(Collectors.joining(System.lineSeparator()));
+ assertThat(lines, containsString("# main section 1"));
+ assertThat(lines, containsString("key1 = mainValue1"));
+ assertThat(lines, containsString("key2 = mainValue2"));
+ assertThat(lines, containsString("# main section 2"));
+ assertThat(lines, containsString("key3 = mainValue3"));
+ assertThat(lines, containsString("key4 = mainValue4"));
+ assertThat(lines, containsString("# missing section 3"));
+ assertThat(lines, containsString("key5 = missingValue5"));
+ assertThat(lines, containsString("key6 = missingValue6"));
+ }
+}
diff --git a/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/XmlToTranslationsConverterTest.java b/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/XmlToTranslationsConverterTest.java
new file mode 100644
index 00000000000..f8e39bfd7df
--- /dev/null
+++ b/tools/i18n-plugin/src/test/java/org/openhab/core/tools/i18n/plugin/XmlToTranslationsConverterTest.java
@@ -0,0 +1,93 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.tools.i18n.plugin;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.emptyString;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.stream.Collectors;
+
+import org.apache.maven.plugin.logging.SystemStreamLog;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link XmlToTranslationsConverter}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class XmlToTranslationsConverterTest {
+
+ @Test
+ public void convertBindingInfo() throws IOException {
+ BundleInfoReader reader = new BundleInfoReader(new SystemStreamLog());
+ BundleInfo bundleInfo = reader.readBundleInfo(Path.of("src/test/resources/acmeweather.bundle/OH-INF"));
+
+ XmlToTranslationsConverter converter = new XmlToTranslationsConverter();
+ Translations translations = converter.convert(bundleInfo);
+
+ assertThat(translations.hasTranslations(), is(true));
+ assertThat(translations.sections.size(), is(7));
+ assertThat(translations.keysStream().count(), is(30L));
+
+ String lines = translations.linesStream().collect(Collectors.joining(System.lineSeparator()));
+ assertThat(lines, containsString("# binding"));
+ assertThat(lines, containsString("binding.acmeweather.name = ACME Weather Binding"));
+ assertThat(lines, containsString(
+ "binding.acmeweather.description = ACME Weather - Current weather and forecasts in your city."));
+ assertThat(lines, containsString(
+ "channel-group-type.acmeweather.forecast.channel.minTemperature.description = Minimum forecasted temperature in degrees Celsius (metric) or Fahrenheit (imperial)."));
+ assertThat(lines, containsString(
+ "channel-type.acmeweather.time-stamp.state.pattern = %1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS"));
+ }
+
+ @Test
+ public void convertGenericBundleInfo() throws IOException {
+ BundleInfoReader reader = new BundleInfoReader(new SystemStreamLog());
+ BundleInfo bundleInfo = reader.readBundleInfo(Path.of("src/test/resources/acmetts.bundle/OH-INF"));
+
+ XmlToTranslationsConverter converter = new XmlToTranslationsConverter();
+ Translations translations = converter.convert(bundleInfo);
+
+ assertThat(translations.hasTranslations(), is(true));
+ assertThat(translations.sections.size(), is(1));
+ assertThat(translations.keysStream().count(), is(18L));
+
+ String lines = translations.linesStream().collect(Collectors.joining(System.lineSeparator()));
+ assertThat(lines, containsString("voice.config.acmetts.clientId.label = Client Id"));
+ assertThat(lines,
+ containsString("voice.config.acmetts.clientId.description = ACME Platform OAuth 2.0-Client Id."));
+ assertThat(lines, containsString("voice.config.acmetts.pitch.label = Pitch"));
+ assertThat(lines, containsString(
+ "voice.config.acmetts.pitch.description = Customize the pitch of your selected voice, up to 20 semitones more or less than the default output."));
+ }
+
+ @Test
+ public void convertPathWithoutAnyInfo() throws IOException {
+ BundleInfoReader reader = new BundleInfoReader(new SystemStreamLog());
+ BundleInfo bundleInfo = reader.readBundleInfo(Path.of("src/test/resources/infoless.bundle/OH-INF"));
+
+ XmlToTranslationsConverter converter = new XmlToTranslationsConverter();
+ Translations translations = converter.convert(bundleInfo);
+
+ assertThat(translations.hasTranslations(), is(false));
+ assertThat(translations.keysStream().count(), is(0L));
+
+ String lines = translations.linesStream().collect(Collectors.joining(System.lineSeparator()));
+ assertThat(lines, is(emptyString()));
+ }
+}
diff --git a/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/config/config.xml b/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/config/config.xml
new file mode 100644
index 00000000000..4bf93ea2268
--- /dev/null
+++ b/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/config/config.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+ Authentication for connecting to ACME Platform.
+
+
+
+ Parameters for ACME TTS API.
+
+
+
+
+ ACME Platform OAuth 2.0-Client Id.
+
+
+ password
+
+ ACME Platform OAuth 2.0-Client Secret.
+
+
+
+ Please go to your browser ... https://accounts.google.com/o/oauth2/auth?client_id={{clientId}}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=https://www.googleapis.com/auth/cloud-platform&response_type=code ... to generate an auth-code and paste it here.]]>
+
+
+
+ Customize the pitch of your selected voice, up to 20 semitones more or less than the default output.
+ 0
+
+
+
+ Increase the volume of the output by up to 16db or decrease the volume up to -96db.
+ 0
+
+
+
+ Speaking rate can be 4x faster or slower than the normal rate.
+ 1
+
+
+ true
+
+ Purges the cache e.g. after testing different voice configuration parameters. When enabled the cache is
+ purged once. Make sure to disable this setting again so the cache is maintained after restarts.
+ false
+
+
+
+
diff --git a/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/i18n/acmetts.generated.properties b/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/i18n/acmetts.generated.properties
new file mode 100644
index 00000000000..9b6367b2c01
--- /dev/null
+++ b/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/i18n/acmetts.generated.properties
@@ -0,0 +1,18 @@
+voice.config.acmetts.authcode.label = Authorization Code
+voice.config.acmetts.authcode.description = The auth-code is a one-time code needed to retrieve the necessary access-codes from ACME Platform. Please go to your browser ... https://accounts.google.com/o/oauth2/auth?client_id={{clientId}}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=https://www.googleapis.com/auth/cloud-platform&response_type=code ... to generate an auth-code and paste it here.
+voice.config.acmetts.clientId.label = Client Id
+voice.config.acmetts.clientId.description = ACME Platform OAuth 2.0-Client Id.
+voice.config.acmetts.clientSecret.label = Client Secret
+voice.config.acmetts.clientSecret.description = ACME Platform OAuth 2.0-Client Secret.
+voice.config.acmetts.group.authentication.label = Authentication
+voice.config.acmetts.group.authentication.description = Authentication for connecting to ACME Platform.
+voice.config.acmetts.group.tts.label = TTS Configuration
+voice.config.acmetts.group.tts.description = Parameters for ACME TTS API.
+voice.config.acmetts.pitch.label = Pitch
+voice.config.acmetts.pitch.description = Customize the pitch of your selected voice, up to 20 semitones more or less than the default output.
+voice.config.acmetts.purgeCache.label = Purge Cache
+voice.config.acmetts.purgeCache.description = Purges the cache e.g. after testing different voice configuration parameters. When enabled the cache is purged once. Make sure to disable this setting again so the cache is maintained after restarts.
+voice.config.acmetts.speakingRate.label = Speaking Rate
+voice.config.acmetts.speakingRate.description = Speaking rate can be 4x faster or slower than the normal rate.
+voice.config.acmetts.volumeGain.label = Volume Gain
+voice.config.acmetts.volumeGain.description = Increase the volume of the output by up to 16db or decrease the volume up to -96db.
diff --git a/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/i18n/acmetts.partial.properties b/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/i18n/acmetts.partial.properties
new file mode 100644
index 00000000000..2afe16d3f62
--- /dev/null
+++ b/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/i18n/acmetts.partial.properties
@@ -0,0 +1,3 @@
+# custom
+
+CUSTOM_KEY = Could not connect to the ACME TTS API
diff --git a/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/i18n/acmetts.properties b/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/i18n/acmetts.properties
new file mode 100644
index 00000000000..ffb017cc929
--- /dev/null
+++ b/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/i18n/acmetts.properties
@@ -0,0 +1,22 @@
+voice.config.acmetts.authcode.label = Authorization Code
+voice.config.acmetts.authcode.description = The auth-code is a one-time code needed to retrieve the necessary access-codes from ACME Platform. Please go to your browser ... https://accounts.google.com/o/oauth2/auth?client_id={{clientId}}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=https://www.googleapis.com/auth/cloud-platform&response_type=code ... to generate an auth-code and paste it here.
+voice.config.acmetts.clientId.label = Client Id
+voice.config.acmetts.clientId.description = ACME Platform OAuth 2.0-Client Id.
+voice.config.acmetts.clientSecret.label = Client Secret
+voice.config.acmetts.clientSecret.description = ACME Platform OAuth 2.0-Client Secret.
+voice.config.acmetts.group.authentication.label = Authentication
+voice.config.acmetts.group.authentication.description = Authentication for connecting to ACME Platform.
+voice.config.acmetts.group.tts.label = TTS Configuration
+voice.config.acmetts.group.tts.description = Parameters for ACME TTS API.
+voice.config.acmetts.pitch.label = Pitch
+voice.config.acmetts.pitch.description = Customize the pitch of your selected voice, up to 20 semitones more or less than the default output.
+voice.config.acmetts.purgeCache.label = Purge Cache
+voice.config.acmetts.purgeCache.description = Purges the cache e.g. after testing different voice configuration parameters. When enabled the cache is purged once. Make sure to disable this setting again so the cache is maintained after restarts.
+voice.config.acmetts.speakingRate.label = Speaking Rate
+voice.config.acmetts.speakingRate.description = Speaking rate can be 4x faster or slower than the normal rate.
+voice.config.acmetts.volumeGain.label = Volume Gain
+voice.config.acmetts.volumeGain.description = Increase the volume of the output by up to 16db or decrease the volume up to -96db.
+
+# custom
+
+CUSTOM_KEY = Could not connect to the ACME TTS API
diff --git a/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/i18n/acmetts_de.properties b/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/i18n/acmetts_de.properties
new file mode 100644
index 00000000000..bf92ddba041
--- /dev/null
+++ b/tools/i18n-plugin/src/test/resources/acmetts.bundle/OH-INF/i18n/acmetts_de.properties
@@ -0,0 +1,22 @@
+voice.config.acmetts.authcode.label = Autorisierungscode
+voice.config.acmetts.authcode.description = Der Autorisierungscode ist ein einmaliger Code, der benötigt wird, um den notwendigen Authentifizierungscode von der ACME Plattform abzurufen. Aufruf der URL ... https\://accounts.google.com/o/oauth2/auth?client_id\={{clientId}}&redirect_uri\=urn\:ietf\:wg\:oauth\:2.0\:oob&scope\=https\://www.googleapis.com/auth/cloud-platform&response_type\=code im Browser, um einen Autorisierungscode zu generieren und ihn hier einzufügen.
+voice.config.acmetts.clientId.label = Client Id
+voice.config.acmetts.clientId.description = ACME Plattform OAuth 2.0-Client Id.
+voice.config.acmetts.clientSecret.label = Client Secret
+voice.config.acmetts.clientSecret.description = ACME Plattform OAuth 2.0-Client Secret.
+voice.config.acmetts.group.authentication.label = Authentifizierungscode
+voice.config.acmetts.group.authentication.description = Authentifizierungscode für die Verbindung zur ACME Plattform.
+voice.config.acmetts.group.tts.label = Sprachkonfiguration
+voice.config.acmetts.group.tts.description = Sprachkonfiguration für die ACME TTS API.
+voice.config.acmetts.pitch.label = Tonhöhe
+voice.config.acmetts.pitch.description = Die Tonhöhe der gewählten Stimme kann bis zu 20 Halbtöne höher oder niedriger sein als der Standardwert.
+voice.config.acmetts.purgeCache.label = Cache Leeren
+voice.config.acmetts.purgeCache.description = Leert den Cache z.B. nach dem Testen verschiedener Sprachkonfigurationen. Wenn aktiviert, wird der Cache einmalig gelöscht. Stellen Sie sicher, dass Sie diese Einstellung im Anschluss deaktivieren wird, so dass der Cache nach einem Neustart genutzt wird.
+voice.config.acmetts.speakingRate.label = Sprachgeschwindigkeit
+voice.config.acmetts.speakingRate.description = Die Sprachgeschwindigkeit kann bis zu 4x schneller oder langsamer sein als die normale Geschwindigkeit.
+voice.config.acmetts.volumeGain.label = Lautstärke Verstärkung
+voice.config.acmetts.volumeGain.description = Die Lautstärke der Sprachausgabe kann um bis zu 16db erhöht oder um bis zu -96db verringert werden.
+
+# custom
+
+CUSTOM_KEY = Keine Verbindung mit ACME TTS API möglich
diff --git a/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/binding/binding.xml b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/binding/binding.xml
new file mode 100644
index 00000000000..97266803f7a
--- /dev/null
+++ b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/binding/binding.xml
@@ -0,0 +1,9 @@
+
+
+
+ ACME Weather Binding
+ ACME Weather - Current weather and forecasts in your city.
+
+
diff --git a/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/config/config.xml b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/config/config.xml
new file mode 100644
index 00000000000..f738ff332a8
--- /dev/null
+++ b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/config/config.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+ Specifies the refresh interval (in minutes).
+ 60
+
+
+
+ Language to be used by the ACME API.
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/i18n/acmeweather.generated.properties b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/i18n/acmeweather.generated.properties
new file mode 100644
index 00000000000..bbbddaebdf3
--- /dev/null
+++ b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/i18n/acmeweather.generated.properties
@@ -0,0 +1,44 @@
+# binding
+
+binding.acmeweather.name = ACME Weather Binding
+binding.acmeweather.description = ACME Weather - Current weather and forecasts in your city.
+
+# thing types
+
+thing-type.acmeweather.weather-with-group.label = Weather Information with Group
+thing-type.acmeweather.weather-with-group.group.forecastToday.label = Today
+thing-type.acmeweather.weather-with-group.group.forecastToday.description = This is the weather forecast for today.
+thing-type.acmeweather.weather-with-group.group.forecastTomorrow.label = Weather Forecast Tomorrow
+thing-type.acmeweather.weather-with-group.group.forecastTomorrow.description = This is the weather forecast for tomorrow.
+thing-type.acmeweather.weather.label = Weather Information *
+thing-type.acmeweather.weather.channel.minTemperature.label = Min. Temperature
+thing-type.acmeweather.weather.channel.minTemperature.description = Minimum temperature in degrees Celsius (metric) or Fahrenheit (imperial).
+
+# thing types config
+
+thing-type.config.acmeweather.weather.language.label = Language
+thing-type.config.acmeweather.weather.language.description = Language to be used by the ACME API.
+thing-type.config.acmeweather.weather.language.option.nl = Dutch
+thing-type.config.acmeweather.weather.language.option.de = German
+thing-type.config.acmeweather.weather.language.option.en = English
+thing-type.config.acmeweather.weather.language.option.fr = French
+thing-type.config.acmeweather.weather.refreshInterval.label = Refresh Interval
+thing-type.config.acmeweather.weather.refreshInterval.description = Specifies the refresh interval (in minutes).
+
+# channel group types
+
+channel-group-type.acmeweather.forecast.label = Weather information group
+channel-group-type.acmeweather.forecast.description = Weather information group description.
+channel-group-type.acmeweather.forecast.channel.maxTemperature.label = Max. Temperature
+channel-group-type.acmeweather.forecast.channel.maxTemperature.description = Maximum forecasted temperature in degrees Celsius (metric) or Fahrenheit (imperial).
+channel-group-type.acmeweather.forecast.channel.minTemperature.label = Min. Temperature
+channel-group-type.acmeweather.forecast.channel.minTemperature.description = Minimum forecasted temperature in degrees Celsius (metric) or Fahrenheit (imperial).
+
+# channel types
+
+channel-type.acmeweather.temperature.label = Temperature
+channel-type.acmeweather.temperature.description = Temperature in degrees Celsius (metric) or Fahrenheit (imperial).
+channel-type.acmeweather.temperature.state.option.VALUE = My label
+channel-type.acmeweather.time-stamp.label = Observation Time
+channel-type.acmeweather.time-stamp.description = Time of data observation.
+channel-type.acmeweather.time-stamp.state.pattern = %1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS
diff --git a/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/i18n/acmeweather.partial.properties b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/i18n/acmeweather.partial.properties
new file mode 100644
index 00000000000..141c516bfe9
--- /dev/null
+++ b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/i18n/acmeweather.partial.properties
@@ -0,0 +1,3 @@
+# custom
+
+CUSTOM_KEY = Provides various weather data from the ACME weather service
diff --git a/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/i18n/acmeweather.properties b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/i18n/acmeweather.properties
new file mode 100644
index 00000000000..3dc43a53a91
--- /dev/null
+++ b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/i18n/acmeweather.properties
@@ -0,0 +1,48 @@
+# binding
+
+binding.acmeweather.name = ACME Weather Binding
+binding.acmeweather.description = ACME Weather - Current weather and forecasts in your city.
+
+# thing types
+
+thing-type.acmeweather.weather-with-group.label = Weather Information with Group
+thing-type.acmeweather.weather-with-group.group.forecastToday.label = Today
+thing-type.acmeweather.weather-with-group.group.forecastToday.description = This is the weather forecast for today.
+thing-type.acmeweather.weather-with-group.group.forecastTomorrow.label = Weather Forecast Tomorrow
+thing-type.acmeweather.weather-with-group.group.forecastTomorrow.description = This is the weather forecast for tomorrow.
+thing-type.acmeweather.weather.label = Weather Information *
+thing-type.acmeweather.weather.channel.minTemperature.label = Min. Temperature
+thing-type.acmeweather.weather.channel.minTemperature.description = Minimum temperature in degrees Celsius (metric) or Fahrenheit (imperial).
+
+# thing types config
+
+thing-type.config.acmeweather.weather.language.label = Language
+thing-type.config.acmeweather.weather.language.description = Language to be used by the ACME API.
+thing-type.config.acmeweather.weather.language.option.nl = Dutch
+thing-type.config.acmeweather.weather.language.option.de = German
+thing-type.config.acmeweather.weather.language.option.en = English
+thing-type.config.acmeweather.weather.language.option.fr = French
+thing-type.config.acmeweather.weather.refreshInterval.label = Refresh Interval
+thing-type.config.acmeweather.weather.refreshInterval.description = Specifies the refresh interval (in minutes).
+
+# channel group types
+
+channel-group-type.acmeweather.forecast.label = Weather information group
+channel-group-type.acmeweather.forecast.description = Weather information group description.
+channel-group-type.acmeweather.forecast.channel.maxTemperature.label = Max. Temperature
+channel-group-type.acmeweather.forecast.channel.maxTemperature.description = Maximum forecasted temperature in degrees Celsius (metric) or Fahrenheit (imperial).
+channel-group-type.acmeweather.forecast.channel.minTemperature.label = Min. Temperature
+channel-group-type.acmeweather.forecast.channel.minTemperature.description = Minimum forecasted temperature in degrees Celsius (metric) or Fahrenheit (imperial).
+
+# channel types
+
+channel-type.acmeweather.temperature.label = Temperature
+channel-type.acmeweather.temperature.description = Temperature in degrees Celsius (metric) or Fahrenheit (imperial).
+channel-type.acmeweather.temperature.state.option.VALUE = My label
+channel-type.acmeweather.time-stamp.label = Observation Time
+channel-type.acmeweather.time-stamp.description = Time of data observation.
+channel-type.acmeweather.time-stamp.state.pattern = %1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS
+
+# custom
+
+CUSTOM_KEY = Provides various weather data from the ACME weather service
diff --git a/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/i18n/acmeweather_de.properties b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/i18n/acmeweather_de.properties
new file mode 100644
index 00000000000..de7bd445cc5
--- /dev/null
+++ b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/i18n/acmeweather_de.properties
@@ -0,0 +1,48 @@
+# binding
+
+binding.acmeweather.name = ACME Wetter Binding
+binding.acmeweather.description = ACME Wetter - Aktuelles Wetter und Prognosen in Ihrer Stadt.
+
+# thing types
+
+thing-type.acmeweather.weather-with-group.label = Wetterinformationen mit Gruppe
+thing-type.acmeweather.weather-with-group.group.forecastToday.label = Wettervorhersage heute
+thing-type.acmeweather.weather-with-group.group.forecastToday.description = Wettervorhersage für den heutigen Tag.
+thing-type.acmeweather.weather-with-group.group.forecastTomorrow.label = Wettervorhersage morgen
+thing-type.acmeweather.weather-with-group.group.forecastTomorrow.description = Wettervorhersage für den morgigen Tag.
+thing-type.acmeweather.weather.label = Wetterinformation
+thing-type.acmeweather.weather.channel.minTemperature.label = Min. Temperatur
+thing-type.acmeweather.weather.channel.minTemperature.description = Minimale Temperatur in Grad Celsius (Metrisch) oder Fahrenheit (Imperial).
+
+# thing types config
+
+thing-type.config.acmeweather.weather.language.label = Sprache
+thing-type.config.acmeweather.weather.language.description = Sprache zur Anzeige der Daten.
+thing-type.config.acmeweather.weather.language.option.nl = Holländisch
+thing-type.config.acmeweather.weather.language.option.de = Deutsch
+thing-type.config.acmeweather.weather.language.option.en = Englisch
+thing-type.config.acmeweather.weather.language.option.fr = Französisch
+thing-type.config.acmeweather.weather.refreshInterval.label = Abfrageintervall
+thing-type.config.acmeweather.weather.refreshInterval.description = Intervall zur Abfrage der OpenWeatherMap API (in min).
+
+# channel group types
+
+channel-group-type.acmeweather.forecast.label = Wetterinformation mit Gruppe
+channel-group-type.acmeweather.forecast.description = Wetterinformation mit Gruppe Beschreibung.
+channel-group-type.acmeweather.forecast.channel.maxTemperature.label = Max. Temperatur
+channel-group-type.acmeweather.forecast.channel.maxTemperature.description = Maximale vorhergesagte Temperatur in Grad Celsius (Metrisch) oder Fahrenheit (Imperial).
+channel-group-type.acmeweather.forecast.channel.minTemperature.label = Min. Temperatur
+channel-group-type.acmeweather.forecast.channel.minTemperature.description = Minimale vorhergesagte Temperatur in Grad Celsius (Metrisch) oder Fahrenheit (Imperial).
+
+# channel types
+
+channel-type.acmeweather.temperature.label = Temperatur
+channel-type.acmeweather.temperature.description = Temperatur in Grad Celsius (Metrisch) oder Fahrenheit (Imperial).
+channel-type.acmeweather.temperature.state.option.VALUE = Mein String
+channel-type.acmeweather.time-stamp.label = Letzte Messung
+channel-type.acmeweather.time-stamp.description = Zeigt den Zeitpunkt der letzten Messung an.
+channel-type.acmeweather.time-stamp.state.pattern = %1$td.%1$tm.%1$tY %1$tH:%1$tM:%1$tS
+
+# custom
+
+CUSTOM_KEY = Stellt verschiedene Wetterdaten vom ACME Wetterdienst bereit
diff --git a/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/thing/channel-types.xml b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/thing/channel-types.xml
new file mode 100644
index 00000000000..92bfb684b73
--- /dev/null
+++ b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/thing/channel-types.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+ Weather information group description.
+
+
+
+
+ Minimum forecasted temperature in degrees Celsius (metric) or Fahrenheit (imperial).
+
+
+
+ Maximum forecasted temperature in degrees Celsius (metric) or Fahrenheit (imperial).
+
+
+
+
+
+
+ Number
+
+ Temperature in degrees Celsius (metric) or Fahrenheit (imperial).
+
+
+
+
+
+
+
+
+ DateTime
+
+ Time of data observation.
+ Time
+
+
+
+
diff --git a/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/thing/thing-types.xml b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/thing/thing-types.xml
new file mode 100644
index 00000000000..13970984c66
--- /dev/null
+++ b/tools/i18n-plugin/src/test/resources/acmeweather.bundle/OH-INF/thing/thing-types.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+ @text/CUSTOM_KEY
+
+
+
+
+
+ Minimum temperature in degrees Celsius (metric) or Fahrenheit (imperial).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This is the weather forecast for today.
+
+
+
+ This is the weather forecast for tomorrow.
+
+
+
+
+
diff --git a/tools/i18n-plugin/src/test/resources/infoless.bundle/.gitkeep b/tools/i18n-plugin/src/test/resources/infoless.bundle/.gitkeep
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tools/pom.xml b/tools/pom.xml
index 7a378d4edbf..dc5ec289e94 100644
--- a/tools/pom.xml
+++ b/tools/pom.xml
@@ -18,6 +18,7 @@
archetype
+ i18n-plugin