diff --git a/tools/upgradetool/pom.xml b/tools/upgradetool/pom.xml index 4d8ace94c..2ad1619a2 100644 --- a/tools/upgradetool/pom.xml +++ b/tools/upgradetool/pom.xml @@ -43,6 +43,11 @@ org.openhab.core.persistence ${project.version} + + org.openhab.core.bundles + org.openhab.core.semantics + ${project.version} + org.openhab.core.bundles org.openhab.core.storage.json @@ -92,6 +97,11 @@ ${jackson.version} compile + + org.apache.maven + maven-artifact + ${maven.version} + org.slf4j slf4j-api diff --git a/tools/upgradetool/src/main/java/org/openhab/core/tools/UpgradeTool.java b/tools/upgradetool/src/main/java/org/openhab/core/tools/UpgradeTool.java index 380147e28..b0ec60570 100644 --- a/tools/upgradetool/src/main/java/org/openhab/core/tools/UpgradeTool.java +++ b/tools/upgradetool/src/main/java/org/openhab/core/tools/UpgradeTool.java @@ -12,11 +12,14 @@ */ package org.openhab.core.tools; +import java.io.FileInputStream; import java.io.IOException; import java.io.PrintStream; +import java.nio.file.Files; import java.nio.file.Path; import java.time.ZonedDateTime; import java.util.List; +import java.util.Properties; import java.util.Set; import org.apache.commons.cli.CommandLine; @@ -25,6 +28,7 @@ import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import org.apache.commons.cli.ParseException; import org.apache.commons.cli.help.HelpFormatter; +import org.apache.maven.artifact.versioning.ComparableVersion; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.storage.json.internal.JsonStorage; @@ -38,6 +42,8 @@ import org.slf4j.LoggerFactory; * @author Jan N. Klug - Initial contribution * @author Jimmy Tanagra - Refactor upgraders into individual classes * @author Mark Herwege - Added persistence strategy upgrader + * @author Mark Herwege - Added semantic tag upgrader + * @author Mark Herwege - Track OH versions and let upgraders set a target version */ @NonNullByDefault public class UpgradeTool { @@ -46,24 +52,31 @@ public class UpgradeTool { private static final String OPT_LIST_COMMANDS = "list-commands"; private static final String OPT_USERDATA_DIR = "userdata"; private static final String OPT_CONF_DIR = "conf"; + private static final String OPT_OH_VERSION = "version"; private static final String OPT_LOG = "log"; private static final String OPT_FORCE = "force"; private static final String ENV_USERDATA = "OPENHAB_USERDATA"; private static final String ENV_CONF = "OPENHAB_CONF"; + private static final String UPGRADE_TOOL_VERSION_KEY = "UpgradeTool"; + private static final List UPGRADERS = List.of( // - new ItemUnitToMetadataUpgrader(), // - new JSProfileUpgrader(), // - new ScriptProfileUpgrader(), // - new YamlConfigurationV1TagsUpgrader(), // Added in 5.0 - new HomeAssistantAddonUpgrader(), // Added in 5.1 - new HomieAddonUpgrader(), // Added in 5.1 - new PersistenceUpgrader() // Added in 5.1 + new ItemUnitToMetadataUpgrader(), // Since 4.0.0 + new JSProfileUpgrader(), // Since 4.0.0 + new ScriptProfileUpgrader(), // Since 4.2.0 + new YamlConfigurationV1TagsUpgrader(), // Since 5.0 + new HomeAssistantAddonUpgrader(), // Since 5.1 + new HomieAddonUpgrader(), // Since 5.1 + new PersistenceUpgrader(), // Since 5.1 + new SemanticTagUpgrader() // Since 5.2 ); private static final Logger LOGGER = LoggerFactory.getLogger(UpgradeTool.class); private static @Nullable JsonStorage upgradeRecords = null; + private static @Nullable JsonStorage ohVersionRecords = null; + + private static VersionRecord ohTargetVersion = new VersionRecord(); private static Options getOptions() { Options options = new Options(); @@ -74,6 +87,9 @@ public class UpgradeTool { options.addOption(Option.builder().longOpt(OPT_CONF_DIR).desc( "CONF directory to process. Enclose it in double quotes to ensure that any backslashes are not ignored by your command shell.") .numberOfArgs(1).get()); + options.addOption(Option.builder().longOpt(OPT_OH_VERSION).desc( + "openHAB target version. Upgraders will be executed again if they have been executed in an upgrade to an earlier openHAB version and there are new changes.") + .numberOfArgs(1).get()); options.addOption(Option.builder().longOpt(OPT_COMMAND).numberOfArgs(1) .desc("command to execute (executes all if omitted)").get()); options.addOption(Option.builder().longOpt(OPT_LIST_COMMANDS).desc("list available commands").get()); @@ -83,7 +99,7 @@ public class UpgradeTool { return options; } - public static void main(String[] args) { + public static void main(String[] args) throws IOException { Options options = getOptions(); try { CommandLine commandLine = new DefaultParser().parse(options, args); @@ -117,22 +133,39 @@ public class UpgradeTool { Path confPath = getPath("conf", commandLine, OPT_CONF_DIR, ENV_CONF); if (userdataPath != null) { - Path upgradeJsonDatabasePath = userdataPath - .resolve(Path.of("jsondb", "org.openhab.core.tools.UpgradeTool")); - upgradeRecords = new JsonStorage<>(upgradeJsonDatabasePath.toFile(), null, 5, 0, 0, List.of()); + upgradeRecords = getUpgradeRecordsStorage(userdataPath); + Path ohVersionJsonDatabasePath = userdataPath + .resolve(Path.of("jsondb", "org.openhab.core.tools.ohVersion.json")); + ohVersionRecords = new JsonStorage<>(ohVersionJsonDatabasePath.toFile(), null, 5, 0, 0, List.of()); + ohTargetVersion = commandLine.hasOption(OPT_OH_VERSION) + ? new VersionRecord(commandLine.getOptionValue(OPT_OH_VERSION)) + : getTargetVersion(userdataPath); } else { - LOGGER.warn("Upgrade records storage is not initialized."); + LOGGER.warn("USERDATA not initialized. Unable to retrieve and save upgrade records."); } - UPGRADERS.forEach(upgrader -> { String upgraderName = upgrader.getName(); if (command != null && !upgraderName.equals(command)) { return; } - if (!force && lastExecuted(upgraderName) instanceof String executionDate) { - LOGGER.info("Already executed '{}' on {}. Use '--force' to execute it again.", upgraderName, - executionDate); - return; + if (!force) { + if (upgrader.getTargetVersion() instanceof String targetVersion) { + if (lastExecutedVersion(upgraderName) instanceof String executionVersion) { + if (compareVersions(executionVersion, targetVersion) >= 0) { + LOGGER.info("Already executed '{}' to version {}. Use '--force' to execute it again.", + upgraderName, executionVersion); + return; + } + } else if (lastExecuted(upgraderName) instanceof String executionDate) { + LOGGER.info("Already executed '{}' on {}. Use '--force' to execute it again.", upgraderName, + executionDate); + return; + } + } else if (lastExecuted(upgraderName) instanceof String executionDate) { + LOGGER.info("Already executed '{}' on {}. Use '--force' to execute it again.", upgraderName, + executionDate); + return; + } } try { LOGGER.info("Executing {}: {}", upgraderName, upgrader.getDescription()); @@ -143,6 +176,12 @@ public class UpgradeTool { LOGGER.error("Error executing upgrader {}: {}", upgraderName, e.getMessage()); } }); + + // Only save version record if all upgraders have been executed + if (command == null) { + updateVersionRecord(); + } + } catch (ParseException e) { HelpFormatter formatter = HelpFormatter.builder().get(); try { @@ -155,6 +194,32 @@ public class UpgradeTool { System.exit(0); } + private static JsonStorage getUpgradeRecordsStorage(Path userdataPath) { + // The old storage name did not have a json extension. If the new storage name does not exist yet, create it and + // move the data from old to new. + Path upgradeJsonDatabasePath = userdataPath + .resolve(Path.of("jsondb", "org.openhab.core.tools.UpgradeTool.json")); + JsonStorage upgradeRecordStorage = new JsonStorage<>(upgradeJsonDatabasePath.toFile(), null, 5, + 0, 0, List.of()); + if (!Files.exists(upgradeJsonDatabasePath)) { + Path oldUpgradeJsonDatabasePath = userdataPath + .resolve(Path.of("jsondb", "org.openhab.core.tools.UpgradeTool")); + if (Files.isReadable(oldUpgradeJsonDatabasePath)) { + JsonStorage oldUpgradeRecordStorage = new JsonStorage<>( + oldUpgradeJsonDatabasePath.toFile(), null, 5, 0, 0, List.of()); + oldUpgradeRecordStorage.stream() + .forEach(entry -> upgradeRecordStorage.put(entry.getKey(), entry.getValue())); + upgradeRecordStorage.flush(); + try { + Files.delete(oldUpgradeJsonDatabasePath); + } catch (IOException e) { + LOGGER.debug("Failed to delete upgrade tool storage with old name."); + } + } + } + return upgradeRecordStorage; + } + /** * Returns the path to the given directory, either from the command line or from the environment variable. * If neither is set, it defaults to a relative subdirectory of the given pathName ('./userdata' or './conf'). @@ -189,12 +254,57 @@ public class UpgradeTool { } } + private static VersionRecord getTargetVersion(Path userdataPath) { + Path versionFilePath = userdataPath.resolve(Path.of("etc", "version.properties")); + Properties prop = new Properties(); + try (FileInputStream fis = new FileInputStream(versionFilePath.toFile())) { + prop.load(fis); + String build = prop.getProperty("build-no"); + String distro = prop.getProperty("openhab-distro"); + String core = prop.getProperty("openhab-core"); + String addons = prop.getProperty("openhab-addons"); + String karaf = prop.getProperty("karaf"); + return new VersionRecord(build, distro, core, addons, karaf); + } catch (IOException e) { + LOGGER.warn( + "Cannot retrieve OH core version from '{}' file. Some tasks may fail. You can provide the target version through the --version option.", + versionFilePath); + } + return new VersionRecord(); + } + + private static int compareVersions(String versionA, String versionB) { + ComparableVersion version1 = new ComparableVersion(versionA); + ComparableVersion version2 = new ComparableVersion(versionB); + return version1.compareTo(version2); + } + private static @Nullable String lastExecuted(String upgrader) { - JsonStorage records = upgradeRecords; - if (records != null) { - UpgradeRecord upgradeRecord = records.get(upgrader); + JsonStorage upgrades = upgradeRecords; + if (upgrades != null) { + UpgradeRecord upgradeRecord = upgrades.get(upgrader); if (upgradeRecord != null) { - return upgradeRecord.executionDate; + return upgradeRecord.executionDate(); + } + } + return null; + } + + private static @Nullable String lastExecutedVersion(String upgrader) { + JsonStorage upgrades = upgradeRecords; + if (upgrades != null && upgrades.get(upgrader) instanceof UpgradeRecord upgradeRecord) { + String executionVersion = upgradeRecord.executionVersion(); + if (executionVersion != null && !executionVersion.isEmpty()) { + return executionVersion; + } + // Legacy records may lack an executionVersion; in that case, fall back to the + // global core version recorded by the upgrade tool. + JsonStorage versions = ohVersionRecords; + if (versions != null) { + VersionRecord versionRecord = versions.get(UPGRADE_TOOL_VERSION_KEY); + if (versionRecord != null) { + return versionRecord.core(); + } } } return null; @@ -203,7 +313,15 @@ public class UpgradeTool { private static void updateUpgradeRecord(String upgrader) { JsonStorage records = upgradeRecords; if (records != null) { - records.put(upgrader, new UpgradeRecord(ZonedDateTime.now())); + records.put(upgrader, new UpgradeRecord(ZonedDateTime.now(), ohTargetVersion.core())); + records.flush(); + } + } + + private static void updateVersionRecord() { + JsonStorage records = ohVersionRecords; + if (records != null && ohTargetVersion.isDefined()) { + records.put(UPGRADE_TOOL_VERSION_KEY, ohTargetVersion); records.flush(); } } @@ -216,11 +334,35 @@ public class UpgradeTool { } } - private static class UpgradeRecord { - public final String executionDate; - - public UpgradeRecord(ZonedDateTime executionDate) { - this.executionDate = executionDate.toString(); + private record UpgradeRecord(String executionDate, @Nullable String executionVersion) { + public UpgradeRecord(ZonedDateTime executionDate, @Nullable String executionVersion) { + this(executionDate.toString(), executionVersion); } } + + private record VersionRecord(String executionDate, @Nullable String build, @Nullable String distro, + @Nullable String core, @Nullable String addons, @Nullable String karaf) { + + protected VersionRecord() { + this(null); + } + + protected VersionRecord(@Nullable String core) { + this(null, null, core, null, null); + } + + protected VersionRecord(@Nullable String build, @Nullable String distro, @Nullable String core, + @Nullable String addons, @Nullable String karaf) { + this(ZonedDateTime.now(), build, distro, core, addons, karaf); + } + + protected VersionRecord(ZonedDateTime executionDate, @Nullable String build, @Nullable String distro, + @Nullable String core, @Nullable String addons, @Nullable String karaf) { + this(executionDate.toString(), build, distro, core, addons, karaf); + } + + protected boolean isDefined() { + return core != null; + } + }; } diff --git a/tools/upgradetool/src/main/java/org/openhab/core/tools/Upgrader.java b/tools/upgradetool/src/main/java/org/openhab/core/tools/Upgrader.java index 956b070d8..69f16708c 100644 --- a/tools/upgradetool/src/main/java/org/openhab/core/tools/Upgrader.java +++ b/tools/upgradetool/src/main/java/org/openhab/core/tools/Upgrader.java @@ -30,6 +30,10 @@ public interface Upgrader { String getDescription(); + default @Nullable String getTargetVersion() { + return null; + } + /** * Executes the upgrade process. * diff --git a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/HomeAssistantAddonUpgrader.java b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/HomeAssistantAddonUpgrader.java index b58b2e086..c4232a355 100644 --- a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/HomeAssistantAddonUpgrader.java +++ b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/HomeAssistantAddonUpgrader.java @@ -20,6 +20,8 @@ import org.openhab.core.tools.ExtractedAddonUpgrader; * installed, and if Home Assistant things exist, and if so installs the * Home Assistant addon. * + * @since 5.1.0 + * * @author Cody Cutrer - Initial contribution */ @NonNullByDefault diff --git a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/HomieAddonUpgrader.java b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/HomieAddonUpgrader.java index a9d8ad681..e60fb26d8 100644 --- a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/HomieAddonUpgrader.java +++ b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/HomieAddonUpgrader.java @@ -20,6 +20,8 @@ import org.openhab.core.tools.ExtractedAddonUpgrader; * installed, and if Home Assistant things exist, and if so installs the * Home Assistant addon. * + * @since 5.1.0 + * * @author Cody Cutrer - Initial contribution */ @NonNullByDefault diff --git a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/ItemUnitToMetadataUpgrader.java b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/ItemUnitToMetadataUpgrader.java index 4355927b4..84579dbac 100644 --- a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/ItemUnitToMetadataUpgrader.java +++ b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/ItemUnitToMetadataUpgrader.java @@ -64,13 +64,13 @@ public class ItemUnitToMetadataUpgrader implements Upgrader { return false; } - userdataPath = userdataPath.resolve("jsondb"); + Path dataPath = userdataPath.resolve("jsondb"); boolean noLink; - Path itemJsonDatabasePath = userdataPath.resolve("org.openhab.core.items.Item.json"); - Path metadataJsonDatabasePath = userdataPath.resolve("org.openhab.core.items.Metadata.json"); - Path linkJsonDatabasePath = userdataPath.resolve("org.openhab.core.thing.link.ItemChannelLink.json"); - Path thingJsonDatabasePath = userdataPath.resolve("org.openhab.core.thing.Thing.json"); + Path itemJsonDatabasePath = dataPath.resolve("org.openhab.core.items.Item.json"); + Path metadataJsonDatabasePath = dataPath.resolve("org.openhab.core.items.Metadata.json"); + Path linkJsonDatabasePath = dataPath.resolve("org.openhab.core.thing.link.ItemChannelLink.json"); + Path thingJsonDatabasePath = dataPath.resolve("org.openhab.core.thing.Thing.json"); logger.info("Copying item unit from state description to metadata in database '{}'", itemJsonDatabasePath); if (!Files.isReadable(itemJsonDatabasePath)) { diff --git a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/JSProfileUpgrader.java b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/JSProfileUpgrader.java index 5fc0c985c..d3f94c283 100644 --- a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/JSProfileUpgrader.java +++ b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/JSProfileUpgrader.java @@ -30,6 +30,8 @@ import org.slf4j.LoggerFactory; /** * The {@link JSProfileUpgrader} upgrades JS Profile configurations * + * @since 4.0.0 + * * @author Jan N. Klug - Initial contribution * @author Jimmy Tanagra - Refactored into a separate class */ @@ -54,9 +56,9 @@ public class JSProfileUpgrader implements Upgrader { return false; } - userdataPath = userdataPath.resolve("jsondb"); + Path dataPath = userdataPath.resolve("jsondb"); - Path linkJsonDatabasePath = userdataPath.resolve("org.openhab.core.thing.link.ItemChannelLink.json"); + Path linkJsonDatabasePath = dataPath.resolve("org.openhab.core.thing.link.ItemChannelLink.json"); logger.info("Upgrading JS profile configuration in database '{}'", linkJsonDatabasePath); if (!Files.isWritable(linkJsonDatabasePath)) { diff --git a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/PersistenceUpgrader.java b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/PersistenceUpgrader.java index 40aa31633..147746028 100644 --- a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/PersistenceUpgrader.java +++ b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/PersistenceUpgrader.java @@ -38,6 +38,8 @@ import org.slf4j.LoggerFactory; * configuration that has no strategy defined. * See openhab/openhab-core#4682. * + * @since 5.1.0 + * * @author Mark Herwege - Initial Contribution */ @NonNullByDefault @@ -123,28 +125,32 @@ public class PersistenceUpgrader implements Upgrader { // Update existing managed configurations persistenceStorageKeys.forEach(serviceId -> { - PersistenceServiceConfigurationDTO serviceConfigDTO = Objects - .requireNonNull(persistenceStorage.get(serviceId)); - Collection defaults = serviceConfigDTO.defaults; - if (defaults != null) { - Collection configs = serviceConfigDTO.configs; - configs.forEach(config -> { - Collection strategies = config.strategies; - if (strategies.isEmpty()) { - config.strategies = defaults; - } - }); - serviceConfigDTO.defaults = null; - - persistenceStorage.put(serviceId, serviceConfigDTO); - logger.info("{}: updated strategy configurations and removed default strategies", serviceId); - } + updateConfig(persistenceStorage, serviceId); }); persistenceStorage.flush(); return true; } + @SuppressWarnings("deprecation") + private void updateConfig(JsonStorage persistenceStorage, String serviceId) { + PersistenceServiceConfigurationDTO serviceConfigDTO = Objects.requireNonNull(persistenceStorage.get(serviceId)); + Collection defaults = serviceConfigDTO.defaults; + if (defaults != null) { + Collection configs = serviceConfigDTO.configs; + configs.forEach(config -> { + Collection strategies = config.strategies; + if (strategies.isEmpty()) { + config.strategies = defaults; + } + }); + serviceConfigDTO.defaults = null; + + persistenceStorage.put(serviceId, serviceConfigDTO); + logger.info("{}: updated strategy configurations and removed default strategies", serviceId); + } + } + private List installedPersistenceAddons(Path userdataPath) throws IOException { Path addonsConfigPath = userdataPath.resolve("config/org/openhab/addons.config"); if (Files.notExists(addonsConfigPath)) { diff --git a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/ScriptProfileUpgrader.java b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/ScriptProfileUpgrader.java index 1c7aecd14..f1d21ad4f 100644 --- a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/ScriptProfileUpgrader.java +++ b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/ScriptProfileUpgrader.java @@ -33,6 +33,8 @@ import org.slf4j.LoggerFactory; * {@code commandFromItemScript} and {@code stateFromItemScript}. * See openhab/openhab-core#4058. * + * @since 4.2.0 + * * @author Florian Hotze - Initial contribution * @author Jimmy Tanagra - Refactored into a separate class */ diff --git a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/SemanticTagUpgrader.java b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/SemanticTagUpgrader.java new file mode 100644 index 000000000..a833828b7 --- /dev/null +++ b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/SemanticTagUpgrader.java @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2010-2026 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.internal; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.items.ManagedItemProvider; +import org.openhab.core.semantics.SemanticTag; +import org.openhab.core.semantics.SemanticTagImpl; +import org.openhab.core.semantics.dto.SemanticTagDTO; +import org.openhab.core.semantics.dto.SemanticTagDTOMapper; +import org.openhab.core.semantics.model.DefaultSemanticTagProvider; +import org.openhab.core.storage.json.internal.JsonStorage; +import org.openhab.core.tools.Upgrader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link SemanticTagUpgrader} removes custom semantic tags that are now part of standard semantic tags. + * The custom tags will also be checked for their position in the hierarchy. If hierarchy in the default tags has + * changed, and a custom tag depends on it, the custom tag parent will be adjusted. + * Semantic tags applied to items will be cleaned. If there is more than one semantic tag by class, only one will be + * kept and the others will be renamed to be non-semantic. Preference is given to keep the tags that moved class. If + * there is a Property tag, a Point tag will be added if missing. + * With these adjustments, the model can be further corrected from the UI without issues of double tags of the same + * class not being shown in the UI. + * This upgrader only considers managed tags and items. + * + * @since 5.2.0 + * + * @author Mark Herwege - Initial contribution + */ +@NonNullByDefault +public class SemanticTagUpgrader implements Upgrader { + private final Logger logger = LoggerFactory.getLogger(SemanticTagUpgrader.class); + + private static final String TARGET_VERSION = "5.0.0"; + + // Tags known to have changed class in 5.0 + private static final Set TAGS_CHANGED_CLASS = Set.of("LowBattery", "OpenLevel", "OpenState", "Tampered"); + + private static final String DEFAULT_POINT_TAG = "Status"; + + @Override + public String getName() { + return "deduplicateSemanticTags"; + } + + @Override + public String getDescription() { + return "Correct custom semantic tags and item semantic tags for changes in provided default semantic tags"; + } + + @Override + public @Nullable String getTargetVersion() { + return TARGET_VERSION; + } + + @Override + public boolean execute(@Nullable Path userdataPath, @Nullable Path confPath) { + if (userdataPath == null) { + logger.error("{} skipped: no userdata directory found.", getName()); + return false; + } + + Path semanticsJsonDatabasePath = userdataPath + .resolve(Path.of("jsondb", "org.openhab.core.semantics.SemanticTag.json")); + logger.info("Deduplicating custom semantic tags '{}'", semanticsJsonDatabasePath); + + boolean dbUpdated = false; + boolean canUpdateDb = true; + if (!Files.exists(semanticsJsonDatabasePath)) { + canUpdateDb = false; + dbUpdated = true; + logger.info("Semantic tags database '{}' does not exist, no tags to update.", semanticsJsonDatabasePath); + } else if (!Files.isWritable(semanticsJsonDatabasePath)) { + canUpdateDb = false; + logger.warn( + "Cannot update semantic tags database '{}', update may be incomplete, check path and access rights.", + semanticsJsonDatabasePath); + } + + Map defaultTags = (new DefaultSemanticTagProvider()).getAll().stream() + .collect(Collectors.toMap(SemanticTag::getName, Function.identity())); + Map customTags = Map.of(); + + Set changedTags = new HashSet<>(TAGS_CHANGED_CLASS); + + if (canUpdateDb) { + JsonStorage semanticTagStorage = new JsonStorage<>(semanticsJsonDatabasePath.toFile(), null, + 5, 0, 0, List.of()); + + // Remove duplicate tags from custom tag store + for (String tagKey : List.copyOf(semanticTagStorage.getKeys())) { + SemanticTag tag = SemanticTagDTOMapper.map(semanticTagStorage.get(tagKey)); + if (tag == null) { + continue; + } + + String tagUID = tag.getUID(); + String tagName = tag.getName(); + if (defaultTags.keySet().contains(tagName)) { + logger.info(" Removed duplicate tag '{}' with UID '{}' from custom tags", tagName, tagUID); + semanticTagStorage.remove(tagUID); + changedTags.add(tagName); + } + } + semanticTagStorage.flush(); + + // Rewrite parent relationships if position in hierarchy has changed + for (String tagKey : List.copyOf(semanticTagStorage.getKeys())) { + SemanticTag tag = SemanticTagDTOMapper.map(semanticTagStorage.get(tagKey)); + if (tag == null) { + continue; + } + + String tagUID = tag.getUID(); + String[] hierarchy = tagUID.split("_"); + if (hierarchy.length <= 2) { + // No need to check for update if only semantic class and tag + continue; + } + + String tagName = tag.getName(); + for (int i = hierarchy.length - 2; i > 0; i--) { + String parentName = hierarchy[i]; + SemanticTag newParent = defaultTags.get(parentName); + if (newParent != null) { + String newParentUID = newParent.getUID(); + String childUID = String.join("_", Arrays.copyOfRange(hierarchy, i + 1, hierarchy.length)); + String newUID = newParentUID + "_" + childUID; + if (!tagUID.equals(newUID)) { + SemanticTag newTag = new SemanticTagImpl(newUID, tag.getLabel(), tag.getDescription(), + tag.getSynonyms()); + semanticTagStorage.put(newUID, SemanticTagDTOMapper.map(newTag)); + semanticTagStorage.remove(tagUID); + changedTags.add(tagName); + logger.info(" Updated custom tag '{}' parent from '{}' to '{}'", tagName, + tag.getParentUID(), newTag.getParentUID()); + break; + } + } + } + } + semanticTagStorage.flush(); + dbUpdated = true; + + customTags = semanticTagStorage.getValues().stream().map(tag -> SemanticTagDTOMapper.map(tag)) + .filter(Objects::nonNull).collect(Collectors.toMap(SemanticTag::getName, Function.identity())); + } + + Path itemJsonDatabasePath = userdataPath.resolve(Path.of("jsondb", "org.openhab.core.items.Item.json")); + logger.info("Cleaning item semantic tags '{}'", itemJsonDatabasePath); + + if (!Files.exists(itemJsonDatabasePath)) { + logger.info("No item database '{}', no managed items to update.", itemJsonDatabasePath); + return dbUpdated; + } else if (!Files.isWritable(itemJsonDatabasePath)) { + logger.warn("Cannot access item database '{}', update may be incomplete, check path and access rights.", + itemJsonDatabasePath); + return false; + } + JsonStorage itemStorage = new JsonStorage<>(itemJsonDatabasePath.toFile(), + null, 5, 0, 0, List.of()); + + // Adjust item tags, make sure only 1 tag of every class exists, rename other tags + Map allSemanticTags = Stream.of(defaultTags, customTags) + .flatMap(map -> map.entrySet().stream()).collect(Collectors.toMap(Map.Entry::getKey, + Map.Entry::getValue, (defaultTag, customTag) -> defaultTag)); + Set itemTagNames = itemStorage.getValues().stream().filter(Objects::nonNull).map(item -> item.tags) + .filter(Objects::nonNull).flatMap(tags -> tags.stream()).collect(Collectors.toSet()); + Set allTagNames = Stream.of(allSemanticTags.keySet(), itemTagNames).flatMap(tags -> tags.stream()) + .collect(Collectors.toSet()); + + for (String itemKey : itemStorage.getKeys()) { + ManagedItemProvider.PersistedItem item = itemStorage.get(itemKey); + Set tags = item != null ? item.tags : null; + if (item == null || tags == null || tags.isEmpty()) { + continue; + } + + Set newTags = new HashSet<>(tags); + Set locationTags = new HashSet<>(tags.stream() + .filter(tag -> "Location".equals(tagClass(allSemanticTags.get(tag)))).collect(Collectors.toSet())); + Set equipmentTags = new HashSet<>(tags.stream() + .filter(tag -> "Equipment".equals(tagClass(allSemanticTags.get(tag)))).collect(Collectors.toSet())); + Set pointTags = new HashSet<>(tags.stream() + .filter(tag -> "Point".equals(tagClass(allSemanticTags.get(tag)))).collect(Collectors.toSet())); + Set propertyTags = new HashSet<>(tags.stream() + .filter(tag -> "Property".equals(tagClass(allSemanticTags.get(tag)))).collect(Collectors.toSet())); + + Map> tagSets = Map.of("Location", locationTags, "Equipment", equipmentTags, "Point", + pointTags, "Property", propertyTags); + for (String tagClass : tagSets.keySet()) { + Set tagSet = tagSets.get(tagClass); + while (tagSet != null && tagSet.size() > 1) { + String tagToRemove = tagSet.stream().filter(tag -> !changedTags.contains(tag)).findAny() + .orElse(tagSet.iterator().next()); + if (tagToRemove != null) { + String tagToAdd = newTagName(tagToRemove, allTagNames); + newTags.remove(tagToRemove); + newTags.add(tagToAdd); + tagSet.remove(tagToRemove); + logger.info(" Multiple {} tags on item '{}', renamed '{}' to '{}'", tagClass, itemKey, + tagToRemove, tagToAdd); + } + } + if (tagSet != null && tagSet.size() == 1 && "Property".equals(tagClass) && pointTags.isEmpty()) { + // When there is a Property tag, there should also be a Point tag, add it if missing. + // Status is a neutral one, easy to reconfigure if necessary. + newTags.add(DEFAULT_POINT_TAG); + logger.info(" Item '{}' has Property tag '{}' without Point tag, added Point tag '{}'", itemKey, + tagSet.iterator().next(), DEFAULT_POINT_TAG); + } + } + + if (!newTags.equals(tags)) { + item.tags = newTags; + itemStorage.put(itemKey, item); + } + } + itemStorage.flush(); + + return dbUpdated; + } + + private String newTagName(String oldTagName, Set allTagNames) { + String tagName = oldTagName; + int i = 1; + while (allTagNames.contains(tagName)) { + tagName = oldTagName + String.valueOf(i); + i++; + } + return tagName; + } + + private String tagClass(String tagUID) { + int idx = tagUID.indexOf("_"); + return (idx >= 0 ? tagUID.substring(0, idx) : tagUID).trim(); + } + + private @Nullable String tagClass(@Nullable SemanticTag tag) { + if (tag == null) { + return null; + } + return tagClass(tag.getUID()); + } +} diff --git a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/YamlConfigurationV1TagsUpgrader.java b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/YamlConfigurationV1TagsUpgrader.java index bb3afe204..e7d5bcb91 100644 --- a/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/YamlConfigurationV1TagsUpgrader.java +++ b/tools/upgradetool/src/main/java/org/openhab/core/tools/internal/YamlConfigurationV1TagsUpgrader.java @@ -27,13 +27,14 @@ import org.slf4j.LoggerFactory; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonInclude.Value; import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLParser; /** @@ -66,24 +67,26 @@ public class YamlConfigurationV1TagsUpgrader implements Upgrader { private final Logger logger = LoggerFactory.getLogger(YamlConfigurationV1TagsUpgrader.class); - private final YAMLFactory yamlFactory; private final ObjectMapper objectMapper; public YamlConfigurationV1TagsUpgrader() { // match the options used in {@link YamlModelRepositoryImpl} - yamlFactory = YAMLFactory.builder() // + objectMapper = YAMLMapper.builder() // .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) // omit "---" at file start .disable(YAMLGenerator.Feature.SPLIT_LINES) // do not split long lines .enable(YAMLGenerator.Feature.INDENT_ARRAYS_WITH_INDICATOR) // indent arrays .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) // use quotes only where necessary - .enable(YAMLParser.Feature.PARSE_BOOLEAN_LIKE_WORDS_AS_STRINGS).build(); // do not parse ON/OFF/... as - // booleans - objectMapper = new ObjectMapper(yamlFactory); - objectMapper.findAndRegisterModules(); - objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); - objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); - objectMapper.setDefaultPropertyInclusion(Include.NON_NULL); - objectMapper.enable(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN); + .enable(YAMLGenerator.Feature.ALWAYS_QUOTE_NUMBERS_AS_STRINGS) // use quotes for numbers stored as + // strings + .enable(YAMLParser.Feature.PARSE_BOOLEAN_LIKE_WORDS_AS_STRINGS) // do not parse ON/OFF/... as booleans + .findAndAddModules() // + .visibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE) // + .visibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) // + // Correct compatible behavior with {@link YamlModelRepositoryImpl} for jackson > 2.8, don't use + // Include.ALWAYS as second argument (which would have been the behavior earlier) + .defaultPropertyInclusion(Value.construct(Include.NON_NULL, Include.NON_NULL)) // + .enable(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN) // + .build(); } @Override @@ -104,15 +107,13 @@ public class YamlConfigurationV1TagsUpgrader implements Upgrader { } String confEnv = System.getenv("OPENHAB_CONF"); - // If confPath is set to OPENHAB_CONF, look inside /tags/ subdirectory - // otherwise use the given confPath as is - if (confEnv != null && !confEnv.isBlank() && Path.of(confEnv).toAbsolutePath().equals(confPath)) { - confPath = confPath.resolve("tags"); - } + // If confPath is set to OPENHAB_CONF, look inside /tags/ subdirectory otherwise use the given confPath as is. + // Make configPath "effectively final" inside the lambda below. + Path configPath = confEnv != null && !confEnv.isBlank() && Path.of(confEnv).toAbsolutePath().equals(confPath) + ? confPath.resolve("tags") + : confPath; + logger.info("Upgrading YAML tags configurations in '{}'", configPath); - logger.info("Upgrading YAML tags configurations in '{}'", confPath); - - Path configPath = confPath; // make configPath "effectively final" inside the lambda below try { Files.walkFileTree(configPath, new SimpleFileVisitor<>() { @Override