diff --git a/bundles/org.openhab.automation.jsscripting/pom.xml b/bundles/org.openhab.automation.jsscripting/pom.xml
index 61b1912a72a1b..66f5bfff945ee 100644
--- a/bundles/org.openhab.automation.jsscripting/pom.xml
+++ b/bundles/org.openhab.automation.jsscripting/pom.xml
@@ -20,7 +20,7 @@
org.openhab.addons.bundles
org.openhab.addons.reactor.bundles
- 3.0.0-SNAPSHOT
+ 3.1.0-SNAPSHOT
org.openhab.automation.jsscripting
diff --git a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/ExtScriptFileWatcher.java b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/ExtScriptFileWatcher.java
new file mode 100644
index 0000000000000..b4668b8fd6c8f
--- /dev/null
+++ b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/ExtScriptFileWatcher.java
@@ -0,0 +1,327 @@
+// Hello
+package org.openhab.automation.jsscripting.internal;
+
+import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
+import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE;
+import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.MalformedURLException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.WatchEvent;
+import java.nio.file.WatchEvent.Kind;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.OpenHAB;
+import org.openhab.core.automation.module.script.ScriptEngineContainer;
+import org.openhab.core.automation.module.script.ScriptEngineManager;
+import org.openhab.core.automation.module.script.rulesupport.internal.loader.ScriptFileWatcher;
+import org.openhab.core.common.NamedThreadFactory;
+import org.openhab.core.service.AbstractWatchService;
+import org.openhab.core.service.ReadyMarker;
+import org.openhab.core.service.ReadyMarkerFilter;
+import org.openhab.core.service.ReadyService;
+import org.openhab.core.service.ReadyService.ReadyTracker;
+import org.openhab.core.service.StartLevelService;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link ScriptFileWatcher} watches the jsr223 directory for files. If a
+ * new/modified file is detected, the script is read and passed to the
+ * {@link ScriptEngineManager}.
+ *
+ * @author Simon Merschjohann - Initial contribution
+ * @author Kai Kreuzer - improved logging and removed thread pool
+ */
+@Component(immediate = true)
+public class ExtScriptFileWatcher extends AbstractWatchService implements ReadyTracker {
+
+ private static final Set EXCLUDED_FILE_EXTENSIONS = new HashSet<>(
+ Arrays.asList("txt", "old", "example", "backup", "md", "swp", "tmp", "bak"));
+
+ public static final String FILE_DIRECTORY = "automation" + File.separator + "jsr223-node";
+ private static final long RECHECK_INTERVAL = 20;
+
+ private boolean started = false;
+
+ private final ScriptEngineManager manager;
+ private final ReadyService readyService;
+ private @Nullable ScheduledExecutorService scheduler;
+
+ private final Map> urlsByScriptExtension = new ConcurrentHashMap<>();
+ private final Set loaded = new HashSet<>();
+
+ @Activate
+ public ExtScriptFileWatcher(final @Reference ScriptEngineManager manager,
+ final @Reference ReadyService readyService) {
+ super(OpenHAB.getConfigFolder() + File.separator + FILE_DIRECTORY);
+ this.manager = manager;
+ this.readyService = readyService;
+ }
+
+ @Activate
+ @Override
+ public void activate() {
+ super.activate();
+ readyService.registerTracker(this, new ReadyMarkerFilter().withType(StartLevelService.STARTLEVEL_MARKER_TYPE)
+ .withIdentifier(Integer.toString(StartLevelService.STARTLEVEL_MODEL)));
+
+ File f = new File(super.pathToWatch);
+ if (!f.isDirectory()) {
+ logger.warn("Missing directory '{}' for extended JSR223 support", super.pathToWatch);
+ }
+ }
+
+ @Deactivate
+ @Override
+ public void deactivate() {
+ logger.info("Stopping ...");
+ readyService.unregisterTracker(this);
+ ScheduledExecutorService localScheduler = scheduler;
+ if (localScheduler != null) {
+ localScheduler.shutdownNow();
+ scheduler = null;
+ }
+ super.deactivate();
+ }
+
+ /**
+ * Imports resources from the specified file or directory.
+ *
+ * @param file the file or directory to import resources from
+ */
+ private void importResources(File file) {
+ logger.info("importResources ..." + file.getAbsolutePath());
+ if (file.exists()) {
+ File[] files = file.listFiles();
+ if (files != null) {
+ for (File f : files) {
+ if (!f.isHidden() && !f.isDirectory()) {
+ importResources(f);
+ }
+ }
+ } else {
+ try {
+ URL url = file.toURI().toURL();
+ importFile(url);
+ } catch (MalformedURLException e) {
+ // can't happen for the 'file' protocol handler with a correctly formatted URI
+ logger.debug("Can't create a URL", e);
+ }
+ }
+ }
+ }
+
+ @Override
+ protected boolean watchSubDirectories() {
+ return false;
+ }
+
+ @Override
+ protected Kind>[] getWatchEventKinds(Path subDir) {
+ return new Kind>[] { ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY };
+ }
+
+ @Override
+ protected void processWatchEvent(WatchEvent> event, Kind> kind, Path path) {
+ File root = new File(pathToWatch);
+ File file = path.toFile();
+ if (!file.isHidden()) {
+
+ // if the changed file is not a root file, reload all files
+ if (!file.getParent().equals(root.getPath())) {
+ // reloadAllFiles();
+ System.out.println("SKIP FILE -------------> " + file.getAbsolutePath());
+ return;
+ }
+
+ try {
+ URL fileUrl = file.toURI().toURL();
+ if (ENTRY_DELETE.equals(kind)) {
+ removeFile(fileUrl);
+ }
+
+ if (file.canRead() && (ENTRY_CREATE.equals(kind) || ENTRY_MODIFY.equals(kind))) {
+ importFile(fileUrl);
+ }
+ } catch (MalformedURLException e) {
+ logger.error("malformed", e);
+ }
+ }
+ }
+
+ private void removeFile(URL url) {
+ dequeueUrl(url);
+ manager.removeEngine(getScriptIdentifier(url));
+ loaded.remove(url);
+ }
+
+ private synchronized void importFile(URL url) {
+ String fileName = url.getFile();
+ if (loaded.contains(url)) {
+ this.removeFile(url); // if already loaded, remove first
+ }
+
+ String scriptType = getScriptType(url);
+ if (scriptType != null) {
+ if (!started) {
+ enqueueUrl(url, scriptType);
+ } else {
+ if (manager.isSupported(scriptType)) {
+ try (InputStreamReader reader = new InputStreamReader(new BufferedInputStream(url.openStream()),
+ StandardCharsets.UTF_8)) {
+ logger.info("Loading script '{}'", fileName);
+
+ ScriptEngineContainer container = manager.createScriptEngine(scriptType,
+ getScriptIdentifier(url));
+
+ if (container != null) {
+ manager.loadScript(container.getIdentifier(), reader);
+ loaded.add(url);
+ logger.debug("Script loaded: {}", fileName);
+ } else {
+ logger.error("Script loading error, ignoring file: {}", fileName);
+ }
+ } catch (IOException e) {
+ logger.error("Failed to load file '{}': {}", url.getFile(), e.getMessage());
+ }
+ } else {
+ enqueueUrl(url, scriptType);
+ logger.info("ScriptEngine for {} not available", scriptType);
+ }
+ }
+ }
+ }
+
+ private void enqueueUrl(URL url, String scriptType) {
+ synchronized (urlsByScriptExtension) {
+ Set set = urlsByScriptExtension.get(scriptType);
+ if (set == null) {
+ set = new HashSet<>();
+ urlsByScriptExtension.put(scriptType, set);
+ }
+ set.add(url);
+ logger.debug("in queue: {}", urlsByScriptExtension);
+ }
+ }
+
+ private void dequeueUrl(URL url) {
+ String scriptType = getScriptType(url);
+ if (scriptType != null) {
+ synchronized (urlsByScriptExtension) {
+ Set set = urlsByScriptExtension.get(scriptType);
+ if (set != null) {
+ set.remove(url);
+ if (set.isEmpty()) {
+ urlsByScriptExtension.remove(scriptType);
+ }
+ }
+ logger.debug("in queue: {}", urlsByScriptExtension);
+ }
+ }
+ }
+
+ private @Nullable String getScriptType(URL url) {
+ logger.info("getScriptType ...");
+ String fileName = url.getPath();
+ int index = fileName.lastIndexOf(".");
+ if (index == -1) {
+ return null;
+ }
+ String fileExtension = fileName.substring(index + 1);
+
+ // ignore known file extensions for "temp" files
+ if (EXCLUDED_FILE_EXTENSIONS.contains(fileExtension) || fileExtension.endsWith("~")) {
+ return null;
+ }
+ return fileExtension;
+ }
+
+ private String getScriptIdentifier(URL url) {
+ return url.toString();
+ }
+
+ private void checkFiles() {
+ SortedSet reimportUrls = new TreeSet(new Comparator() {
+ @Override
+ public int compare(URL o1, URL o2) {
+ try {
+ Path path1 = Paths.get(o1.toURI());
+ String name1 = path1.getFileName().toString();
+ logger.trace("o1 [{}], path1 [{}], name1 [{}]", o1, path1, name1);
+
+ Path path2 = Paths.get(o2.toURI());
+ String name2 = path2.getFileName().toString();
+ logger.trace("o2 [{}], path2 [{}], name2 [{}]", o2, path2, name2);
+
+ int nameCompare = name1.compareToIgnoreCase(name2);
+ if (nameCompare != 0) {
+ return nameCompare;
+ } else {
+ int pathCompare = path1.getParent().toString()
+ .compareToIgnoreCase(path2.getParent().toString());
+ return pathCompare;
+ }
+ } catch (URISyntaxException e) {
+ logger.error("URI syntax exception", e);
+ return 0;
+ }
+ }
+ });
+
+ synchronized (urlsByScriptExtension) {
+ Set newlySupported = new HashSet<>();
+ for (String key : urlsByScriptExtension.keySet()) {
+ if (manager.isSupported(key)) {
+ newlySupported.add(key);
+ }
+ }
+
+ for (String key : newlySupported) {
+ reimportUrls.addAll(Objects.requireNonNullElse(urlsByScriptExtension.remove(key), Set.of()));
+ }
+ }
+
+ for (URL url : reimportUrls) {
+ importFile(url);
+ }
+ }
+
+ @Override
+ public void onReadyMarkerAdded(@NonNull ReadyMarker readyMarker) {
+ started = true;
+ ScheduledExecutorService localScheduler = Executors
+ .newSingleThreadScheduledExecutor(new NamedThreadFactory("ext-scriptwatcher"));
+ scheduler = localScheduler;
+ localScheduler.submit(() -> importResources(new File(pathToWatch)));
+ localScheduler.scheduleWithFixedDelay(this::checkFiles, RECHECK_INTERVAL, RECHECK_INTERVAL, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public void onReadyMarkerRemoved(@NonNull ReadyMarker readyMarker) {
+ started = false;
+ }
+}
diff --git a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/GraalJSScriptEngineFactory.java b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/GraalJSScriptEngineFactory.java
index 2a5682636a94d..761fd5bd1c05f 100644
--- a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/GraalJSScriptEngineFactory.java
+++ b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/GraalJSScriptEngineFactory.java
@@ -13,9 +13,10 @@
package org.openhab.automation.jsscripting.internal;
import java.util.*;
-
+import java.io.File;
import javax.script.ScriptEngine;
+import org.openhab.core.OpenHAB;
import org.openhab.core.automation.module.script.ScriptEngineFactory;
import org.osgi.service.component.annotations.Component;
@@ -47,7 +48,13 @@ public void scopeValues(ScriptEngine scriptEngine, Map scopeValu
@Override
public ScriptEngine createScriptEngine(String scriptType) {
- OpenhabGraalJSScriptEngine engine = new OpenhabGraalJSScriptEngine();
+
+ // final String MODULE_DIR = String.join(File.separator, OpenHAB.getConfigFolder(), "automation", "lib",
+ // "javascript", "personal");
+
+ final String MODULE_DIR = String.join(File.separator, OpenHAB.getConfigFolder(), "automation", "jsr223-ts");
+
+ OpenhabGraalJSScriptEngine engine = new OpenhabGraalJSScriptEngine(MODULE_DIR);
return new DebuggingGraalScriptEngine<>(engine);
}
}
diff --git a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java
index 39623c50a8c75..4211a2d43c39a 100644
--- a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java
+++ b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java
@@ -50,8 +50,8 @@ public class OpenhabGraalJSScriptEngine extends InvocationInterceptingScriptEngi
private static final Logger logger = LoggerFactory.getLogger(OpenhabGraalJSScriptEngine.class);
private static final String REQUIRE_WRAPPER_NAME = "__wraprequire__";
- private static final String MODULE_DIR = String.join(File.separator, OpenHAB.getConfigFolder(), "automation", "lib",
- "javascript", "personal");
+ // private static final String MODULE_DIR = String.join(File.separator, OpenHAB.getConfigFolder(), "automation", "lib",
+ // "javascript", "personal");
// these fields start as null because they are populated on first use
@NonNullByDefault({})
@@ -65,11 +65,12 @@ public class OpenhabGraalJSScriptEngine extends InvocationInterceptingScriptEngi
* Creates an implementation of ScriptEngine (& Invocable), wrapping the contained engine, that tracks the script
* lifecycle and provides hooks for scripts to do so too.
*/
- public OpenhabGraalJSScriptEngine() {
+ public OpenhabGraalJSScriptEngine(String moduleDir) {
super(null); // delegate depends on fields not yet initialised, so we cannot set it immediately
+
delegate = GraalJSScriptEngine.create(null,
Context.newBuilder("js").allowExperimentalOptions(true).allowAllAccess(true)
- .option("js.commonjs-require-cwd", MODULE_DIR).option("js.nashorn-compat", "true") // to ease
+ .option("js.commonjs-require-cwd", moduleDir).option("js.nashorn-compat", "true") // to ease
// migration
.option("js.commonjs-require", "true") // enable CommonJS module support
.fileSystem(new DelegatingFileSystem(FileSystems.getDefault().provider()) {