[i18n] Allow generation of default translations for automation modules (#2966)

* Allow generation of default translations for automation modules

Signed-off-by: Jan N. Klug <github@klug.nrw>
This commit is contained in:
J-N-K
2022-05-29 18:26:23 +02:00
committed by GitHub
parent 344a9ae018
commit d6c269d95a
13 changed files with 372 additions and 4 deletions
+5
View File
@@ -68,6 +68,11 @@
<artifactId>org.openhab.core.thing.xml</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.9</version>
</dependency>
<dependency>
<groupId>org.openhab.core.bom</groupId>
<artifactId>org.openhab.core.bom.test</artifactId>
@@ -25,6 +25,8 @@ 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 com.google.gson.JsonObject;
/**
* The bundle information provided by the openHAB XML files in the <code>OH-INF</code> directory.
*
@@ -39,6 +41,8 @@ public class BundleInfo {
private List<ChannelGroupTypeXmlResult> channelGroupTypesXml = new ArrayList<>(5);
private List<ChannelTypeXmlResult> channelTypesXml = new ArrayList<>(5);
private List<ThingTypeXmlResult> thingTypesXml = new ArrayList<>(5);
private List<JsonObject> moduleTypesJson = new ArrayList<>(5);
private List<JsonObject> ruleTemplateJson = new ArrayList<>(5);
public String getBindingId() {
return bindingId;
@@ -80,6 +84,22 @@ public class BundleInfo {
this.channelTypesXml = channelTypesXml;
}
public List<JsonObject> getModuleTypesJson() {
return moduleTypesJson;
}
public void setModuleTypesJson(List<JsonObject> moduleTypesJson) {
this.moduleTypesJson = moduleTypesJson;
}
public List<JsonObject> getRuleTemplateJson() {
return ruleTemplateJson;
}
public void setRuleTemplateJson(List<JsonObject> ruleTemplateJson) {
this.ruleTemplateJson = ruleTemplateJson;
}
public List<ThingTypeXmlResult> getThingTypesXml() {
return thingTypesXml;
}
@@ -17,7 +17,11 @@ import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import org.apache.maven.plugin.logging.Log;
import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -30,6 +34,10 @@ 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.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.thoughtworks.xstream.converters.ConversionException;
/**
@@ -42,6 +50,8 @@ public class BundleInfoReader {
private final Log log;
private final Predicate<Path> isJsonFile = path -> Files.isRegularFile(path) && path.toString().endsWith(".json");
public BundleInfoReader(Log log) {
this.log = log;
}
@@ -51,6 +61,8 @@ public class BundleInfoReader {
readBindingInfo(ohinfPath, bundleInfo);
readConfigInfo(ohinfPath, bundleInfo);
readThingInfo(ohinfPath, bundleInfo);
readModuleTypeInfo(ohinfPath, bundleInfo);
readRuleTemplateInfo(ohinfPath, bundleInfo);
return bundleInfo;
}
@@ -129,4 +141,43 @@ public class BundleInfoReader {
}
});
}
private void readModuleTypeInfo(Path ohinfPath, BundleInfo bundleInfo) throws IOException {
Path modulePath = ohinfPath.resolve("automation").resolve("moduletypes");
if (Files.exists(modulePath)) {
try (Stream<Path> files = Files.walk(modulePath)) {
List<JsonObject> moduleTypes = files.filter(isJsonFile).flatMap(this::readJsonElementsFromFile)
.map(JsonElement::getAsJsonObject).collect(Collectors.toList());
if (!moduleTypes.isEmpty()) {
bundleInfo.setModuleTypesJson(moduleTypes);
}
}
}
}
private void readRuleTemplateInfo(Path ohinfPath, BundleInfo bundleInfo) throws IOException {
Path template = ohinfPath.resolve("automation").resolve("templates");
if (Files.exists(template)) {
try (Stream<Path> files = Files.walk(template)) {
List<JsonObject> ruleTemplates = files.filter(isJsonFile).flatMap(this::readJsonElementsFromFile)
.map(JsonElement::getAsJsonObject).collect(Collectors.toList());
if (!ruleTemplates.isEmpty()) {
bundleInfo.setRuleTemplateJson(ruleTemplates);
}
}
}
}
private Stream<JsonElement> readJsonElementsFromFile(Path path) {
try {
JsonElement element = JsonParser.parseString(Files.readString(path));
if (element.isJsonObject()) {
return element.getAsJsonObject().entrySet().stream().map(Map.Entry::getValue)
.filter(JsonElement::isJsonArray)
.flatMap(a -> StreamSupport.stream(a.getAsJsonArray().spliterator(), false));
}
} catch (IOException ignored) {
}
return Stream.of(JsonNull.INSTANCE);
}
}
@@ -111,6 +111,9 @@ public class GenerateDefaultTranslationsMojo extends AbstractI18nMojo {
XmlToTranslationsConverter xmlConverter = new XmlToTranslationsConverter();
Translations generatedTranslations = xmlConverter.convert(bundleInfo);
JsonToTranslationsConverter jsonConverter = new JsonToTranslationsConverter();
jsonConverter.convert(bundleInfo).sections.forEach(generatedTranslations::addSection);
PropertiesToTranslationsConverter propertiesConverter = new PropertiesToTranslationsConverter(getLog());
Translations existingTranslations = propertiesConverter.convert(defaultTranslationsPath);
@@ -0,0 +1,154 @@
/**
* Copyright (c) 2010-2022 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 java.util.Comparator;
import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.Stream.Builder;
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;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
/**
* Converts JSON based {@link BundleInfo} to {@link Translations}.
*
* @author Jan N. Klug - Initial contribution
*/
@NonNullByDefault
public class JsonToTranslationsConverter {
private static final Pattern OPTION_ESCAPE_PATTERN = Pattern.compile("([ :=])");
public Translations convert(BundleInfo bundleInfo) {
return new Translations(Stream.of( //
moduleTypeSection(bundleInfo), ruleTemplateSection(bundleInfo)//
).flatMap(Function.identity()).collect(Collectors.toList()));
}
private Stream<TranslationsSection> moduleTypeSection(BundleInfo bundleInfo) {
Builder<TranslationsSection> sectionBuilder = Stream.builder();
bundleInfo.getModuleTypesJson().stream().flatMap(this::getModuleType).forEach(sectionBuilder::add);
return sectionBuilder.build().sorted(Comparator.comparing(s -> s.header));
}
private Stream<TranslationsSection> ruleTemplateSection(BundleInfo bundleInfo) {
Builder<TranslationsSection> sectionBuilder = Stream.builder();
bundleInfo.getRuleTemplateJson().stream().flatMap(this::getRuleTemplate).forEach(sectionBuilder::add);
return sectionBuilder.build().sorted(Comparator.comparing(s -> s.header));
}
private Stream<TranslationsSection> getModuleType(JsonObject moduleType) {
String uid = getAsString(moduleType, "uid").orElse("");
if (uid.isBlank()) {
return Stream.of();
}
String globalPrefix = "module-type." + uid + ".";
Builder<TranslationsGroup> groupBuilder = Stream.builder();
// global entries
Builder<TranslationsEntry> entriesBuilder = Stream.builder();
getAsString(moduleType, "label").ifPresent(label -> entriesBuilder.add(entry(globalPrefix + "label", label)));
getAsString(moduleType, "description")
.ifPresent(description -> entriesBuilder.add(entry(globalPrefix + "description", description)));
groupBuilder.add(group(entriesBuilder.build()));
// configDescriptions
JsonElement configDescriptionsElement = moduleType.get("configDescriptions");
if (configDescriptionsElement != null) {
String prefix = globalPrefix + "config.";
groupBuilder.add(getGroupFromArray(configDescriptionsElement, prefix));
}
// inputs
JsonElement inputsElement = moduleType.get("inputs");
if (inputsElement != null) {
String prefix = globalPrefix + "input.";
groupBuilder.add(getGroupFromArray(inputsElement, prefix));
}
// outputs
JsonElement outputsElement = moduleType.get("outputs");
if (outputsElement != null) {
String prefix = globalPrefix + "output.";
groupBuilder.add(getGroupFromArray(outputsElement, prefix));
}
return Stream.of(new TranslationsSection(uid, groupBuilder.build().collect(Collectors.toList())));
}
private Stream<TranslationsSection> getRuleTemplate(JsonObject ruleTemplate) {
String uid = getAsString(ruleTemplate, "uid").orElse("");
if (uid.isBlank()) {
return Stream.of();
}
String globalPrefix = "rule-template." + uid + ".";
Builder<TranslationsGroup> groupBuilder = Stream.builder();
// global entries
Builder<TranslationsEntry> entriesBuilder = Stream.builder();
getAsString(ruleTemplate, "label").ifPresent(label -> entriesBuilder.add(entry(globalPrefix + "label", label)));
getAsString(ruleTemplate, "description")
.ifPresent(description -> entriesBuilder.add(entry(globalPrefix + "description", description)));
groupBuilder.add(group(entriesBuilder.build()));
return Stream.of(new TranslationsSection(uid, groupBuilder.build().collect(Collectors.toList())));
}
private TranslationsGroup getGroupFromArray(JsonElement parentElement, String prefix) {
Builder<TranslationsEntry> entriesBuilder = Stream.builder();
for (JsonElement jsonElement : parentElement.getAsJsonArray()) {
JsonObject jsonObject = jsonElement.getAsJsonObject();
getAsString(jsonObject, "name").ifPresent(name -> {
String namePrefix = prefix + name + ".";
getAsString(jsonObject, "label")
.ifPresent(label -> entriesBuilder.add(entry(namePrefix + "label", label)));
getAsString(jsonObject, "description")
.ifPresent(description -> entriesBuilder.add(entry(namePrefix + "description", description)));
JsonElement optionsElement = jsonObject.get("options");
if (optionsElement != null) {
for (JsonElement optionElement : optionsElement.getAsJsonArray()) {
JsonObject optionObject = optionElement.getAsJsonObject();
getAsString(optionObject, "value")
.ifPresent(value -> getAsString(optionObject, "label").ifPresent(label -> {
String optionKey = namePrefix + "option."
+ OPTION_ESCAPE_PATTERN.matcher(value).replaceAll("\\\\$1");
entriesBuilder.add(entry(optionKey, label));
}));
}
}
});
}
return group(entriesBuilder.build());
}
private Optional<String> getAsString(JsonObject object, String key) {
JsonElement element = object.get(key);
return element == null ? Optional.empty() : Optional.of(element.getAsString());
}
}
@@ -49,6 +49,8 @@ public class BundleInfoReaderTest {
assertThat(bundleInfo.getChannelTypesXml().size(), is(2));
assertThat(bundleInfo.getConfigDescriptions().size(), is(1));
assertThat(bundleInfo.getThingTypesXml().size(), is(2));
assertThat(bundleInfo.getModuleTypesJson().size(), is(2));
assertThat(bundleInfo.getRuleTemplateJson().size(), is(0));
}
@Test
@@ -62,6 +64,8 @@ public class BundleInfoReaderTest {
assertThat(bundleInfo.getChannelTypesXml().size(), is(0));
assertThat(bundleInfo.getConfigDescriptions().size(), is(1));
assertThat(bundleInfo.getThingTypesXml().size(), is(0));
assertThat(bundleInfo.getModuleTypesJson().size(), is(0));
assertThat(bundleInfo.getRuleTemplateJson().size(), is(0));
}
@Test
@@ -75,5 +79,7 @@ public class BundleInfoReaderTest {
assertThat(bundleInfo.getChannelTypesXml().size(), is(0));
assertThat(bundleInfo.getConfigDescriptions().size(), is(0));
assertThat(bundleInfo.getThingTypesXml().size(), is(0));
assertThat(bundleInfo.getModuleTypesJson().size(), is(0));
assertThat(bundleInfo.getRuleTemplateJson().size(), is(0));
}
}
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2010-2022 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.containsString;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
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 JsonToTranslationsConverter}.
*
* @author Jan N. Klug - Initial contribution
*/
@NonNullByDefault
public class JsonToTranslationsConverterTest {
@Test
public void convertModuleTypeInfo() throws IOException {
BundleInfoReader reader = new BundleInfoReader(new SystemStreamLog());
BundleInfo bundleInfo = reader.readBundleInfo(Path.of("src/test/resources/acmeweather.bundle/OH-INF"));
JsonToTranslationsConverter converter = new JsonToTranslationsConverter();
Translations translations = converter.convert(bundleInfo);
assertThat(translations.hasTranslations(), is(true));
assertThat(translations.sections.size(), is(2));
assertThat(translations.keysStream().count(), is(10L));
String lines = translations.linesStream().collect(Collectors.joining(System.lineSeparator()));
assertThat(lines, containsString("acme.WeatherBadTrigger.label = Weather Bad Trigger"));
assertThat(lines, containsString(
"module-type.acme.WeatherBadTrigger.config.minimumLevel.description = The minimum level that results in a trigger"));
assertThat(lines, containsString("module-type.acme.WeatherBadTrigger.output.level.label = Level"));
}
}
@@ -38,8 +38,8 @@ public class PropertiesToTranslationsConverterTest {
.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(34L));
assertThat(translations.sections.size(), is(8));
assertThat(translations.keysStream().count(), is(44L));
String lines = translations.linesStream().collect(Collectors.joining(System.lineSeparator()));
assertThat(lines, containsString("# binding"));
@@ -0,0 +1,43 @@
{
"triggers": [
{
"uid": "acme.WeatherBadTrigger",
"label": "Weather Bad Trigger",
"description": "Triggers when weather is bad",
"configDescriptions": [
{
"name": "minimumLevel",
"type": "INTEGER",
"label": "Minimum Level",
"description": "The minimum level that results in a trigger",
"required": true,
"default": "5"
}
],
"outputs": [
{
"name": "level",
"type": "java.lang.Integer",
"label": "Level",
"description": "The level that triggered this event."
}
]
}
],
"conditions": [
{
"uid": "acme.WeatherCondition",
"label": "the weather is good",
"description": "Compares the weather to a weather level (0 - 10)",
"configDescriptions": [
{
"name": "threshold",
"type": "INTEGER",
"label": "Threshold",
"description": "the maximum weather level that is considered good",
"required": true
}
]
}
]
}
@@ -46,3 +46,19 @@ channel-type.acmeweather.temperature.state.option.VALUE\=3 = My label 3
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
# acme.WeatherBadTrigger
module-type.acme.WeatherBadTrigger.label = Weather Bad Trigger
module-type.acme.WeatherBadTrigger.description = Triggers when weather is bad
module-type.acme.WeatherBadTrigger.config.minimumLevel.label = Minimum Level
module-type.acme.WeatherBadTrigger.config.minimumLevel.description = The minimum level that results in a trigger
module-type.acme.WeatherBadTrigger.output.level.label = Level
module-type.acme.WeatherBadTrigger.output.level.description = The level that triggered this event.
# acme.WeatherCondition
module-type.acme.WeatherCondition.label = the weather is good
module-type.acme.WeatherCondition.description = Compares the weather to a weather level (0 - 10)
module-type.acme.WeatherCondition.config.threshold.label = Threshold
module-type.acme.WeatherCondition.config.threshold.description = the maximum weather level that is considered good
@@ -47,6 +47,22 @@ 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
# acme.WeatherBadTrigger
module-type.acme.WeatherBadTrigger.label = Weather Bad Trigger
module-type.acme.WeatherBadTrigger.description = Triggers when weather is bad
module-type.acme.WeatherBadTrigger.config.minimumLevel.label = Minimum Level
module-type.acme.WeatherBadTrigger.config.minimumLevel.description = The minimum level that results in a trigger
module-type.acme.WeatherBadTrigger.output.level.label = Level
module-type.acme.WeatherBadTrigger.output.level.description = The level that triggered this event.
# acme.WeatherCondition
module-type.acme.WeatherCondition.label = the weather is good
module-type.acme.WeatherCondition.description = Compares the weather to a weather level (0 - 10)
module-type.acme.WeatherCondition.config.threshold.label = Threshold
module-type.acme.WeatherCondition.config.threshold.description = the maximum weather level that is considered good
# custom
CUSTOM_KEY = Provides various weather data from the ACME weather service