diff --git a/bundles/org.openhab.transform.map/README.md b/bundles/org.openhab.transform.map/README.md index ebd0873649b..efc8317f1e5 100644 --- a/bundles/org.openhab.transform.map/README.md +++ b/bundles/org.openhab.transform.map/README.md @@ -1,15 +1,30 @@ # Map Transformation Service -Transforms the input by mapping it to another string. It expects the mappings to be read from a file which is stored under the `transform` folder. -The file name must have the `.map` extension. +Transforms the input by mapping it to another string. -This file should be in property syntax, i.e. simple lines with "key=value" pairs. -The file format is documented [here](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Properties.html#load(java.io.Reader)). -To organize the various transformations one might use subfolders. +## Map Syntax -A default value can be provided if no matching entry is found by using "=value" syntax. +The mapping is performed based on "key=value" pairs. +When the input matches a `key` in the mapping table, the corresponding `value` is given as the output of the transformation. + +The format of the mapping table is documented [here](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Properties.html#load(java.io.Reader)). + +A default value can be provided if no matching entry is found by using "=value" syntax. Defining this default value using `_source_` would then return the non transformed input string. +## File-based Map + +The mapping table can be stored in a file under the `transform` folder. +The file name must have the `.map` extension. + +To organize the various transformations one might use subfolders. + +## Inline Map + +Instead of providing the file name from which to load, the mapping table can be specified inline by prefixing it with the `|` character. +The "key=value" pairs are separated with a semicolon (`;`) or a newline character. + +For example, the following map function translates open/closed to ON/OFF: `|open=ON; closed=OFF` ## Example @@ -33,7 +48,6 @@ white\ space=using escape | `white space` | `using escape` | | `anything` | `default` | - ## Usage as a Profile The functionality of this `TransformationService` can be used in a `Profile` on an `ItemChannelLink` too. diff --git a/bundles/org.openhab.transform.map/src/main/java/org/openhab/transform/map/internal/MapTransformationService.java b/bundles/org.openhab.transform.map/src/main/java/org/openhab/transform/map/internal/MapTransformationService.java index 076be76ef06..c17d5be0b47 100644 --- a/bundles/org.openhab.transform.map/src/main/java/org/openhab/transform/map/internal/MapTransformationService.java +++ b/bundles/org.openhab.transform.map/src/main/java/org/openhab/transform/map/internal/MapTransformationService.java @@ -16,11 +16,15 @@ import java.io.IOException; import java.io.StringReader; import java.net.URI; import java.util.Collection; +import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; +import java.util.Map.Entry; import java.util.Properties; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -56,10 +60,12 @@ public class MapTransformationService private static final String PROFILE_CONFIG_URI = "profile:transform:MAP"; private static final String CONFIG_PARAM_FUNCTION = "function"; private static final Set SUPPORTED_CONFIGURATION_TYPES = Set.of("map"); + private static final Pattern INLINE_MAP_CONFIG_PATTERN = Pattern.compile("\\s*\\|(?.+)", Pattern.DOTALL); private final Logger logger = LoggerFactory.getLogger(MapTransformationService.class); private final TransformationRegistry transformationRegistry; private final Map cachedTransformations = new ConcurrentHashMap<>(); + private final Map cachedInlineMap = new LRUMap<>(1000); @Activate public MapTransformationService(@Reference TransformationRegistry transformationRegistry) { @@ -74,30 +80,51 @@ public class MapTransformationService @Override public @Nullable String transform(String function, String source) throws TransformationException { - // always get a configuration from the registry to account for changed system locale - Transformation transformation = transformationRegistry.get(function, null); + Properties properties = null; - if (transformation != null) { - if (!cachedTransformations.containsKey(transformation.getUID())) { - importConfiguration(transformation); - } - Properties properties = cachedTransformations.get(transformation.getUID()); - if (properties != null) { - String target = properties.getProperty(source); - - if (target == null) { - target = properties.getProperty(""); - if (target == null) { - throw new TransformationException("Target value not found in map for '" + source + "'"); - } else if (SOURCE_VALUE.equals(target)) { - target = source; - } + Matcher matcher = INLINE_MAP_CONFIG_PATTERN.matcher(function); + if (matcher.matches()) { + properties = cachedInlineMap.computeIfAbsent(function, f -> { + Properties props = new Properties(); + String map = matcher.group("map").trim(); + if (!map.contains("\n")) { + map = map.replace(";", "\n"); + } + try { + props.load(new StringReader(map)); + logger.trace("Parsed inline map configuration '{}'", props); + } catch (IOException e) { + logger.warn("Failed to parse inline map configuration '{}': {}", map, e.getMessage()); + return null; + } + return props; + }); + } else { + // always get a configuration from the registry to account for changed system locale + Transformation transformation = transformationRegistry.get(function, null); + if (transformation != null) { + properties = cachedTransformations.get(transformation.getUID()); + if (properties == null) { + properties = importConfiguration(transformation); } - - logger.debug("Transformation resulted in '{}'", target); - return target; } } + + if (properties != null) { + String target = properties.getProperty(source); + + if (target == null) { + target = properties.getProperty(""); + if (target == null) { + throw new TransformationException("Target value not found in map for '" + source + "'"); + } else if (SOURCE_VALUE.equals(target)) { + target = source; + } + } + + logger.debug("Transformation resulted in '{}'", target); + return target; + } throw new TransformationException("Could not find configuration '" + function + "' or failed to parse it."); } @@ -131,19 +158,34 @@ public class MapTransformationService } } - private void importConfiguration(@Nullable Transformation transformation) { + private @Nullable Properties importConfiguration(@Nullable Transformation transformation) { if (transformation != null) { try { Properties properties = new Properties(); String function = transformation.getConfiguration().get(Transformation.FUNCTION); if (function == null || function.isBlank()) { logger.warn("Function not defined for transformation '{}'", transformation.getUID()); - return; + return null; } properties.load(new StringReader(function)); cachedTransformations.put(transformation.getUID(), properties); + return properties; } catch (IOException ignored) { } } + return null; + } + + class LRUMap extends LinkedHashMap { + private final int maxEntries; + + public LRUMap(int maxEntries) { + super(10, 0.75f, true); + this.maxEntries = maxEntries; + } + + protected boolean removeEldestEntry(@Nullable Entry eldest) { + return size() > maxEntries; + } } } diff --git a/bundles/org.openhab.transform.map/src/test/java/org/openhab/transform/map/internal/MapTransformationServiceTest.java b/bundles/org.openhab.transform.map/src/test/java/org/openhab/transform/map/internal/MapTransformationServiceTest.java index 47fd29a1559..28fd8ead873 100644 --- a/bundles/org.openhab.transform.map/src/test/java/org/openhab/transform/map/internal/MapTransformationServiceTest.java +++ b/bundles/org.openhab.transform.map/src/test/java/org/openhab/transform/map/internal/MapTransformationServiceTest.java @@ -152,4 +152,28 @@ public class MapTransformationServiceTest extends JavaTest { // ensure modified configuration is applied assertEquals("fermé", processor.transform(NON_DEFAULTED_TRANSFORMATION_DE, SOURCE_CLOSED)); } + + @Test + public void oneLineInlineMapTest() throws TransformationException { + String transformation = "|key1=semicolons_are_the_separators ; key2 = value2"; + assertEquals("value2", processor.transform(transformation, "key2")); + } + + @Test + public void multiLineInlineMapTest() throws TransformationException { + String transformation = "|key1=semicolons_arent_separators;1 \n key2 = value;2"; + assertEquals("value;2", processor.transform(transformation, "key2")); + } + + @Test + public void defaultInlineTest() throws TransformationException { + String transformation = "|key1=value1;key2=value;=default"; + assertEquals("default", processor.transform(transformation, "nonexistent")); + } + + @Test + public void defaultSourceInlineTest() throws TransformationException { + String transformation = "|key1=value1;key2=value;=_source_"; + assertEquals("nonexistent", processor.transform(transformation, "nonexistent")); + } }