diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/WorldEdit.java b/worldedit-core/src/main/java/com/sk89q/worldedit/WorldEdit.java index 246c736c84..02e9aee961 100644 --- a/worldedit-core/src/main/java/com/sk89q/worldedit/WorldEdit.java +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/WorldEdit.java @@ -46,6 +46,7 @@ import com.sk89q.worldedit.function.pattern.Pattern; import com.sk89q.worldedit.internal.SchematicsEventListener; import com.sk89q.worldedit.internal.expression.invoke.ReturnException; +import com.sk89q.worldedit.internal.schematic.SchematicsManager; import com.sk89q.worldedit.internal.util.LogManagerCompat; import com.sk89q.worldedit.math.BlockVector3; import com.sk89q.worldedit.scripting.CraftScriptContext; @@ -127,6 +128,7 @@ public final class WorldEdit { EvenMoreExecutors.newBoundedCachedThreadPool(0, 1, 20, "WorldEdit Task Executor - %s")); private final Supervisor supervisor = new SimpleSupervisor(); private final AssetLoaders assetLoaders = new AssetLoaders(this); + private final SchematicsManager schematicsManager = new SchematicsManager(this); private final BlockFactory blockFactory = new BlockFactory(this); private final ItemFactory itemFactory = new ItemFactory(this); @@ -262,6 +264,15 @@ public AssetLoaders getAssetLoaders() { return assetLoaders; } + /** + * Return the Schematics Manager instance. + * + * @return the schematics manager instance + */ + public SchematicsManager getSchematicsManager() { + return schematicsManager; + } + /** * Gets the path to a file. This method will check to see if the filename * has valid characters and has an extension. It also prevents directory @@ -396,8 +407,10 @@ private File getSafeFileWithExtension(File dir, String filename, String extensio return new File(dir, filename); } + private static final java.util.regex.Pattern SAFE_FILENAME_REGEX = java.util.regex.Pattern.compile("^[A-Za-z0-9_\\- \\./\\\\'\\$@~!%\\^\\*\\(\\)\\[\\]\\+\\{\\},\\?]+\\.[A-Za-z0-9]+$"); + private boolean checkFilename(String filename) { - return filename.matches("^[A-Za-z0-9_\\- \\./\\\\'\\$@~!%\\^\\*\\(\\)\\[\\]\\+\\{\\},\\?]+\\.[A-Za-z0-9]+$"); + return SAFE_FILENAME_REGEX.matcher(filename).matches(); } /** diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/command/SchematicCommands.java b/worldedit-core/src/main/java/com/sk89q/worldedit/command/SchematicCommands.java index eba358dd33..199e951a1d 100644 --- a/worldedit-core/src/main/java/com/sk89q/worldedit/command/SchematicCommands.java +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/command/SchematicCommands.java @@ -39,6 +39,8 @@ import com.sk89q.worldedit.extent.clipboard.io.ClipboardWriter; import com.sk89q.worldedit.extent.clipboard.io.share.ClipboardShareDestination; import com.sk89q.worldedit.extent.clipboard.io.share.ClipboardShareMetadata; +import com.sk89q.worldedit.internal.annotation.SchematicPath; +import com.sk89q.worldedit.internal.schematic.SchematicsManager; import com.sk89q.worldedit.internal.util.LogManagerCompat; import com.sk89q.worldedit.math.transform.Transform; import com.sk89q.worldedit.session.ClipboardHolder; @@ -71,8 +73,6 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; -import java.nio.file.DirectoryStream; -import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Comparator; @@ -108,14 +108,17 @@ public SchematicCommands(WorldEdit worldEdit) { ) @CommandPermissions({"worldedit.clipboard.load", "worldedit.schematic.load"}) public void load(Actor actor, LocalSession session, + @SchematicPath @Arg(desc = "File name.") - String filename, + Path schematic, @Arg(desc = "Format name.", def = "sponge") ClipboardFormat format) throws FilenameException { LocalConfiguration config = worldEdit.getConfiguration(); - File dir = worldEdit.getWorkingDirectoryPath(config.saveDir).toFile(); - File f = worldEdit.getSafeOpenFile(actor, dir, filename, + // Schematic.path is relative, so treat it as filename + String filename = schematic.toString(); + File schematicsRoot = worldEdit.getSchematicsManager().getRoot().toFile(); + File f = worldEdit.getSafeOpenFile(actor, schematicsRoot, filename, BuiltInClipboardFormat.SPONGE_V3_SCHEMATIC.getPrimaryFileExtension(), ClipboardFormats.getFileExtensionArray()); @@ -246,11 +249,14 @@ public void share(Actor actor, LocalSession session, ) @CommandPermissions("worldedit.schematic.delete") public void delete(Actor actor, + @SchematicPath @Arg(desc = "File name.") - String filename) throws WorldEditException { + Path schematic) throws WorldEditException { LocalConfiguration config = worldEdit.getConfiguration(); File dir = worldEdit.getWorkingDirectoryPath(config.saveDir).toFile(); + // Schematic.path is relative, so treat it as filename + String filename = schematic.toString(); File f = worldEdit.getSafeOpenFile(actor, dir, filename, "schematic", ClipboardFormats.getFileExtensionArray()); @@ -314,7 +320,6 @@ public void list(Actor actor, if (oldFirst && newFirst) { throw new StopExecutionException(TextComponent.of("Cannot sort by oldest and newest.")); } - final String saveDir = worldEdit.getConfiguration().saveDir; Comparator pathComparator; String flag; if (oldFirst) { @@ -331,7 +336,7 @@ public void list(Actor actor, ? "//schem list -p %page%" + flag : null; WorldEditAsyncCommandBuilder.createAndSendMessage(actor, - new SchematicListTask(saveDir, pathComparator, page, pageCommand), + new SchematicListTask(pathComparator::compare, page, pageCommand), SubtleFormat.wrap("(Please wait... gathering schematic list.)")); } @@ -399,6 +404,7 @@ private static class SchematicSaveTask extends SchematicOutputTask { public Void call() throws Exception { try { writeToOutputStream(new FileOutputStream(file)); + WorldEdit.getInstance().getSchematicsManager().update(); LOGGER.info(actor.getName() + " saved " + file.getCanonicalPath() + (overwrite ? " (overwriting previous file)" : "")); } catch (IOException e) { file.delete(); @@ -439,20 +445,19 @@ public Consumer call() throws Exception { private static class SchematicListTask implements Callable { private final Comparator pathComparator; private final int page; - private final Path rootDir; private final String pageCommand; - SchematicListTask(String prefix, Comparator pathComparator, int page, String pageCommand) { + SchematicListTask(Comparator pathComparator, int page, String pageCommand) { this.pathComparator = pathComparator; this.page = page; - this.rootDir = WorldEdit.getInstance().getWorkingDirectoryPath(prefix); this.pageCommand = pageCommand; } @Override public Component call() throws Exception { - Path resolvedRoot = rootDir.toRealPath(); - List fileList = allFiles(resolvedRoot); + SchematicsManager schematicsManager = WorldEdit.getInstance().getSchematicsManager(); + // Copy this to a mutable list, we're sorting it below. + List fileList = new ArrayList<>(schematicsManager.getSchematicPaths()); if (fileList.isEmpty()) { return ErrorFormat.wrap("No schematics found."); @@ -460,25 +465,11 @@ public Component call() throws Exception { fileList.sort(pathComparator); - PaginationBox paginationBox = new SchematicPaginationBox(resolvedRoot, fileList, pageCommand); + PaginationBox paginationBox = new SchematicPaginationBox(schematicsManager.getRoot(), fileList, pageCommand); return paginationBox.create(page); } } - private static List allFiles(Path root) throws IOException { - List pathList = new ArrayList<>(); - try (DirectoryStream stream = Files.newDirectoryStream(root)) { - for (Path path : stream) { - if (Files.isDirectory(path)) { - pathList.addAll(allFiles(path)); - } else { - pathList.add(path); - } - } - } - return pathList; - } - private static class SchematicPaginationBox extends PaginationBox { private final Path rootDir; private final List files; diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/command/argument/SchematicConverter.java b/worldedit-core/src/main/java/com/sk89q/worldedit/command/argument/SchematicConverter.java new file mode 100644 index 0000000000..638ab8b05d --- /dev/null +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/command/argument/SchematicConverter.java @@ -0,0 +1,88 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.command.argument; + +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.internal.annotation.SchematicPath; +import com.sk89q.worldedit.internal.schematic.SchematicsManager; +import com.sk89q.worldedit.util.formatting.text.Component; +import com.sk89q.worldedit.util.formatting.text.TextComponent; +import com.sk89q.worldedit.util.io.file.FilenameException; +import org.enginehub.piston.CommandManager; +import org.enginehub.piston.converter.ArgumentConverter; +import org.enginehub.piston.converter.ConversionResult; +import org.enginehub.piston.converter.FailedConversion; +import org.enginehub.piston.converter.SuccessfulConversion; +import org.enginehub.piston.inject.InjectedValueAccess; +import org.enginehub.piston.inject.Key; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.enginehub.piston.converter.SuggestionHelper.limitByPrefix; + +public class SchematicConverter implements ArgumentConverter { + + public static void register(WorldEdit worldEdit, CommandManager commandManager) { + commandManager.registerConverter(Key.of(Path.class, SchematicPath.class), new SchematicConverter(worldEdit)); + } + + private final WorldEdit worldEdit; + + private SchematicConverter(WorldEdit worldEdit) { + this.worldEdit = worldEdit; + } + + private final TextComponent choices = TextComponent.of("schematic filename"); + + @Override + public Component describeAcceptableArguments() { + return choices; + } + + @Override + public List getSuggestions(String input, InjectedValueAccess context) { + SchematicsManager schematicsManager = worldEdit.getSchematicsManager(); + Path schematicsRootPath = schematicsManager.getRoot(); + + return limitByPrefix(schematicsManager.getSchematicPaths().stream() + .map(s -> schematicsRootPath.relativize(s).toString()), input); + } + + @Override + public ConversionResult convert(String s, InjectedValueAccess injectedValueAccess) { + Path schematicsRoot = worldEdit.getSchematicsManager().getRoot(); + // resolve as subpath of schematicsRoot + Path schematicPath = schematicsRoot.resolve(s).toAbsolutePath(); + // then check whether it is still a subpath to rule out "../" + if (!schematicPath.startsWith(schematicsRoot)) { + return FailedConversion.from(new FilenameException(s)); + } + // check whether the file exists + if (Files.exists(schematicPath)) { + // continue as relative path to schematicsRoot + schematicPath = schematicsRoot.relativize(schematicPath); + return SuccessfulConversion.fromSingle(schematicPath); + } else { + return FailedConversion.from(new FilenameException(s)); + } + } +} diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/extension/platform/Capability.java b/worldedit-core/src/main/java/com/sk89q/worldedit/extension/platform/Capability.java index be34b5e183..f132bc0473 100644 --- a/worldedit-core/src/main/java/com/sk89q/worldedit/extension/platform/Capability.java +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/extension/platform/Capability.java @@ -53,10 +53,12 @@ void uninitialize(PlatformManager platformManager, Platform platform) { @Override void initialize(PlatformManager platformManager, Platform platform) { WorldEdit.getInstance().getAssetLoaders().init(); + WorldEdit.getInstance().getSchematicsManager().init(); } @Override void uninitialize(PlatformManager platformManager, Platform platform) { + WorldEdit.getInstance().getSchematicsManager().uninit(); WorldEdit.getInstance().getAssetLoaders().uninit(); } }, diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/extension/platform/PlatformCommandManager.java b/worldedit-core/src/main/java/com/sk89q/worldedit/extension/platform/PlatformCommandManager.java index 8a28f81fca..d801c0f1ca 100644 --- a/worldedit-core/src/main/java/com/sk89q/worldedit/extension/platform/PlatformCommandManager.java +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/extension/platform/PlatformCommandManager.java @@ -82,6 +82,7 @@ import com.sk89q.worldedit.command.argument.OffsetConverter; import com.sk89q.worldedit.command.argument.RegionFactoryConverter; import com.sk89q.worldedit.command.argument.RegistryConverter; +import com.sk89q.worldedit.command.argument.SchematicConverter; import com.sk89q.worldedit.command.argument.SelectorChoiceConverter; import com.sk89q.worldedit.command.argument.SideEffectConverter; import com.sk89q.worldedit.command.argument.SideEffectSetConverter; @@ -232,6 +233,7 @@ private void registerArgumentConverters() { ClipboardFormatConverter.register(commandManager); ClipboardShareDestinationConverter.register(commandManager); SelectorChoiceConverter.register(commandManager); + SchematicConverter.register(worldEdit, commandManager); } private void registerAlwaysInjectedValues() { diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/annotation/SchematicPath.java b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/annotation/SchematicPath.java new file mode 100644 index 0000000000..1cb691cfa8 --- /dev/null +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/annotation/SchematicPath.java @@ -0,0 +1,36 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.internal.annotation; + +import org.enginehub.piston.inject.InjectAnnotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to denote an argument as a schematic path. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +@InjectAnnotation +public @interface SchematicPath { +} diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/SchematicsManager.java b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/SchematicsManager.java new file mode 100644 index 0000000000..b52122f34d --- /dev/null +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/SchematicsManager.java @@ -0,0 +1,129 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.internal.schematic; + +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.internal.schematic.backends.DummySchematicsBackend; +import com.sk89q.worldedit.internal.schematic.backends.FileWatcherSchematicsBackend; +import com.sk89q.worldedit.internal.schematic.backends.PollingSchematicsBackend; +import com.sk89q.worldedit.internal.schematic.backends.SchematicsBackend; +import com.sk89q.worldedit.internal.util.LogManagerCompat; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Class that manages the known schematic files. + * + *

This class monitors the schematics folder for changes and maintains an up-to-date list of known + * schematics in order to speed up queries. + * + *

If initialization of the file-watching backend fails, a polling backend is used instead. + */ +public class SchematicsManager { + + private static final Logger LOGGER = LogManagerCompat.getLogger(); + private final WorldEdit worldEdit; + + private Path schematicsDir; + private SchematicsBackend backend; + + public SchematicsManager(WorldEdit worldEdit) { + this.worldEdit = worldEdit; + } + + private void createFallbackBackend() { + LOGGER.warn("Failed to initialize file-monitoring based schematics backend. Falling back to polling."); + backend = PollingSchematicsBackend.create(schematicsDir); + } + + private void setupBackend() { + try { + var fileWatcherBackend = FileWatcherSchematicsBackend.create(schematicsDir); + if (fileWatcherBackend.isPresent()) { + backend = fileWatcherBackend.get(); + } else { + createFallbackBackend(); + } + } catch (IOException e) { + createFallbackBackend(); + } + } + + /** + * Initialize this SchematicsManager. + * This sets everything up, and initially scans the schematics folder. + */ + public void init() { + try { + schematicsDir = worldEdit.getWorkingDirectoryPath(worldEdit.getConfiguration().saveDir); + Files.createDirectories(schematicsDir); + schematicsDir = schematicsDir.toRealPath(); + setupBackend(); + } catch (IOException e) { + LOGGER.warn("Failed to create schematics directory", e); + backend = new DummySchematicsBackend(); //fallback to dummy backend + } + backend.init(); + } + + /** + * Uninitialize this SchematicsManager. + */ + public void uninit() { + if (backend != null) { + backend.uninit(); + backend = null; + } + } + + /** + * Gets the root folder in which the schematics are stored. + * + * @return the root folder where schematics are stored + */ + public Path getRoot() { + checkNotNull(schematicsDir, "not initialized"); + return schematicsDir; + } + + /** + * Gets a set of known schematics. + * + * @return a set of all known schematics + */ + public Set getSchematicPaths() { + checkNotNull(backend); + return backend.getPaths(); + } + + /** + * Force an update of the list. + */ + public void update() { + backend.update(); + } + +} diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/backends/DummySchematicsBackend.java b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/backends/DummySchematicsBackend.java new file mode 100644 index 0000000000..37964f30bd --- /dev/null +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/backends/DummySchematicsBackend.java @@ -0,0 +1,45 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.internal.schematic.backends; + +import java.nio.file.Path; +import java.util.Set; + +/** + * A backend that never lists any files. + */ +public class DummySchematicsBackend implements SchematicsBackend { + @Override + public void init() { + } + + @Override + public void uninit() { + } + + @Override + public Set getPaths() { + return Set.of(); + } + + @Override + public void update() { + } +} diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/backends/FileWatcherSchematicsBackend.java b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/backends/FileWatcherSchematicsBackend.java new file mode 100644 index 0000000000..b1a3ed1f4e --- /dev/null +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/backends/FileWatcherSchematicsBackend.java @@ -0,0 +1,94 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.internal.schematic.backends; + +import com.sk89q.worldedit.internal.util.LogManagerCompat; +import com.sk89q.worldedit.internal.util.RecursiveDirectoryWatcher; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * A backend that efficiently scans for file changes using {@link RecursiveDirectoryWatcher}. + */ +public class FileWatcherSchematicsBackend implements SchematicsBackend { + private static final Logger LOGGER = LogManagerCompat.getLogger(); + + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private final Set schematics = new HashSet<>(); + private final RecursiveDirectoryWatcher directoryWatcher; + + private FileWatcherSchematicsBackend(RecursiveDirectoryWatcher directoryWatcher) { + this.directoryWatcher = directoryWatcher; + } + + /** + * Create a new instance of the directory-monitoring SchematicsManager backend. + * @param schematicsFolder Root folder for schematics. + * @return A new FileWatcherSchematicsBackend instance. + * @throws IOException When creation of the filesystem watcher fails. + */ + public static Optional create(Path schematicsFolder) throws IOException { + return RecursiveDirectoryWatcher.create(schematicsFolder).map(FileWatcherSchematicsBackend::new); + } + + @Override + public void init() { + directoryWatcher.start(event -> { + lock.writeLock().lock(); + try { + if (event instanceof RecursiveDirectoryWatcher.FileCreatedEvent) { + schematics.add(event.path()); + LOGGER.debug("New Schematic found: " + event.path()); + } else if (event instanceof RecursiveDirectoryWatcher.FileDeletedEvent) { + schematics.remove(event.path()); + LOGGER.debug("Schematic deleted: " + event.path()); + } + } finally { + lock.writeLock().unlock(); + } + }); + } + + @Override + public void uninit() { + directoryWatcher.close(); + } + + @Override + public Set getPaths() { + lock.readLock().lock(); + try { + return Set.copyOf(schematics); + } finally { + lock.readLock().unlock(); + } + } + + @Override + public void update() { + // Nothing to do here, we probably already know :) + } +} \ No newline at end of file diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/backends/PollingSchematicsBackend.java b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/backends/PollingSchematicsBackend.java new file mode 100644 index 0000000000..d8dc827973 --- /dev/null +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/backends/PollingSchematicsBackend.java @@ -0,0 +1,110 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.internal.schematic.backends; + +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.internal.util.LogManagerCompat; +import com.sk89q.worldedit.util.io.file.FilenameException; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * A backend that scans the folder tree, then caches the result for a certain amount of time. + */ +public class PollingSchematicsBackend implements SchematicsBackend { + + private static final Logger LOGGER = LogManagerCompat.getLogger(); + + private static final Duration MAX_RESULT_AGE = Duration.ofSeconds(10); + + private final Path schematicsDir; + private Instant lastUpdateTs = Instant.EPOCH; + private List schematics = new ArrayList<>(); + + private PollingSchematicsBackend(Path schematicsDir) { + this.schematicsDir = schematicsDir; + } + + /** + * Create a new instance of the polling SchematicsManager backend. + * @param schematicsFolder Root folder for schematics. + * @return A new PollingSchematicsBackend instance. + */ + public static PollingSchematicsBackend create(Path schematicsFolder) { + return new PollingSchematicsBackend(schematicsFolder); + } + + private List scanFolder(Path root) { + List pathList = new ArrayList<>(); + Path schematicRoot = WorldEdit.getInstance().getSchematicsManager().getRoot(); + + try (DirectoryStream stream = Files.newDirectoryStream(root)) { + for (Path path : stream) { + path = WorldEdit.getInstance().getSafeOpenFile(null, schematicRoot.toFile(), schematicRoot.relativize(path).toString(), null).toPath(); + if (Files.isDirectory(path)) { + pathList.addAll(scanFolder(path)); + } else { + pathList.add(path); + } + } + } catch (IOException | FilenameException e) { + LOGGER.error(e); + } + return pathList; + } + + private void runRescan() { + LOGGER.debug("Rescanning schematics"); + this.schematics = scanFolder(schematicsDir); + lastUpdateTs = Instant.now(); + } + + @Override + public void init() { + } + + @Override + public void uninit() { + } + + @Override + public synchronized Set getPaths() { + // Update internal cache if required (determined by age) + Duration age = Duration.between(lastUpdateTs, Instant.now()); + if (age.compareTo(MAX_RESULT_AGE) >= 0) { + runRescan(); + } + return Set.copyOf(schematics); + } + + @Override + public synchronized void update() { + lastUpdateTs = Instant.EPOCH; // invalidate cache + } +} diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/backends/SchematicsBackend.java b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/backends/SchematicsBackend.java new file mode 100644 index 0000000000..6b1f88d253 --- /dev/null +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/schematic/backends/SchematicsBackend.java @@ -0,0 +1,53 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.internal.schematic.backends; + +import com.sk89q.worldedit.internal.schematic.SchematicsManager; + +import java.nio.file.Path; +import java.util.Set; + +/** + * {@link SchematicsManager} backend interface. + */ +public interface SchematicsBackend { + + /** + * Initialize the backend. + */ + void init(); + + /** + * Uninitialize the backend. + */ + void uninit(); + + /** + * Gets the set of known schematic paths. + * + * @return the set of known schematics + */ + Set getPaths(); + + /** + * Tells the backend that there are changes it should take into account. + */ + void update(); +} \ No newline at end of file diff --git a/worldedit-core/src/main/java/com/sk89q/worldedit/internal/util/RecursiveDirectoryWatcher.java b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/util/RecursiveDirectoryWatcher.java new file mode 100644 index 0000000000..c50ba193ea --- /dev/null +++ b/worldedit-core/src/main/java/com/sk89q/worldedit/internal/util/RecursiveDirectoryWatcher.java @@ -0,0 +1,234 @@ +/* + * WorldEdit, a Minecraft world manipulation toolkit + * Copyright (C) sk89q + * Copyright (C) WorldEdit team and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sk89q.worldedit.internal.util; + +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.util.io.file.FilenameException; +import org.apache.logging.log4j.Logger; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.ClosedWatchServiceException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.util.Optional; +import java.util.function.Consumer; + +/** + * Helper class that recursively monitors a directory for changes to files and folders, including creation, deletion, and modification. + * + * @apiNote File and folder events might be sent multiple times. Users of this class need to employ their own + * deduplication! + */ +public class RecursiveDirectoryWatcher implements Closeable { + + /** + * Base interface for all change events. + */ + public interface DirEntryChangeEvent { + Path path(); + } + + /** + * Event signaling the creation of a new file. + */ + public record FileCreatedEvent(Path path) implements DirEntryChangeEvent { + } + + /** + * Event signaling the deletion of a file. + */ + public record FileDeletedEvent(Path path) implements DirEntryChangeEvent { + } + + /** + * Event signaling the creation of a new directory. + */ + public record DirectoryCreatedEvent(Path path) implements DirEntryChangeEvent { + } + + /** + * Event signaling the deletion of a directory. + */ + public record DirectoryDeletedEvent(Path path) implements DirEntryChangeEvent { + } + + + private static final Logger LOGGER = LogManagerCompat.getLogger(); + + private final Path root; + private final WatchService watchService; + private Thread watchThread; + private Consumer eventConsumer; + private BiMap watchRootMap = HashBiMap.create(); + + private RecursiveDirectoryWatcher(Path root, WatchService watchService) { + this.root = root; + this.watchService = watchService; + } + + /** + * Create a new recursive directory watcher for the given root folder. + * You have to call {@link #start(Consumer)} before the instance starts monitoring. + * + * @param root Folder to watch for changed files recursively. + * @return a new instance that will monitor the given root folder + * @throws IOException If creating the watcher failed, e.g. due to root not existing. + */ + public static Optional create(Path root) throws IOException { + try { + WatchService watchService = root.getFileSystem().newWatchService(); + return Optional.of(new RecursiveDirectoryWatcher(root, watchService)); + } catch (UnsupportedOperationException ignored) { + return Optional.empty(); + } + } + + private void registerFolderWatcher(Path root) throws IOException { + WatchKey watchKey = root.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY); + LOGGER.debug("Watch registered: " + root); + watchRootMap.put(watchKey, root); + } + + private void triggerInitialEvents(Path root) throws IOException, FilenameException { + Path schematicRoot = WorldEdit.getInstance().getSchematicsManager().getRoot(); + eventConsumer.accept(new DirectoryCreatedEvent(root)); + for (Path path : Files.newDirectoryStream(root)) { + path = WorldEdit.getInstance().getSafeOpenFile(null, schematicRoot.toFile(), schematicRoot.relativize(path).toString(), null).toPath(); + if (Files.isDirectory(path)) { + triggerInitialEvents(path); + } else { + eventConsumer.accept(new FileCreatedEvent(path)); + } + } + } + + /** + * Make this RecursiveDirectoryWatcher instance start monitoring the root folder it was created on. + * When this is called, RecursiveDirectoryWatcher will send initial notifications for the entire + * file structure in the configured root. + * @param eventConsumer The lambda that's fired for every file event. + */ + public void start(Consumer eventConsumer) { + Path schematicRoot = WorldEdit.getInstance().getSchematicsManager().getRoot(); + + this.eventConsumer = eventConsumer; + watchThread = new Thread(() -> { + LOGGER.debug("RecursiveDirectoryWatcher::EventConsumer started"); + + try { + registerFolderWatcher(root); + triggerInitialEvents(root); + } catch (IOException | FilenameException e) { + LOGGER.error(e); + } + + try { + WatchKey watchKey; + while (true) { + try { + watchKey = watchService.take(); + } catch (InterruptedException e) { break; } + + for (WatchEvent event : watchKey.pollEvents()) { + WatchEvent.Kind kind = event.kind(); + if (kind.equals(StandardWatchEventKinds.OVERFLOW)) { + LOGGER.warn("Seems like we can't keep up with updates"); + continue; + } + // make sure to work with an absolute path + Path path = (Path) event.context(); + Path parentPath = watchRootMap.get(watchKey); + path = parentPath.resolve(path); + + if (kind.equals(StandardWatchEventKinds.ENTRY_CREATE)) { + path = WorldEdit.getInstance().getSafeOpenFile(null, schematicRoot.toFile(), schematicRoot.relativize(path).toString(), null).toPath(); + + if (Files.isDirectory(path)) { // new subfolder created, create watch for it + try { + registerFolderWatcher(path); + triggerInitialEvents(path); + } catch (IOException | FilenameException e) { + LOGGER.error(e); + } + } else { // new file created + eventConsumer.accept(new FileCreatedEvent(path)); + } + } else if (kind.equals(StandardWatchEventKinds.ENTRY_DELETE)) { + // When we are notified about a deleted entry, we can't simply ask the filesystem + // whether the entry is a file or a folder. But we have our watchRootMap, that stores + // one WatchKey per (sub)folder, so we can just ask it. + if (watchRootMap.containsValue(path)) { // was a folder + LOGGER.debug("Watch unregistered: " + path); + WatchKey obsoleteSubfolderWatchKey = watchRootMap.inverse().get(path); + // stop listening to changes from deleted dir + obsoleteSubfolderWatchKey.cancel(); + watchRootMap.remove(obsoleteSubfolderWatchKey); + eventConsumer.accept(new DirectoryDeletedEvent(path)); + } else { // was a file + eventConsumer.accept(new FileDeletedEvent(path)); + } + } + } + + if (!watchKey.reset()) { + watchRootMap.remove(watchKey); + if (watchRootMap.isEmpty()) { + break; // nothing left to watch + } + } + } + } catch (ClosedWatchServiceException | FilenameException ignored) { + } + LOGGER.debug("RecursiveDirectoryWatcher::EventConsumer exited"); + }); + watchThread.setName("RecursiveDirectoryWatcher"); + watchThread.start(); + } + + /** + * Close this RecursiveDirectoryWatcher instance and wait for it to be completely shut down. + * @apiNote RecursiveDirectoryWatcher is not reusable! + */ + @Override + public void close() { + try { + watchService.close(); + } catch (IOException e) { + LOGGER.error(e); + } + if (watchThread != null) { + try { + watchThread.join(); + } catch (InterruptedException e) { + LOGGER.error(e); + } + watchThread = null; + } + eventConsumer = null; + } + +}