diff --git a/build.gradle.kts b/build.gradle.kts
index 4098653..b2b60a5 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1 +1 @@
-version = "1.0.2"
+version = "1.1.0"
diff --git a/extensionmanager-app/src/main/java/qupath/ext/extensionmanager/app/ExtensionManagerApp.java b/extensionmanager-app/src/main/java/qupath/ext/extensionmanager/app/ExtensionManagerApp.java
index 6788a31..8080b41 100644
--- a/extensionmanager-app/src/main/java/qupath/ext/extensionmanager/app/ExtensionManagerApp.java
+++ b/extensionmanager-app/src/main/java/qupath/ext/extensionmanager/app/ExtensionManagerApp.java
@@ -4,8 +4,7 @@
import javafx.beans.property.SimpleObjectProperty;
import javafx.stage.Stage;
import qupath.ext.extensionmanager.core.ExtensionCatalogManager;
-import qupath.ext.extensionmanager.core.savedentities.Registry;
-import qupath.ext.extensionmanager.core.savedentities.SavedCatalog;
+import qupath.ext.extensionmanager.core.catalog.DefaultCatalog;
import qupath.ext.extensionmanager.gui.ExtensionManager;
import java.io.IOException;
@@ -15,9 +14,8 @@
import java.util.List;
/**
- * An application that launches a {@link ExtensionManager}. A temporary directory (with
- * an empty extension JAR file inside) is used as the extension directory.
- * This catalog is used.
+ * An application that launches a {@link ExtensionManager}. A temporary directory (with an empty extension JAR file inside)
+ * is used as the extension directory. This catalog is used.
*/
public class ExtensionManagerApp extends Application {
@@ -38,13 +36,12 @@ public void start(Stage stage) throws IOException {
new SimpleObjectProperty<>(createExtensionDirectory()),
ExtensionManagerApp.class.getClassLoader(),
"v0.6.0",
- new Registry(List.of(new SavedCatalog(
+ List.of(new DefaultCatalog(
"QuPath catalog",
"Extensions maintained by the QuPath team",
URI.create("https://github.com/qupath/qupath-catalog"),
- URI.create("https://raw.githubusercontent.com/qupath/qupath-catalog/refs/heads/main/catalog.json"),
- false
- )))
+ URI.create("https://raw.githubusercontent.com/qupath/qupath-catalog/refs/heads/main/catalog.json")
+ ))
);
new ExtensionManager(extensionCatalogManager, () -> {}).show();
diff --git a/extensionmanager-app/src/main/java/qupath/ext/extensionmanager/app/package-info.java b/extensionmanager-app/src/main/java/qupath/ext/extensionmanager/app/package-info.java
index 288d46e..99571a1 100644
--- a/extensionmanager-app/src/main/java/qupath/ext/extensionmanager/app/package-info.java
+++ b/extensionmanager-app/src/main/java/qupath/ext/extensionmanager/app/package-info.java
@@ -1,5 +1,4 @@
/**
- * This package contains an application to start an
- * {@link qupath.ext.extensionmanager.gui.ExtensionManager ExtensionManager}.
+ * This package contains an application to start an {@link qupath.ext.extensionmanager.gui.ExtensionManager ExtensionManager}.
*/
package qupath.ext.extensionmanager.app;
\ No newline at end of file
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/ExtensionCatalogManager.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/ExtensionCatalogManager.java
index bc85e24..5126331 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/ExtensionCatalogManager.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/ExtensionCatalogManager.java
@@ -1,23 +1,21 @@
package qupath.ext.extensionmanager.core;
-import javafx.beans.property.ObjectProperty;
-import javafx.beans.property.ReadOnlyObjectProperty;
-import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import qupath.ext.extensionmanager.core.catalog.CatalogFetcher;
-import qupath.ext.extensionmanager.core.savedentities.SavedCatalog;
-import qupath.ext.extensionmanager.core.savedentities.UpdateAvailable;
+import qupath.ext.extensionmanager.core.catalog.Catalog;
+import qupath.ext.extensionmanager.core.catalog.DefaultCatalog;
+import qupath.ext.extensionmanager.core.catalog.Extension;
+import qupath.ext.extensionmanager.core.catalog.Release;
+import qupath.ext.extensionmanager.core.registry.Registry;
+import qupath.ext.extensionmanager.core.catalog.UpdateAvailable;
+import qupath.ext.extensionmanager.core.registry.RegistryCatalog;
import qupath.ext.extensionmanager.core.tools.FileDownloader;
import qupath.ext.extensionmanager.core.tools.FileTools;
import qupath.ext.extensionmanager.core.tools.ZipExtractor;
-import qupath.ext.extensionmanager.core.catalog.Extension;
-import qupath.ext.extensionmanager.core.catalog.Release;
-import qupath.ext.extensionmanager.core.savedentities.InstalledExtension;
-import qupath.ext.extensionmanager.core.savedentities.Registry;
import java.io.IOError;
import java.io.IOException;
@@ -29,27 +27,25 @@
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
-import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutionException;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
-import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
- * A manager for catalogs and extensions. It can be used to get access to all saved catalogs,
- * add or remove a catalog, get access to all installed extensions, and install or delete an extension.
- * Manually installed extensions are automatically detected.
+ * A manager for catalogs and extensions. It can be used to get access to all saved catalogs, add or remove a catalog, get
+ * access to all installed extensions, and install or delete an extension. Manually installed extensions are automatically
+ * detected.
*
- * It also automatically loads extension classes with a custom ClassLoader (see {@link #getExtensionClassLoader()}).
- * Note that removed extensions are not unloaded from the class loader.
+ * It also automatically loads extension classes with a custom ClassLoader (see {@link #getExtensionClassLoader()}). Note
+ * that removed extensions are not unloaded from the class loader.
*
- * The list of active catalogs and installed extensions is determined by this class. It is internally saved
- * in a registry JSON file located in the extension directory (see {@link Registry}).
+ * The list of active catalogs and installed extensions is determined by this class. It is internally saved in a registry
+ * JSON file located in the extension directory.
*
* This class is thread-safe.
*
@@ -58,16 +54,13 @@
public class ExtensionCatalogManager implements AutoCloseable{
private static final Logger logger = LoggerFactory.getLogger(ExtensionCatalogManager.class);
- private final ObservableList savedCatalogs = FXCollections.observableList(new CopyOnWriteArrayList<>());
- private final ObservableList savedCatalogsImmutable = FXCollections.unmodifiableObservableList(savedCatalogs);
- private final Map>> installedExtensions = new ConcurrentHashMap<>();
+ private final ObservableList catalogs = FXCollections.observableList(new CopyOnWriteArrayList<>());
+ private final ObservableList catalogsImmutable = FXCollections.unmodifiableObservableList(catalogs);
private final ObservableList catalogManagedInstalledJars = FXCollections.observableList(new CopyOnWriteArrayList<>());
private final ObservableList catalogManagedInstalledJarsImmutable = FXCollections.unmodifiableObservableList(catalogManagedInstalledJars);
private final ExtensionFolderManager extensionFolderManager;
private final ExtensionClassLoader extensionClassLoader;
- private final String version;
- private final Registry defaultRegistry;
- private record CatalogExtension(SavedCatalog savedCatalog, Extension extension) {}
+ private final Version version;
private record UriFileName(URI uri, Path filePath) {}
/**
* Indicate an extension installation step
@@ -90,360 +83,293 @@ private enum Operation {
/**
* Create the extension catalog manager.
*
- * @param extensionDirectoryPath a read-only property pointing to the path the extension directory should have. The
- * path can be null or invalid (but not the property). If this property is changed,
- * catalogs and extensions will be set to the content of the new value of the property
- * (so will be reset if the new path is empty)
+ * @param extensionsDirectoryPath an observable value pointing to the path the extensions directory should have. The
+ * path can be null or invalid (but not the observable). If this observable is changed,
+ * catalogs and extensions will be set to the content of the new value of the observable
+ * (so will be reset if the new path is empty)
* @param parentClassLoader the class loader that should be the parent of the extension class loader. Can be null to use
* the bootstrap class loader
- * @param version a text describing the release of the current software with the form "v[MAJOR].[MINOR].[PATCH]"
- * or "v[MAJOR].[MINOR].[PATCH]-rc[RELEASE_CANDIDATE]". It will determine which extensions are
- * compatible
- * @param defaultRegistry the default registry to use when the saved one cannot be used. Can be null
+ * @param version a text describing the release of the current software with the form "v[MAJOR].[MINOR].[PATCH]" or
+ * "v[MAJOR].[MINOR].[PATCH]-rc[RELEASE_CANDIDATE]". It will determine which extensions are compatible
+ * @param defaultCatalogs a list of catalogs this manager should use by default, i.e. when no catalog or extension is
+ * installed
* @throws IllegalArgumentException if the provided version doesn't meet the specified requirements
- * @throws SecurityException if the user doesn't have enough rights to create the extension class loader
- * @throws NullPointerException if extensionDirectoryPath or version is null
+ * @throws NullPointerException if one of the parameters (except the class loader) is null
*/
public ExtensionCatalogManager(
- ReadOnlyObjectProperty extensionDirectoryPath,
+ ObservableValue extensionsDirectoryPath,
ClassLoader parentClassLoader,
String version,
- Registry defaultRegistry
+ List defaultCatalogs
) {
- Version.isValid(version, true);
-
- this.extensionFolderManager = new ExtensionFolderManager(extensionDirectoryPath);
+ this.extensionFolderManager = new ExtensionFolderManager(extensionsDirectoryPath);
this.extensionClassLoader = new ExtensionClassLoader(parentClassLoader);
- this.version = version;
- this.defaultRegistry = defaultRegistry;
-
- setCatalogsFromRegistry();
- extensionDirectoryPath.addListener((p, o, n) -> {
- setCatalogsFromRegistry();
+ this.version = new Version(version);
- synchronized (this) {
- for (CatalogExtension catalogExtension : installedExtensions.keySet()) {
- installedExtensions.get(catalogExtension).set(getInstalledExtension(catalogExtension));
- }
- }
- });
-
- updateCatalogManagedInstalledJarsOfDirectory(extensionFolderManager.getCatalogsDirectoryPath().getValue(), Operation.ADD);
- extensionFolderManager.getCatalogsDirectoryPath().addListener((p, o, n) -> {
- updateCatalogManagedInstalledJarsOfDirectory(o, Operation.REMOVE);
- updateCatalogManagedInstalledJarsOfDirectory(n, Operation.ADD);
- });
+ List copyOfDefaultCatalogs = List.copyOf(defaultCatalogs); // make sure the list is not modified later
+ resetCatalogsAndJars(copyOfDefaultCatalogs);
+ extensionFolderManager.getCatalogsDirectoryPath().addListener((p, o, n) ->
+ resetCatalogsAndJars(copyOfDefaultCatalogs)
+ );
loadJars();
}
@Override
public void close() throws Exception {
- this.extensionFolderManager.close();
this.extensionClassLoader.close();
+ this.extensionFolderManager.close();
}
/**
- * @return a read only property containing the path to the extension folder. It may be updated from any thread and the
- * path (but not the property) canvbe null or invalid
+ * @return the version of the current software, as given in {@link #ExtensionCatalogManager(ObservableValue, ClassLoader, String, List)}
*/
- public ReadOnlyObjectProperty getExtensionDirectoryPath() {
- return extensionFolderManager.getExtensionDirectoryPath();
+ public Version getVersion() {
+ return version;
}
/**
- * @return a text describing the release of the current software with the form "v[MAJOR].[MINOR].[PATCH]" or
- * "v[MAJOR].[MINOR].[PATCH]-rc[RELEASE_CANDIDATE]"
+ * @return an observable value containing the path to the extensions folder. It may be updated from any thread and the
+ * path (but not the observable) can be null or invalid
*/
- public String getVersion() {
- return version;
+ public ObservableValue getExtensionsDirectory() {
+ return extensionFolderManager.getExtensionsDirectoryPath();
}
/**
- * Get the path to the directory containing the provided catalog.
+ * Get the path to the directory containing the provided catalog. It may not exist.
*
- * @param savedCatalog the catalog to retrieve
+ * @param catalogName the name of the catalog to retrieve
* @return the path of the directory containing the provided catalog
* @throws InvalidPathException if the path cannot be created
- * @throws NullPointerException if the provided catalog is null or if the path contained in
- * {@link ExtensionFolderManager#getCatalogsDirectoryPath()} is null
+ * @throws NullPointerException if the provided catalog is null or if the path contained in {@link #getExtensionsDirectory()}
+ * is null
*/
- public Path getCatalogDirectory(SavedCatalog savedCatalog) {
- return extensionFolderManager.getCatalogDirectoryPath(savedCatalog);
+ public Path getCatalogDirectory(String catalogName) {
+ return extensionFolderManager.getCatalogDirectoryPath(catalogName);
}
/**
- * Add catalogs to the available list. This will save them to the registry. Catalogs with the same name as an already
- * existing catalog will not be added. No check will be performed concerning whether the provided catalogs point to
- * valid catalogs.
+ * Add and save a catalog.
*
- * If an exception occurs (see below), the provided catalogs are not added.
+ * This operation may take some time, but can be interrupted.
*
- * @param savedCatalogs the catalogs to add. They must have different names
- * @throws IOException if an I/O error occurs while saving the registry file. In that case, the provided catalogs are
- * not added
- * @throws SecurityException if the user doesn't have sufficient rights to save the registry file
- * @throws NullPointerException if the path contained in {@link #getExtensionDirectoryPath()} is null, if the provided
- * list of catalogs is null or if one of the provided catalog is null
- * @throws IllegalArgumentException if at least two of the provided catalogs have the same name
+ * @param catalog the catalog to add. It must have a different name from the ones returned by {@link #getCatalogs()}
+ * @throws IllegalArgumentException if a catalog with the same name already exists
+ * @throws IOException if an I/O error occurs while saving the catalogs to disk
+ * @throws NullPointerException if the path contained in {@link #getExtensionsDirectory()} is null or if the provided
+ * catalog is null
+ * @throws InvalidPathException if the path to the registry containing the list of catalogs cannot be created
+ * @throws ExecutionException if an error occurred while saving the registry
+ * @throws InterruptedException if the calling thread is interrupted
*/
- public void addCatalog(List savedCatalogs) throws IOException {
- if (savedCatalogs.stream().map(SavedCatalog::name).collect(Collectors.toSet()).size() < savedCatalogs.size()) {
+ public synchronized void addCatalog(Catalog catalog) throws IOException, ExecutionException, InterruptedException {
+ Objects.requireNonNull(catalog);
+
+ if (catalogs.stream().map(Catalog::getName).anyMatch(catalogName -> catalogName.equals(catalog.getName()))) {
throw new IllegalArgumentException(String.format(
- "Two of the provided catalogs %s have the same name",
- savedCatalogs
+ "Cannot add %s: a catalog with the same name already exists",
+ catalog
));
}
- if (getExtensionDirectoryPath().get() == null) {
- throw new NullPointerException("The extension directory path is null");
- }
-
- List catalogsToAdd;
- synchronized (this) {
- catalogsToAdd = savedCatalogs.stream()
- .filter(savedCatalog -> {
- if (this.savedCatalogs.stream().noneMatch(catalog -> catalog.name().equals(savedCatalog.name()))) {
- return true;
- } else {
- logger.warn("{} has the same name as an existing catalog and will not be added", savedCatalog.name());
- return false;
- }
- })
- .toList();
-
- if (catalogsToAdd.isEmpty()) {
- logger.debug("No catalog to add");
- return;
- }
-
- this.savedCatalogs.addAll(catalogsToAdd);
- }
+ catalogs.add(catalog);
try {
- extensionFolderManager.saveRegistry(new Registry(this.savedCatalogs));
+ extensionFolderManager.saveRegistry(Registry.createFromCatalogs(catalogs).get());
} catch (Exception e) {
- this.savedCatalogs.removeAll(catalogsToAdd);
-
+ catalogs.remove(catalog);
throw e;
}
- logger.info("Catalogs {} added", catalogsToAdd.stream().map(SavedCatalog::name).toList());
+ logger.info("Catalog {} added", catalog);
}
/**
- * Get the catalogs added or removed with {@link #addCatalog(List)} and {@link #removeCatalogs(List, boolean)}. This
- * list may be updated from any thread and won't contain null elements.
+ * Get the catalogs added or removed with {@link #addCatalog(Catalog)} and {@link #removeCatalog(Catalog)}. This list
+ * may be updated from any thread and won't contain null elements.
*
* @return a read-only observable list of all saved catalogs
*/
- public ObservableList getCatalogs() {
- return savedCatalogsImmutable;
+ public ObservableList getCatalogs() {
+ return catalogsImmutable;
}
/**
- * Remove catalogs from the available list. This will remove them from the saved registry and may delete any installed
- * extension belonging to these catalogs.
+ * Remove the provided catalog from the list of saved catalogs.
*
- * Catalogs that are not deletable (see {@link SavedCatalog#deletable()}) won't be deleted.
+ * Warning: this will attempt to move the directory returned by {@link #getCatalogDirectory(String)} to trash if supported
+ * by this platform or recursively delete it if extension are asked to be removed. If this operation fails, no exception
+ * is thrown.
*
- * If an exception occurs (see below), the provided catalogs are not added.
+ * If the provided catalog does not belong to {@link #getCatalogs()}, nothing happens.
*
- * Warning: this will move the directory returned by {@link #getCatalogDirectory(SavedCatalog)} to trash if supported
- * by this platform or recursively delete it if extension are asked to be removed.
+ * This operation may take some time, but can be interrupted.
*
- * @param savedCatalogs the catalogs to remove
- * @param removeExtensions whether to remove extensions belonging to the catalogs to remove
- * @throws IOException if an I/O error occurs while saving the registry file. In that case, the provided catalogs are
- * not added
- * @throws SecurityException if the user doesn't have sufficient rights to save the registry file
- * @throws NullPointerException if the path contained in {@link #getExtensionDirectoryPath()} is null, if the provided
- * list of catalogs is null or if one of the provided catalog is null
+ * @param catalog the catalog to remove
+ * @throws IllegalArgumentException if the provided catalog is not {@link RegistryCatalog#deletable() deletable}
+ * @throws IOException if an I/O error occurs while removing the catalog from disk
+ * @throws NullPointerException if the path contained in {@link #getExtensionsDirectory()} is null or if the provided
+ * catalog is null
+ * @throws InvalidPathException if the path to the registry containing the list of catalogs cannot be created
+ * @throws ExecutionException if an error occurred while saving the registry
+ * @throws InterruptedException if the calling thread is interrupted
*/
- public void removeCatalogs(List savedCatalogs, boolean removeExtensions) throws IOException {
- if (getExtensionDirectoryPath().get() == null) {
- throw new NullPointerException("The extension directory path is null");
- }
-
- List catalogsToRemove = savedCatalogs.stream()
- .filter(savedCatalog -> {
- if (savedCatalog.deletable()) {
- return true;
- } else {
- logger.warn("{} is not deletable and won't be deleted", savedCatalog.name());
- return false;
- }
- })
- .toList();
- if (catalogsToRemove.isEmpty()) {
- logger.debug("No catalog to remove");
+ public synchronized void removeCatalog(Catalog catalog) throws IOException, ExecutionException, InterruptedException {
+ if (catalogs.stream().noneMatch(catalog::equals)) {
+ logger.debug("{} was asked to be removed, but does not belong to {}. Doing nothing", catalog, catalogs);
return;
}
- this.savedCatalogs.removeAll(catalogsToRemove);
+ if (!catalog.isDeletable()) {
+ throw new IllegalArgumentException(String.format("Cannot delete %s: this catalog is not deletable", catalog));
+ }
+ catalogs.remove(catalog);
try {
- extensionFolderManager.saveRegistry(new Registry(this.savedCatalogs));
+ extensionFolderManager.saveRegistry(Registry.createFromCatalogs(catalogs).get());
} catch (Exception e) {
- this.savedCatalogs.addAll(catalogsToRemove);
-
+ catalogs.add(catalog);
throw e;
}
- if (removeExtensions) {
- for (SavedCatalog savedCatalog : catalogsToRemove) {
- try {
- extensionFolderManager.deleteExtensionsFromCatalog(savedCatalog);
- } catch (IOException | SecurityException | InvalidPathException | NullPointerException e) {
- logger.debug("Could not delete {}", savedCatalog.name(), e);
- }
- }
+ updateCatalogManagedInstalledJarsOfDirectory(
+ extensionFolderManager.getCatalogDirectoryPath(catalog.getName()),
+ Operation.REMOVE
+ );
- for (var entry: installedExtensions.entrySet()) {
- if (catalogsToRemove.contains(entry.getKey().savedCatalog)) {
- synchronized (this) {
- entry.getValue().set(Optional.empty());
- }
- }
- }
+ try {
+ extensionFolderManager.deleteExtensionsFromCatalog(catalog.getName());
+ } catch (IOException | InvalidPathException | NullPointerException e) {
+ logger.debug("Could not delete {}", catalog, e);
}
- logger.info("Catalogs {} removed", catalogsToRemove.stream().map(SavedCatalog::name).toList());
+ logger.info("Catalog {} removed", catalog);
}
/**
- * Get the path to the directory containing the provided extension of the provided catalog. This will also create the
- * directory containing all installed extensions of the provided catalog if it doesn't already exist (but the returned
- * directory is not guaranteed to be created).
+ * Get the path to the directory containing the provided extension of the provided catalog.
*
- * @param savedCatalog the catalog owning the extension
- * @param extension the extension to retrieve
+ * @param catalogName the name of the catalog owning the extension
+ * @param extensionName the name of the extension to retrieve
* @return the path to the folder containing the provided extension
- * @throws IOException if an I/O error occurs while creating the directory
* @throws InvalidPathException if the path cannot be created
- * @throws SecurityException if the user doesn't have enough rights to create the directory
- * @throws NullPointerException if one of the parameters is null or if the path contained in
- * {@link #getExtensionDirectoryPath()} is null
+ * @throws NullPointerException if one of the parameters is null or if the path contained in {@link #getExtensionsDirectory()}
+ * is null
*/
- public Path getExtensionDirectory(SavedCatalog savedCatalog, Extension extension) throws IOException {
- return extensionFolderManager.getExtensionDirectoryPath(savedCatalog, extension);
+ public Path getExtensionDirectory(String catalogName, String extensionName) {
+ return extensionFolderManager.getExtensionDirectoryPath(catalogName, extensionName);
}
/**
- * Get the list of links the {@link #installOrUpdateExtension(SavedCatalog, Extension, InstalledExtension, Consumer, BiConsumer)}
- * function will download to install the provided extension.
+ * Get the list of links the {@link #installOrUpdateExtension(Catalog, Extension, Release, boolean, Consumer, BiConsumer)}
+ * function will download to install the provided release.
*
- * @param savedCatalog the catalog owning the extension to install
- * @param extension the extension to install
- * @param installationInformation what to install on the extension
+ * @param release the release of the extension to install
+ * @param installOptionalDependencies whether to install optional dependencies
* @return the list URIs that will be downloaded to install the extension with the provided parameters
- * @throws NullPointerException if one of the parameters is null or if the path contained in {@link #getExtensionDirectoryPath()}
- * is null
- * @throws IOException if an I/O error occurred while deleting, downloading or installing the extension
* @throws InvalidPathException if a path cannot be created, for example because the extensions folder path contain
* invalid characters
- * @throws SecurityException if the user doesn't have sufficient rights to install or update the extension
- * @throws IllegalArgumentException if the release name of the provided installation information cannot be found in
- * the releases of the provided extension
+ * @throws NullPointerException if one of the parameters is null or if the path contained in {@link #getExtensionsDirectory()}
+ * is null
*/
- public List getDownloadLinks(SavedCatalog savedCatalog, Extension extension, InstalledExtension installationInformation) throws IOException {
- return getDownloadUrlsToFilePaths(savedCatalog, extension, installationInformation, false).stream()
- .map(UriFileName::uri)
- .toList();
+ public List getDownloadLinks(Release release, boolean installOptionalDependencies) {
+ try {
+ return getDownloadUrlsToFilePaths(
+ "catalog", // The catalog and extension names parameters are only used to create the file
+ "extension", // paths, which are not considered here
+ release,
+ installOptionalDependencies,
+ false
+ ).stream().map(UriFileName::uri).toList();
+ } catch (IOException e) {
+ // IOException only occurs if directories are created, which is not the case here, so this should never be called
+ throw new RuntimeException(e);
+ }
}
/**
* Install (or update if it already exists) an extension. This may take a lot of time depending on the internet connection
- * and the size of the extension, but this operation is cancellable.
+ * and the size of the extension, but this operation can be interrupted.
*
* If the extension already exists, it will be deleted before downloading the provided version of the extension.
*
- * Warning: this will move to trash the directory returned by {@link #getExtensionDirectory(SavedCatalog, Extension)}
- * or recursively delete it if moving files to trash is not supported.
+ * Warning: If the extension already exists, this function will attempt to move to trash the directory returned by
+ * {@link #getExtensionDirectory(String, String)} or recursively delete it if moving files to trash is not supported.
+ * If this operation fails, no exception is thrown.
+ *
+ * Note that this function attempts to install the provided release even if it is not compatible with {@link #getVersion()}.
*
- * @param savedCatalog the catalog owning the extension to install/update
- * @param extension the extension to install/update
- * @param installationInformation what to install/update on the extension
- * @param onProgress a function that will be called at different steps during the installation. Its parameter
- * will be a float between 0 and 1 indicating the progress of the installation (0: beginning,
- * 1: finished). This function will be called from the calling thread
+ * @param catalog the catalog owning the extension to install/update. It must be one of {@link #getCatalogs()}
+ * @param extension the extension to install/update. It must belong to the provided catalog
+ * @param release the release to install. It must belong to the provided extension
+ * @param installOptionalDependencies whether to install optional dependencies
+ * @param onProgress a function that will be called at different steps during the installation. Its parameter will be
+ * a float between 0 and 1 indicating the progress of the installation (0: beginning, 1: finished).
+ * This function will be called from the calling thread
* @param onStatusChanged a function that will be called at different steps during the installation. Its first parameter
* will be the step currently happening, and its second parameter a text describing the resource
* on which the step is happening (for example, a link if the step is a download). This function
* will be called from the calling thread
- * @throws NullPointerException if one of the parameters is null or if the path contained in {@link #getExtensionDirectoryPath()}
+ * @throws IllegalArgumentException if the provided catalog does not belong to {@link #getCatalogs()}, if the provided
+ * extension does not belong to the provided catalog, or if the provided release does not belong to the provided extension
+ * @throws NullPointerException if one of the parameters is null or if the path contained in {@link #getExtensionsDirectory()}
* is null
* @throws IOException if an I/O error occurred while deleting, downloading or installing the extension
* @throws InvalidPathException if a path cannot be created, for example because the extensions folder path contain
* invalid characters
- * @throws SecurityException if the user doesn't have sufficient rights to install or update the extension
- * @throws IllegalArgumentException if the release name of the provided installation information cannot be found in
- * the releases of the provided extension
* @throws InterruptedException if the calling thread is interrupted
+ * @throws ExecutionException if an error occurs while retrieving the extensions of the provided catalog
*/
- public void installOrUpdateExtension(
- SavedCatalog savedCatalog,
+ public synchronized void installOrUpdateExtension(
+ Catalog catalog,
Extension extension,
- InstalledExtension installationInformation,
+ Release release,
+ boolean installOptionalDependencies,
Consumer onProgress,
BiConsumer onStatusChanged
- ) throws IOException, InterruptedException {
- var extensionProperty = installedExtensions.computeIfAbsent(
- new CatalogExtension(savedCatalog, extension),
- e -> new SimpleObjectProperty<>()
- );
+ ) throws IOException, InterruptedException, ExecutionException {
+ if (extension.getReleases().stream().noneMatch(release::equals)) {
+ throw new IllegalArgumentException(String.format(
+ "The provided release %s does not belong to the provided extension %s",
+ release,
+ extension
+ ));
+ }
- logger.debug("Deleting files of {} before installing or updating it", extension.name());
- updateCatalogManagedInstalledJarsOfDirectory(
- extensionFolderManager.getExtensionDirectoryPath(savedCatalog, extension),
- Operation.REMOVE
+ removeExtension(catalog, extension);
+
+ downloadAndExtractLinks(
+ getDownloadUrlsToFilePaths(
+ catalog.getName(),
+ extension.getName(),
+ release,
+ installOptionalDependencies,
+ true
+ ),
+ onProgress,
+ onStatusChanged
);
- extensionFolderManager.deleteExtension(savedCatalog, extension);
- synchronized (this) {
- extensionProperty.set(Optional.empty());
- }
+
+ extension.installRelease(release, installOptionalDependencies);
try {
- downloadAndExtractLinks(
- getDownloadUrlsToFilePaths(savedCatalog, extension, installationInformation, true),
- onProgress,
- onStatusChanged
- );
+ extensionFolderManager.saveRegistry(Registry.createFromCatalogs(catalogs).get());
} catch (Exception e) {
- logger.debug("Installation of {} failed. Clearing extension files", extension.name());
- extensionFolderManager.deleteExtension(savedCatalog, extension);
+ extension.uninstallRelease();
throw e;
}
+
updateCatalogManagedInstalledJarsOfDirectory(
- extensionFolderManager.getExtensionDirectoryPath(savedCatalog, extension),
+ extensionFolderManager.getExtensionDirectoryPath(catalog.getName(), extension.getName(), release.getVersion().toString()),
Operation.ADD
);
- synchronized (this) {
- extensionProperty.set(Optional.of(installationInformation));
- }
-
- logger.info("{} of {} installed", extension.name(), savedCatalog.name());
+ logger.info("{} of {} installed", extension, catalog);
}
/**
- * Indicate whether an extension belonging to a catalog is installed.
- *
- * @param savedCatalog the catalog owning the extension to find
- * @param extension the extension to get installed information on
- * @return a read-only object property containing an Optional of an installed extension. If the Optional is empty,
- * then it means the extension is not installed. This property may be updated from any thread
- */
- public ReadOnlyObjectProperty> getInstalledExtension(SavedCatalog savedCatalog, Extension extension) {
- return installedExtensions.computeIfAbsent(
- new CatalogExtension(savedCatalog, extension),
- catalogExtension -> new SimpleObjectProperty<>(getInstalledExtension(catalogExtension))
- );
- }
-
- /**
- * @return a read-only observable list of paths pointing to JAR files that were added with catalogs to the extension
- * directory. This list can be updated from any thread. Note that this list can take a few seconds to update when a
- * JAR is added or removed
+ * @return a read-only observable list of paths pointing to JAR files that were added with function of this class. This
+ * list can be updated from any thread
*/
public ObservableList getCatalogManagedInstalledJars() {
return catalogManagedInstalledJarsImmutable;
@@ -476,49 +402,81 @@ public void addOnJarLoadedRunnable(Runnable runnable) {
* @return a CompletableFuture with a list of available updates, or a failed CompletableFuture if the update query
* failed
*/
- public CompletableFuture> getAvailableUpdates() {
- return CompletableFuture.supplyAsync(() -> savedCatalogs.stream()
- .map(savedCatalog -> CatalogFetcher.getCatalog(savedCatalog.rawUri()).join().extensions().stream()
- .map(extension -> getUpdateAvailable(savedCatalog, extension))
- .filter(Objects::nonNull)
+ public synchronized CompletableFuture> getAvailableUpdates() {
+ return CompletableFuture.supplyAsync(() -> catalogs.stream()
+ .map(catalog -> catalog.getExtensions().join().stream()
+ .map(extension -> extension.getUpdateAvailable(version))
+ .flatMap(Optional::stream)
.toList()
)
.flatMap(List::stream)
- .toList());
+ .toList()
+ );
}
/**
- * Uninstall an extension by removing its files. This can take some time depending on the number of files to delete
- * and the speed of the disk.
+ * Uninstall an extension and attempt to remove its files. This can take some time but the operation can be interrupted.
*
- * Warning: this will move the directory returned by {@link #getExtensionDirectory(SavedCatalog, Extension)} to
- * trash or recursively delete it if moving files to trash is not supported by this platform.
+ * Warning: this will attempt to move the directory returned by {@link #getExtensionDirectory(String, String)} to
+ * trash or recursively delete it if moving files to trash is not supported by this platform. If this operation fails,
+ * the function doesn't throw any exceptions.
*
- * @param savedCatalog the catalog owning the extension to uninstall
- * @param extension the extension to uninstall
- * @throws IOException if an I/O error occurs while deleting the folder
- * @throws InvalidPathException if the path of the extension folder cannot be created, for example because the extension
- * name contain invalid characters
- * @throws SecurityException if the user doesn't have sufficient rights to delete the extension files
- * @throws NullPointerException if the path contained in {@link #getExtensionDirectoryPath()} is null, or if one of
- * the parameters is null
+ * @param catalog the catalog owning the extension to uninstall. It must be one of {@link #getCatalogs()}
+ * @param extension the extension to uninstall. It must belong to the provided catalog
+ * @throws IllegalArgumentException if the provided catalog does not belong to {@link #getCatalogs()}, or if the provided
+ * extension does not belong to the provided catalog
+ * @throws NullPointerException if one of the parameters is null or if the path contained in {@link #getExtensionsDirectory()}
+ * is null
+ * @throws IOException if an I/O error occurred while deleting the extension
+ * @throws InvalidPathException if a path cannot be created, for example because the extensions folder path contain
+ * invalid characters
+ * @throws InterruptedException if the calling thread is interrupted
+ * @throws ExecutionException if an error occurs while retrieving the extensions of the provided catalog
*/
- public void removeExtension(SavedCatalog savedCatalog, Extension extension) throws IOException {
- var extensionProperty = installedExtensions.computeIfAbsent(
- new CatalogExtension(savedCatalog, extension),
- e -> new SimpleObjectProperty<>()
- );
+ public synchronized void removeExtension(Catalog catalog, Extension extension) throws IOException, ExecutionException, InterruptedException {
+ if (catalogs.stream().noneMatch(catalog::equals)) {
+ throw new IllegalArgumentException(String.format(
+ "The provided catalog %s is not among the internal list %s",
+ catalog,
+ catalogs
+ ));
+ }
+ if (catalog.getExtensions().get().stream().noneMatch(extension::equals)) {
+ throw new IllegalArgumentException(String.format(
+ "The provided extension %s does not belong to the provided catalog %s",
+ extension,
+ catalog
+ ));
+ }
+
+ if (extension.getInstalledRelease().getValue().isEmpty()) {
+ logger.debug("{} is not installed. Skipping deletion of it", extension);
+ return;
+ }
+
+ extension.uninstallRelease();
+ try {
+ extensionFolderManager.saveRegistry(Registry.createFromCatalogs(catalogs).get());
+ } catch (Exception e) {
+ extension.installRelease(
+ extension.getInstalledRelease().getValue().get(),
+ extension.areOptionalDependenciesInstalled().get()
+ );
+ throw e;
+ }
updateCatalogManagedInstalledJarsOfDirectory(
- extensionFolderManager.getExtensionDirectoryPath(savedCatalog, extension),
+ extensionFolderManager.getExtensionDirectoryPath(catalog.getName(), extension.getName()),
Operation.REMOVE
);
- extensionFolderManager.deleteExtension(savedCatalog, extension);
- synchronized (this) {
- extensionProperty.set(Optional.empty());
+
+ try {
+ extensionFolderManager.deleteExtension(catalog.getName(), extension.getName());
+ } catch (Exception e) {
+ logger.debug("Error while removing files of {}. They won't be deleted", extension, e);
}
- logger.info("{} of {} removed", extension.name(), savedCatalog.name());
+ logger.info("{} of {} removed", extension, catalog);
}
/**
@@ -530,27 +488,56 @@ public ObservableList getManuallyInstalledJars() {
return extensionFolderManager.getManuallyInstalledJars();
}
- private synchronized void setCatalogsFromRegistry() {
- this.savedCatalogs.clear();
-
+ private synchronized void resetCatalogsAndJars(List defaultCatalogs) {
+ List catalogs;
try {
- this.savedCatalogs.addAll(extensionFolderManager.getSavedRegistry().catalogs());
- logger.debug("Catalogs set from saved registry");
+ catalogs = extensionFolderManager.getSavedRegistry().catalogs();
+ this.catalogs.setAll(catalogs.stream()
+ .map(Catalog::new)
+ .toList()
+ );
} catch (Exception e) {
- logger.debug("Error while retrieving saved registry. Using default one", e);
+ logger.debug("Cannot retrieve saved registry. Using default catalogs {}", defaultCatalogs, e);
- if (defaultRegistry != null) {
- this.savedCatalogs.addAll(defaultRegistry.catalogs());
- logger.debug(
- "Catalogs {} set from default registry",
- defaultRegistry.catalogs().stream().map(SavedCatalog::name).toList()
- );
- }
+ catalogs = List.of();
+ this.catalogs.setAll(defaultCatalogs.stream().map(Catalog::new).toList());
+ }
+
+ catalogManagedInstalledJars.clear();
+ List releasePaths = catalogs.stream()
+ .flatMap(catalog -> catalog.extensions().stream()
+ .map(extension -> extensionFolderManager.getExtensionDirectoryPath(
+ catalog.name(),
+ extension.name(),
+ extension.installedVersion())
+ )
+ )
+ .toList();
+ for (Path releasePath: releasePaths) {
+ updateCatalogManagedInstalledJarsOfDirectory(releasePath, Operation.ADD);
}
}
+ private void loadJars() {
+ addJars(extensionFolderManager.getManuallyInstalledJars());
+ extensionFolderManager.getManuallyInstalledJars().addListener((ListChangeListener super Path>) change -> {
+ while (change.next()) {
+ addJars(change.getAddedSubList());
+ }
+ change.reset();
+ });
+
+ addJars(catalogManagedInstalledJarsImmutable);
+ catalogManagedInstalledJarsImmutable.addListener((ListChangeListener super Path>) change -> {
+ while (change.next()) {
+ addJars(change.getAddedSubList());
+ }
+ change.reset();
+ });
+ }
+
private void updateCatalogManagedInstalledJarsOfDirectory(Path directory, Operation operation) {
- if (directory != null) {
+ if (directory != null && directory.toFile().exists()) {
try (Stream files = Files.walk(directory)) {
List jars = files.filter(path -> path.toString().endsWith(".jar")).toList();
@@ -572,115 +559,72 @@ private void updateCatalogManagedInstalledJarsOfDirectory(Path directory, Operat
}
}
- private Optional getInstalledExtension(CatalogExtension catalogExtension) {
- try {
- return extensionFolderManager.getInstalledExtension(
- catalogExtension.savedCatalog,
- catalogExtension.extension
- );
- } catch (IOException | InvalidPathException | SecurityException | NullPointerException e) {
- logger.debug("Error while retrieving {} installation information", catalogExtension.extension.name(), e);
- return Optional.empty();
- }
- }
-
- private void loadJars() {
- addJars(extensionFolderManager.getManuallyInstalledJars());
- extensionFolderManager.getManuallyInstalledJars().addListener((ListChangeListener super Path>) change -> {
- while (change.next()) {
- addJars(change.getAddedSubList());
- removeJars(change.getRemoved());
- }
- change.reset();
- });
-
- addJars(catalogManagedInstalledJarsImmutable);
- catalogManagedInstalledJarsImmutable.addListener((ListChangeListener super Path>) change -> {
- while (change.next()) {
- addJars(change.getAddedSubList());
- removeJars(change.getRemoved());
- }
- change.reset();
- });
- }
-
private List getDownloadUrlsToFilePaths(
- SavedCatalog savedCatalog,
- Extension extension,
- InstalledExtension installationInformation,
- boolean createFolders
+ String catalogName,
+ String extensionName,
+ Release release,
+ boolean installOptionalDependencies,
+ boolean createDirectories
) throws IOException {
List downloadUrlToFilePaths = new ArrayList<>();
- Optional release = extension.releases().stream()
- .filter(r -> r.name().equals(installationInformation.releaseName()))
- .findAny();
-
- if (release.isEmpty()) {
- throw new IllegalArgumentException(String.format(
- "The provided release name %s is not present in the extension releases %s",
- installationInformation.releaseName(),
- extension.releases()
- ));
- }
-
downloadUrlToFilePaths.add(new UriFileName(
- release.get().mainUrl(),
+ release.getMainUrl(),
Paths.get(
extensionFolderManager.getExtensionPath(
- savedCatalog,
- extension,
- release.get().name(),
+ catalogName,
+ extensionName,
+ release.getVersion().toString(),
ExtensionFolderManager.FileType.MAIN_JAR,
- createFolders
+ createDirectories
).toString(),
- FileTools.getFileNameFromURI(release.get().mainUrl())
+ FileTools.getFileNameFromURI(release.getMainUrl())
)
));
- for (URI javadocUri: release.get().javadocUrls()) {
+ for (URI javadocUri: release.getJavadocUrls()) {
downloadUrlToFilePaths.add(new UriFileName(
javadocUri,
Paths.get(
extensionFolderManager.getExtensionPath(
- savedCatalog,
- extension,
- release.get().name(),
+ catalogName,
+ extensionName,
+ release.getVersion().toString(),
ExtensionFolderManager.FileType.JAVADOCS,
- createFolders
+ createDirectories
).toString(),
FileTools.getFileNameFromURI(javadocUri)
)
));
}
- for (URI requiredDependencyUri: release.get().requiredDependencyUrls()) {
+ for (URI requiredDependencyUri: release.getRequiredDependencyUrls()) {
downloadUrlToFilePaths.add(new UriFileName(
requiredDependencyUri,
Paths.get(
extensionFolderManager.getExtensionPath(
- savedCatalog,
- extension,
- release.get().name(),
+ catalogName,
+ extensionName,
+ release.getVersion().toString(),
ExtensionFolderManager.FileType.REQUIRED_DEPENDENCIES,
- createFolders
+ createDirectories
).toString(),
FileTools.getFileNameFromURI(requiredDependencyUri)
)
));
}
- if (installationInformation.optionalDependenciesInstalled()) {
- for (URI optionalDependencyUri: release.get().optionalDependencyUrls()) {
+ if (installOptionalDependencies) {
+ for (URI optionalDependencyUri: release.getOptionalDependencyUrls()) {
downloadUrlToFilePaths.add(new UriFileName(
optionalDependencyUri,
Paths.get(
extensionFolderManager.getExtensionPath(
- savedCatalog,
- extension,
- release.get().name(),
+ catalogName,
+ extensionName,
+ release.getVersion().toString(),
ExtensionFolderManager.FileType.OPTIONAL_DEPENDENCIES,
- createFolders
+ createDirectories
).toString(),
FileTools.getFileNameFromURI(optionalDependencyUri)
)
@@ -731,54 +675,13 @@ private void downloadAndExtractLinks(
}
}
- private UpdateAvailable getUpdateAvailable(SavedCatalog savedCatalog, Extension extension) {
- Optional installedExtension = getInstalledExtension(
- new CatalogExtension(savedCatalog, extension)
- );
-
- if (installedExtension.isPresent()) {
- String installedRelease = installedExtension.get().releaseName();
- Optional maxCompatibleRelease = extension.getMaxCompatibleRelease(version);
-
- if (maxCompatibleRelease.isPresent() &&
- new Version(maxCompatibleRelease.get().name()).compareTo(new Version(installedRelease)) > 0
- ) {
- logger.debug(
- "{} installed and updatable to {}",
- extension.name(),
- maxCompatibleRelease.get().name()
- );
- return new UpdateAvailable(
- extension.name(),
- installedExtension.get().releaseName(),
- maxCompatibleRelease.get().name()
- );
- } else {
- logger.debug(
- "{} installed but no compatible update found",
- extension.name()
- );
- return null;
- }
- } else {
- logger.debug("{} not installed, so no update available", extension.name());
- return null;
- }
- }
-
private void addJars(List extends Path> jarPaths) {
for (Path path: jarPaths) {
try {
extensionClassLoader.addJar(path);
- } catch (IOError | SecurityException | MalformedURLException e) {
+ } catch (IOError | MalformedURLException e) {
logger.error("Cannot load extension {}", path, e);
}
}
}
-
- private void removeJars(List extends Path> jarPaths) {
- for (Path path: jarPaths) {
- extensionClassLoader.removeJar(path);
- }
- }
}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/ExtensionClassLoader.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/ExtensionClassLoader.java
index d635417..7a20443 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/ExtensionClassLoader.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/ExtensionClassLoader.java
@@ -2,6 +2,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import qupath.ext.extensionmanager.core.tools.FileTools;
import java.net.MalformedURLException;
import java.net.URL;
@@ -21,15 +22,13 @@
class ExtensionClassLoader extends URLClassLoader {
private static final Logger logger = LoggerFactory.getLogger(ExtensionClassLoader.class);
- private final Set filenamesAdded = new HashSet<>();
+ private final Set addedJars = new HashSet<>();
private final List runnables = new ArrayList<>();
/**
* Create the extension class loader.
*
- * @param parent the class loader that should be the parent of this
- * class loader
- * @throws SecurityException if the user doesn't have enough rights to create the class loader
+ * @param parent the class loader that should be the parent of this class loader
*/
public ExtensionClassLoader(ClassLoader parent) {
super(new URL[0], parent);
@@ -39,9 +38,7 @@ public ExtensionClassLoader(ClassLoader parent) {
* Load a JAR file located on the provided path.
*
* @param jarPath the path of the JAR file to load
- * @throws java.io.IOError if an I/O error occurs while obtaining the absolute path of the
- * provided path
- * @throws SecurityException if the user doesn't have read rights on the provided path
+ * @throws java.io.IOError if an I/O error occurs while obtaining the absolute path of the provided path
* @throws MalformedURLException if an error occurred while converting the provided path to a URL
* @throws NullPointerException if the provided path is null
*/
@@ -50,15 +47,16 @@ public void addJar(Path jarPath) throws MalformedURLException {
addURL(jarPath.toUri().toURL());
logger.debug("File {} loaded by extension class loader", jarPath);
- String filename = jarPath.getFileName().toString();
- if (filenamesAdded.contains(filename)) {
+ String jarName = jarPath.getFileName().toString();
+ String nameWithoutVersion = FileTools.stripVersionFromFileName(jarName);
+ if (addedJars.contains(nameWithoutVersion)) {
logger.warn(
- "A JAR file with the same file name ({}) was already added to this class loader. {} will probably not be loaded",
- filename,
- jarPath
+ "A JAR file with the same name ({}) was already added to this class loader. {} will probably not be loaded",
+ nameWithoutVersion,
+ jarName
);
}
- filenamesAdded.add(filename);
+ addedJars.add(nameWithoutVersion);
}
List runnables;
@@ -76,21 +74,7 @@ public void addJar(Path jarPath) throws MalformedURLException {
}
/**
- * Indicate that a JAR file should be unloaded.
- * While this function doesn't currently unload the JAR, it is recommended
- * to call it when a JAR file given to {@link #addJar(Path)} should not be
- * loaded anymore. A future implementation may actually unload the JAR
- *
- * @param jarPath the path of the JAR file to unload
- * @throws NullPointerException if the provided path is null
- */
- public synchronized void removeJar(Path jarPath) {
- filenamesAdded.remove(jarPath.getFileName().toString());
- }
-
- /**
- * Set a runnable to be called each time a JAR file is loaded by this class loader. The call may
- * happen from any thread.
+ * Set a runnable to be called each time a JAR file is loaded by this class loader. The call may happen from any thread.
*
* @param runnable the runnable to run when a JAR file is loaded
* @throws NullPointerException if the provided path is null
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/ExtensionFolderManager.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/ExtensionFolderManager.java
index f9c885f..9775acc 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/ExtensionFolderManager.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/ExtensionFolderManager.java
@@ -1,18 +1,12 @@
package qupath.ext.extensionmanager.core;
import com.google.gson.Gson;
-import com.google.gson.JsonIOException;
-import com.google.gson.JsonSyntaxException;
import com.google.gson.stream.JsonReader;
-import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import qupath.ext.extensionmanager.core.catalog.Extension;
-import qupath.ext.extensionmanager.core.savedentities.InstalledExtension;
-import qupath.ext.extensionmanager.core.savedentities.Registry;
-import qupath.ext.extensionmanager.core.savedentities.SavedCatalog;
+import qupath.ext.extensionmanager.core.registry.Registry;
import qupath.ext.extensionmanager.core.tools.FilesWatcher;
import qupath.ext.extensionmanager.core.tools.FileTools;
@@ -24,11 +18,8 @@
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
-import java.nio.file.Paths;
import java.util.Objects;
-import java.util.Optional;
import java.util.function.Predicate;
-import java.util.stream.Stream;
/**
* A class to manage the extension folder (containing extension installation
@@ -78,7 +69,7 @@ class ExtensionFolderManager implements AutoCloseable {
private static final String REGISTRY_NAME = "registry.json";
private static final Predicate isJar = path -> path.toString().toLowerCase().endsWith(".jar");
private static final Gson gson = new Gson();
- private final ReadOnlyObjectProperty extensionDirectoryPath;
+ private final ObservableValue extensionsDirectoryPath;
private final ObservableValue catalogsDirectoryPath;
private final FilesWatcher manuallyInstalledExtensionsWatcher;
/**
@@ -114,15 +105,15 @@ public enum FileType {
/**
* Create the extension folder manager.
*
- * @param extensionDirectoryPath a read-only property pointing to the path the extension directory should have. The
- * path can be null or invalid (but not the property). If this property is changed,
- * catalogs and extensions will be set to the content of the new value of the property
+ * @param extensionsDirectoryPath an observable value pointing to the path the extension directory should have. The
+ * path can be null or invalid (but not the observable). If this observable is changed,
+ * catalogs and extensions will be set to the content of the new value of the observable
* (so will be reset if the new path is empty)
* @throws NullPointerException if the parameter is null
*/
- public ExtensionFolderManager(ReadOnlyObjectProperty extensionDirectoryPath) {
- this.extensionDirectoryPath = extensionDirectoryPath;
- this.catalogsDirectoryPath = extensionDirectoryPath.map(path -> {
+ public ExtensionFolderManager(ObservableValue extensionsDirectoryPath) {
+ this.extensionsDirectoryPath = extensionsDirectoryPath;
+ this.catalogsDirectoryPath = extensionsDirectoryPath.map(path -> {
if (path == null) {
return null;
}
@@ -149,11 +140,11 @@ public ExtensionFolderManager(ReadOnlyObjectProperty extensionDirectoryPat
});
this.manuallyInstalledExtensionsWatcher = new FilesWatcher(
- extensionDirectoryPath,
+ extensionsDirectoryPath,
isJar,
path -> {
try {
- return path.equals(extensionDirectoryPath.get().resolve(CATALOGS_FOLDER));
+ return path.equals(extensionsDirectoryPath.getValue().resolve(CATALOGS_FOLDER));
} catch (InvalidPathException | NullPointerException e) {
logger.debug("Error when trying to assess if {} should be watched", path, e);
return true;
@@ -168,20 +159,22 @@ public void close() throws Exception {
}
/**
- * @return a read only property containing the path to the extension folder. It may be updated from any thread and
- * the path (but not the property) can be null or invalid
- */
- public ReadOnlyObjectProperty getExtensionDirectoryPath() {
- return extensionDirectoryPath;
- }
-
- /**
- * @return an observable value containing the path to the "catalogs" directory in the extension folder. It may be
- * updated from any thread and the path can be null or invalid. Note that if {@link #getExtensionDirectoryPath()}
- * points to a valid directory, the path returned by this function should also point to a valid (i.e. existing) directory
+ * Read and return the registry that was last saved with {@link #saveRegistry(Registry)}.
+ *
+ * @return the registry that was last saved with {@link #saveRegistry(Registry)}
+ * @throws IOException if an I/O error occurs while reading the registry file
+ * @throws NullPointerException if the registry file exists but is empty or if the path contained in {@link #getCatalogsDirectoryPath()}
+ * is null
+ * @throws InvalidPathException if the path to the registry cannot be created
+ * @throws RuntimeException if the registry file exists but contain a malformed JSON element
*/
- public ObservableValue getCatalogsDirectoryPath() {
- return catalogsDirectoryPath;
+ public synchronized Registry getSavedRegistry() throws IOException {
+ try(
+ FileReader fileReader = new FileReader(catalogsDirectoryPath.getValue().resolve(REGISTRY_NAME).toFile());
+ JsonReader jsonReader = new JsonReader(fileReader)
+ ) {
+ return Objects.requireNonNull(gson.fromJson(jsonReader, Registry.class));
+ }
}
/**
@@ -189,198 +182,140 @@ public ObservableValue getCatalogsDirectoryPath() {
*
* @param registry the registry to save
* @throws IOException if an I/O error occurs while writing the registry file
- * @throws NullPointerException if the path contained in {@link #getCatalogsDirectoryPath()} is null
- * or if the provided registry is null
+ * @throws NullPointerException if the path contained in {@link #getCatalogsDirectoryPath()} is null or if the provided
+ * registry is null
* @throws InvalidPathException if the path to the registry cannot be created
*/
public synchronized void saveRegistry(Registry registry) throws IOException {
try (
- FileWriter fileWriter = new FileWriter(getRegistryPath().toFile());
+ FileWriter fileWriter = new FileWriter(catalogsDirectoryPath.getValue().resolve(REGISTRY_NAME).toFile());
BufferedWriter writer = new BufferedWriter(fileWriter)
) {
writer.write(gson.toJson(Objects.requireNonNull(registry)));
- logger.debug("Registry containing {} saved", registry.catalogs().stream().map(SavedCatalog::name).toList());
}
}
/**
- * Read and return the registry that was last saved with {@link #saveRegistry(Registry)}.
- *
- * @return the registry that was last saved with {@link #saveRegistry(Registry)}
- * @throws IOException if an I/O error occurs while reading the registry file
- * @throws java.io.FileNotFoundException if the registry file does not exist
- * @throws java.nio.file.InvalidPathException if the path to the registry cannot be created
- * @throws NullPointerException if the registry file exists but is empty or if the path contained in
- * {@link #getCatalogsDirectoryPath()} is null
- * @throws JsonSyntaxException if the registry file exists but contain a malformed JSON element
- * @throws JsonIOException if there was a problem reading from the registry file
+ * @return an observable value containing the path to the extension folder. It may be updated from any thread and
+ * the path (but not the observable) can be null or invalid
*/
- public synchronized Registry getSavedRegistry() throws IOException {
- try(
- FileReader fileReader = new FileReader(getRegistryPath().toFile());
- JsonReader jsonReader = new JsonReader(fileReader)
- ) {
- return Objects.requireNonNull(gson.fromJson(jsonReader, Registry.class));
- }
+ public ObservableValue getExtensionsDirectoryPath() {
+ return extensionsDirectoryPath;
+ }
+
+ /**
+ * @return an observable value containing the path to the "catalogs" directory in the extension folder. It may be
+ * updated from any thread and the path can be null or invalid. Note that if {@link #getExtensionsDirectoryPath()}
+ * points to a valid directory, the path returned by this function should also point to a valid (i.e. existing) directory
+ */
+ public ObservableValue getCatalogsDirectoryPath() {
+ return catalogsDirectoryPath;
}
/**
- * Get the path to the directory containing the provided catalog.
+ * Get the path to the directory containing the provided catalog. It may not exist.
*
- * @param savedCatalog the catalog to retrieve
+ * @param catalogName the name of the catalog to retrieve
* @return the path to the directory containing the provided catalog
* @throws InvalidPathException if the path cannot be created
* @throws NullPointerException if the provided catalog is null or if the path contained in
* {@link #getCatalogsDirectoryPath()} is null
*/
- public synchronized Path getCatalogDirectoryPath(SavedCatalog savedCatalog) {
- return catalogsDirectoryPath.getValue().resolve(FileTools.stripInvalidFilenameCharacters(savedCatalog.name()));
+ public synchronized Path getCatalogDirectoryPath(String catalogName) {
+ return catalogsDirectoryPath.getValue().resolve(FileTools.stripInvalidFilenameCharacters(catalogName));
}
/**
* Delete all extensions belonging to the provided catalog. This will move the directory returned by
- * {@link #getCatalogDirectoryPath(SavedCatalog)} to trash or recursively delete it if moving to trash is not supported
+ * {@link #getCatalogDirectoryPath(String)} to trash or recursively delete it if moving to trash is not supported
* by this platform.
*
- * @param savedCatalog the catalog owning the extensions to delete
+ * @param catalogName the name of the catalog owning the extensions to delete
* @throws IOException if an I/O error occur while deleting the files
* @throws InvalidPathException if the path to the catalog directory cannot be created
* @throws NullPointerException if the path contained in {@link #getCatalogsDirectoryPath()} is null or if the
* provided catalog is null
*/
- public synchronized void deleteExtensionsFromCatalog(SavedCatalog savedCatalog) throws IOException {
- File catalogDirectory = getCatalogDirectoryPath(savedCatalog).toFile();
+ public synchronized void deleteExtensionsFromCatalog(String catalogName) throws IOException {
+ File catalogDirectory = getCatalogDirectoryPath(catalogName).toFile();
FileTools.moveDirectoryToTrashOrDeleteRecursively(catalogDirectory);
- logger.debug("The extension files of {} located in {} have been deleted", savedCatalog.name(), catalogDirectory);
+ logger.debug("The extension files of {} located in {} have been deleted", catalogName, catalogDirectory);
}
/**
* Get the path to the directory containing the provided extension of the provided catalog.
*
- * @param savedCatalog the catalog owning the extension
- * @param extension the extension to retrieve
+ * @param catalogName the name of the catalog owning the extension
+ * @param extensionName the name of the extension to retrieve
* @return the path to the folder containing the provided extension
* @throws InvalidPathException if the path cannot be created
* @throws NullPointerException if one of the provided parameter is null or if the path contained in
* {@link #getCatalogsDirectoryPath()} is null
*/
- public synchronized Path getExtensionDirectoryPath(SavedCatalog savedCatalog, Extension extension) {
- return getCatalogDirectoryPath(savedCatalog).resolve(FileTools.stripInvalidFilenameCharacters(extension.name()));
+ public synchronized Path getExtensionDirectoryPath(String catalogName, String extensionName) {
+ return getCatalogDirectoryPath(catalogName).resolve(FileTools.stripInvalidFilenameCharacters(extensionName));
}
/**
- * Indicate whether an extension belonging to a catalog is installed. If that's the case, installation information
- * are returned.
+ * Get the path to the directory containing the provided release of the provided extension of the provided catalog.
+ * It may not exist.
*
- * @param savedCatalog the catalog owning the extension to search
- * @param extension the extension to search
- * @return an empty Optional if the provided extension is not installed, or information
- * on the installed extension
- * @throws IOException if an I/O error occurs when searching for the extension
- * @throws InvalidPathException if the Path object of the extension cannot be created, for example because the
- * extensions folder path contain invalid characters
- * @throws NullPointerException if the path contained in {@link #getCatalogsDirectoryPath()} is null or if one of
- * the parameters is null
+ * @param catalogName the name of the catalog owning the extension
+ * @param extensionName the name of the extension to retrieve
+ * @param releaseName the name of the release to retrieve
+ * @return the path to the folder containing the provided extension
+ * @throws InvalidPathException if the path cannot be created
+ * @throws NullPointerException if one of the provided parameter is null or if the path contained in
+ * {@link #getCatalogsDirectoryPath()} is null
*/
- public synchronized Optional getInstalledExtension(SavedCatalog savedCatalog, Extension extension) throws IOException {
- Path extensionPath = getExtensionDirectoryPath(savedCatalog, extension);
-
- Path versionPath = null;
- if (Files.isDirectory(extensionPath)) {
- try (Stream stream = Files.list(extensionPath)) {
- versionPath = stream
- .filter(Files::isDirectory)
- .findAny()
- .orElse(null);
- }
- }
- if (versionPath == null) {
- logger.debug("No folder found in {}. Guessing {} is not installed", extensionPath, extension.name());
- return Optional.empty();
- }
-
- Path mainJarFolderPath = Paths.get(
- versionPath.toString(),
- FileType.MAIN_JAR.name
- );
- if (!FileTools.isDirectoryNotEmpty(mainJarFolderPath)) {
- logger.debug(
- "The folder at {} is not a non-empty directory. Guessing {} is not installed",
- mainJarFolderPath,
- extension.name()
- );
- return Optional.empty();
- }
- logger.debug("{} detected at {}", extension.name(), mainJarFolderPath);
-
- Path optionalDependenciesFolderPath = Paths.get(
- versionPath.toString(),
- FileType.OPTIONAL_DEPENDENCIES.name
- );
- boolean optionalDependenciesInstalled = FileTools.isDirectoryNotEmpty(optionalDependenciesFolderPath);
- if (optionalDependenciesInstalled) {
- logger.debug(
- "Optional dependencies of {} detected because {} is a non-empty directory",
- extension.name(),
- optionalDependenciesFolderPath
- );
- } else {
- logger.debug(
- "Optional dependencies of {} not detected because {} is not a non-empty directory",
- extension.name(),
- optionalDependenciesFolderPath
- );
- }
-
- return Optional.of(new InstalledExtension(versionPath.toFile().getName(), optionalDependenciesInstalled));
+ public synchronized Path getExtensionDirectoryPath(String catalogName, String extensionName, String releaseName) {
+ return getCatalogDirectoryPath(catalogName)
+ .resolve(FileTools.stripInvalidFilenameCharacters(extensionName))
+ .resolve(FileTools.stripInvalidFilenameCharacters(releaseName));
}
/**
* Delete all files of an extension belonging to a catalog. This will move the
- * {@link #getExtensionDirectoryPath(SavedCatalog, Extension)} directory to trash or recursively delete it the platform
+ * {@link #getExtensionDirectoryPath(String, String)} directory to trash or recursively delete it the platform
* doesn't support moving files to trash.
*
- * @param savedCatalog the catalog owning the extension to delete
- * @param extension the extension to delete
+ * @param catalogName the name of the catalog owning the extension to delete
+ * @param extensionName the name of the extension to delete
* @throws IOException if an I/O error occurs while deleting the folder
- * @throws InvalidPathException if the Path object of the extension folder cannot be created, for example because the
- * extensions folder path contain invalid characters
+ * @throws InvalidPathException if the path of the extension folder cannot be created, for example if the extensions
+ * folder path contain invalid characters
* @throws NullPointerException if the path contained in {@link #getCatalogsDirectoryPath()} is null or if one of the
* provided parameter is null
*/
- public synchronized void deleteExtension(SavedCatalog savedCatalog, Extension extension) throws IOException {
- FileTools.moveDirectoryToTrashOrDeleteRecursively(getExtensionDirectoryPath(savedCatalog, extension).toFile());
- logger.debug("The extension files of {} belonging to {} have been deleted", extension.name(), savedCatalog.name());
+ public synchronized void deleteExtension(String catalogName, String extensionName) throws IOException {
+ FileTools.moveDirectoryToTrashOrDeleteRecursively(getExtensionDirectoryPath(catalogName, extensionName).toFile());
+ logger.debug("The extension files of {} belonging to {} have been deleted", extensionName, catalogName);
}
/**
* Get (and create if asked and if it doesn't already exist) the path to the folder containing the specified files of
* the provided extension at the specified version belonging to the provided catalog.
*
- * @param savedCatalog the catalog owning the extension
- * @param extension the extension to find the folder to
- * @param releaseName the version name of the extension to retrieve
+ * @param catalogName the name of the catalog owning the extension
+ * @param extensionName the name of the extension to find the folder to
+ * @param releaseName the name of the version to retrieve
* @param fileType the type of files to retrieve
* @param createDirectory whether to create a folder on the returned path
* @return the path to the folder containing the specified files of the provided extension
* @throws IOException if an I/O error occurs while creating the folder
- * @throws java.nio.file.InvalidPathException if the Path object cannot be created, for example because the extensions
+ * @throws InvalidPathException if the Path object cannot be created, for example because the extensions
* folder path contain invalid characters
* @throws NullPointerException if the path contained in {@link #getCatalogsDirectoryPath()} is null or if one of the
* provided parameters is null
*/
public synchronized Path getExtensionPath(
- SavedCatalog savedCatalog,
- Extension extension,
+ String catalogName,
+ String extensionName,
String releaseName,
FileType fileType,
boolean createDirectory
) throws IOException {
- Path folderPath = Paths.get(
- getExtensionDirectoryPath(savedCatalog, extension).toString(),
- releaseName,
- fileType.name
- );
+ Path folderPath = getExtensionDirectoryPath(catalogName, extensionName, releaseName).resolve(fileType.name);
if (createDirectory) {
if (Files.isRegularFile(folderPath)) {
@@ -400,8 +335,4 @@ public synchronized Path getExtensionPath(
public ObservableList getManuallyInstalledJars() {
return manuallyInstalledExtensionsWatcher.getFiles();
}
-
- private Path getRegistryPath() {
- return catalogsDirectoryPath.getValue().resolve(REGISTRY_NAME);
- }
}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/Version.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/Version.java
index 7360d1f..e6cd3d4 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/Version.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/Version.java
@@ -26,10 +26,8 @@ public class Version implements Comparable {
/**
* Create a release from a text.
*
- * @param version the text containing the release to parse. It must correspond to the
- * specifications of this class.
- * @throws IllegalArgumentException if the provided text doesn't correspond to the
- * specifications of this class
+ * @param version the text containing the release to parse. It must correspond to the specifications of this class.
+ * @throws IllegalArgumentException if the provided text doesn't correspond to the specifications of this class
* @throws NullPointerException if the provided version is null
*/
public Version(String version) {
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Catalog.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Catalog.java
index 28c5eee..ce76c7d 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Catalog.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Catalog.java
@@ -1,56 +1,182 @@
package qupath.ext.extensionmanager.core.catalog;
+import qupath.ext.extensionmanager.core.model.CatalogModel;
+import qupath.ext.extensionmanager.core.model.CatalogModelFetcher;
+import qupath.ext.extensionmanager.core.registry.RegistryCatalog;
+import qupath.ext.extensionmanager.core.registry.RegistryExtension;
+
import java.net.URI;
-import java.util.Collections;
import java.util.List;
-import java.util.stream.Collectors;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
/**
- * A catalog describing a collection of extensions.
- *
- * @param name the name of the catalog
- * @param description a short (one sentence or so) description of what the catalog contains and what its purpose is
- * @param extensions the collection of extensions that the catalog describes. This list is immutable
+ * A catalog containing a collection of extensions.
*/
-public record Catalog(String name, String description, List extensions) {
+public class Catalog {
+
+ private final String name;
+ private final String description;
+ private final URI uri;
+ private final URI rawUri;
+ private final boolean deletable;
+ private final List registryExtensions;
+ private CompletableFuture> extensions;
/**
- * Create a catalog.
+ * Create a non-deletable catalog from a list of attributes.
+ *
+ * @param name the name of the catalog
+ * @param description a short (one sentence or so) description of what the catalog contains and what its purpose is
+ * @param uri a URI pointing to the raw content of the catalog, or to a GitHub repository where the catalog can be found
+ * @param rawUri the URI pointing to the raw content of the catalog (can be the same as the provided uri)
+ * @throws NullPointerException if one of the provided parameters is null
+ */
+ public Catalog(String name, String description, URI uri, URI rawUri) {
+ this.name = Objects.requireNonNull(name);
+ this.description = Objects.requireNonNull(description);
+ this.uri = Objects.requireNonNull(uri);
+ this.rawUri = Objects.requireNonNull(rawUri);
+ this.deletable = false;
+ this.registryExtensions = List.of();
+ }
+
+ /**
+ * Create a non-deletable catalog from a default catalog.
+ *
+ * @param defaultCatalog the list of attributes the catalog should have
+ */
+ public Catalog(DefaultCatalog defaultCatalog) {
+ this(defaultCatalog.name(), defaultCatalog.description(), defaultCatalog.uri(), defaultCatalog.rawUri());
+ }
+
+ /**
+ * Create a catalog from a {@link CatalogModel}. This will directly populate the extensions.
+ *
+ * @param catalogModel information on the catalog
+ * @param uri a URI pointing to the raw content of the catalog, or to a GitHub repository where the catalog can be found
+ * @param rawUri the URI pointing to the raw content of the catalog (can be same as {@link #uri})
+ * @param deletable whether this catalog can be deleted
+ * @throws NullPointerException if one of the provided parameters is null
+ */
+ public Catalog(CatalogModel catalogModel, URI uri, URI rawUri, boolean deletable) {
+ this.name = catalogModel.name();
+ this.description = catalogModel.description();
+ this.uri = Objects.requireNonNull(uri);
+ this.rawUri = Objects.requireNonNull(rawUri);
+ this.deletable = deletable;
+ this.registryExtensions = List.of();
+ this.extensions = CompletableFuture.completedFuture(createExtensionsFromCatalog(catalogModel));
+ }
+
+ /**
+ * Create a catalog from a {@link RegistryCatalog}. This will not populate the extensions.
+ *
+ * @param registryCatalog information on the catalog
+ * @throws NullPointerException if the provided parameter is null
+ */
+ public Catalog(RegistryCatalog registryCatalog) {
+ this.name = registryCatalog.name();
+ this.description = registryCatalog.description();
+ this.uri = registryCatalog.uri();
+ this.rawUri = registryCatalog.rawUri();
+ this.deletable = registryCatalog.deletable();
+ this.registryExtensions = registryCatalog.extensions();
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+
+ /**
+ * @return the name of the catalog
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * @return a short (one sentence or so) description of what the catalog contains and what its purpose is
+ */
+ public String getDescription() {
+ return description;
+ }
+
+ /**
+ * @return a URI pointing to the raw content of the catalog, or to a GitHub repository where the catalog can be found
+ */
+ public URI getUri() {
+ return uri;
+ }
+
+ /**
+ * @return the URI pointing to the raw content of the catalog (can be same as {@link #uri})
+ */
+ public URI getRawUri() {
+ return rawUri;
+ }
+
+ /**
+ * @return whether this metadata can be deleted
+ */
+ public boolean isDeletable() {
+ return deletable;
+ }
+
+ /**
+ * Compute and return the extensions this catalog owns.
*
- * It must respect the following requirements:
+ * Depending on which constructor was used to create this catalog, this function may act differently:
*
- * - The 'name', 'description', and 'extensions' fields must be defined (but can be empty).
* -
- * Each extension of the 'extensions' list must be a valid object
- * (see {@link Extension#Extension(String, String, String, URI, boolean, List)}).
+ * If {@link #Catalog(String, String, URI, URI)} or {@link #Catalog(DefaultCatalog)} was used, this function
+ * will call {@link CatalogModelFetcher#getCatalog(URI)} to determine the extensions, which might take some
+ * time. No extension is considered to be installed by default.
+ *
+ * -
+ * If {@link #Catalog(CatalogModel, URI, URI, boolean)} was used, extensions are already determined but none
+ * is considered to be installed by default.
+ *
+ * -
+ * If {@link #Catalog(RegistryCatalog)} was used, this function will call {@link CatalogModelFetcher#getCatalog(URI)}
+ * to determine the extensions, which might take some time. Then, the extensions of the provided {@link RegistryCatalog}
+ * are used to determine which extensions are installed.
*
- * - Two extensions of the 'extensions' list cannot have the same name.
*
+ * At most one call to {@link CatalogModelFetcher#getCatalog(URI)} is made, because the results are cached.
+ *
+ * Note that exception handling is left to the caller (the returned CompletableFuture may complete exceptionally
+ * if the request made in {@link CatalogModelFetcher#getCatalog(URI)} failed).
*
- * @param name the name of the catalog
- * @param description a short (one sentence or so) description of what the catalog contains and what its purpose is
- * @param extensions the collection of extensions that the catalog describes
- * @throws IllegalArgumentException when the created catalog is not valid (see the requirements above)
+ * @return a CompletableFuture with the list of extensions this catalog owns (that completes exceptionally if the
+ * operation failed)
*/
- public Catalog(String name, String description, List extensions) {
- this.name = name;
- this.description = description;
- this.extensions = extensions == null ? null : Collections.unmodifiableList(extensions);
-
- checkValidity();
+ public synchronized CompletableFuture> getExtensions() {
+ if (extensions == null) {
+ extensions = CatalogModelFetcher.getCatalog(rawUri).thenApply(this::createExtensionsFromCatalog);
+ }
+ return extensions;
}
- private void checkValidity() {
- Utils.checkField(name, "name", "Catalog");
- Utils.checkField(description, "description", "Catalog");
- Utils.checkField(extensions, "extensions", "Catalog");
+ private List createExtensionsFromCatalog(CatalogModel catalog) {
+ return catalog.extensions().stream()
+ .map(extension -> {
+ Optional registryExtension = registryExtensions.stream()
+ .filter(e -> e.name().equals(extension.name()))
+ .findAny();
- if (extensions.stream().map(Extension::name).collect(Collectors.toSet()).size() < extensions.size()) {
- throw new IllegalArgumentException(String.format(
- "At least two extensions of %s have the same name",
- extensions
- ));
- }
+ return new Extension(
+ extension,
+ registryExtension.flatMap(e -> extension.releases().stream()
+ .filter(release -> release.name().equals(e.installedVersion()))
+ .map(Release::new)
+ .findAny()
+ ).orElse(null),
+ registryExtension.isPresent() && registryExtension.get().optionalDependenciesInstalled()
+ );
+ })
+ .toList();
}
}
-
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/DefaultCatalog.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/DefaultCatalog.java
new file mode 100644
index 0000000..f8d09ee
--- /dev/null
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/DefaultCatalog.java
@@ -0,0 +1,28 @@
+package qupath.ext.extensionmanager.core.catalog;
+
+import java.net.URI;
+import java.util.Objects;
+
+/**
+ * Represent a non-deletable catalog.
+ *
+ * A {@link RuntimeException} is thrown if one parameter is null.
+ *
+ * @param name the name of the catalog
+ * @param description a short (one sentence or so) description of what the catalog contains and what its purpose is
+ * @param uri a URI pointing to the raw content of the catalog, or to a GitHub repository where the catalog can be found
+ * @param rawUri the URI pointing to the raw content of the catalog (can be same as {@link #uri})
+ */
+public record DefaultCatalog(
+ String name,
+ String description,
+ URI uri,
+ URI rawUri
+) {
+ public DefaultCatalog {
+ Objects.requireNonNull(name);
+ Objects.requireNonNull(description);
+ Objects.requireNonNull(uri);
+ Objects.requireNonNull(rawUri);
+ }
+}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Extension.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Extension.java
index 6d672a5..735cba9 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Extension.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Extension.java
@@ -1,75 +1,179 @@
package qupath.ext.extensionmanager.core.catalog;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.value.ObservableBooleanValue;
+import javafx.beans.value.ObservableValue;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import qupath.ext.extensionmanager.core.Version;
+import qupath.ext.extensionmanager.core.model.ExtensionModel;
import java.net.URI;
-import java.util.Collections;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
/**
- * A description of an extension.
- *
- * @param name the extension's name
- * @param description a short (one sentence or so) description of what the extension is and what it does
- * @param author the author or group responsible for the extension
- * @param homepage a link to the GitHub repository associated with the extension
- * @param starred whether the extension is generally useful or recommended for most users
- * @param releases a list of available releases of the extension. This list is immutable
+ * An optionally installed extension.
*/
-public record Extension(String name, String description, String author, URI homepage, boolean starred, List releases) {
+public class Extension {
+
+ private static final Logger logger = LoggerFactory.getLogger(Extension.class);
+ private final String name;
+ private final String description;
+ private final List releases;
+ private final URI homepage;
+ private final boolean starred;
+ private final ObjectProperty> installedRelease;
+ private final BooleanProperty optionalDependenciesInstalled;
+
+ /**
+ * Create an extension from a {@link ExtensionModel}.
+ *
+ * @param extensionModel information on the extension
+ * @param installedRelease the release of the extension that is currently installed. Can be null to indicate that the
+ * extension is not installed
+ * @param optionalDependenciesInstalled whether optional dependencies of the extension are currently installed
+ * @throws NullPointerException if the provided extension model is null
+ */
+ public Extension(ExtensionModel extensionModel, Release installedRelease, boolean optionalDependenciesInstalled) {
+ this.name = extensionModel.name();
+ this.description = extensionModel.description();
+ this.releases = extensionModel.releases().stream().map(Release::new).toList();
+ this.homepage = extensionModel.homepage();
+ this.starred = extensionModel.starred();
+ this.installedRelease = new SimpleObjectProperty<>(Optional.ofNullable(installedRelease));
+ this.optionalDependenciesInstalled = new SimpleBooleanProperty(optionalDependenciesInstalled);
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+
+ /**
+ * @return the name of the extension
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * @return a short (one sentence or so) description of what the extension is and what it does
+ */
+ public String getDescription() {
+ return description;
+ }
+
+ /**
+ * @return a list of available releases of the extension. This list is immutable
+ */
+ public List getReleases() {
+ return releases;
+ }
+
+ /**
+ * @return a link to the GitHub repository associated with the extension
+ */
+ public URI getHomepage() {
+ return homepage;
+ }
+
+ /**
+ * @return whether the extension is generally useful or recommended for most users
+ */
+ public boolean isStarred() {
+ return starred;
+ }
/**
- * Create an Extension.
+ * Get an observable value showing the currently installed release of this extension. An empty Optional indicates
+ * that the extension is not installed.
*
- * It must respect the following requirements:
- *
- * - The 'name', 'description', 'author', 'homepage', and 'releases' fields must be defined (but can be empty).
- * -
- * Each release of the 'version' list must be a valid object
- * (see {@link Release#Release(String, URI, List, List, List, VersionRange)}).
- *
- * - The 'homepage' field must be a GitHub URL.
- *
+ * This observable may be updated from any thread.
*
- * @param name the extension's name
- * @param description a short (one sentence or so) description of what the extension is and what it does
- * @param author the author or group responsible for the extension
- * @param homepage a link to the GitHub repository associated with the extension
- * @param starred whether the extension is generally useful or recommended for most users
- * @param releases a list of available releases of the extension
- * @throws IllegalArgumentException when the created extension is not valid (see the requirements above)
+ * @return an observable value showing the currently installed release of this extension
*/
- public Extension(String name, String description, String author, URI homepage, boolean starred, List releases) {
- this.name = name;
- this.description = description;
- this.author = author;
- this.homepage = homepage;
- this.starred = starred;
- this.releases = releases == null ? null : Collections.unmodifiableList(releases);
-
- checkValidity();
+ public ObservableValue> getInstalledRelease() {
+ return installedRelease;
}
/**
- * Provide the most up-to-date release compatible with the provided version.
+ * Get an observable boolean value showing whether optional dependencies are currently installed.
+ *
+ * This observable may be updated from any thread.
+ *
+ * @return observable boolean value showing whether optional dependencies are currently installed
+ */
+ public ObservableBooleanValue areOptionalDependenciesInstalled() {
+ return optionalDependenciesInstalled;
+ }
+
+ /**
+ * Indicate that this extension is now installed with the provided release.
+ *
+ * @param release the installed release
+ * @param optionalDependenciesInstalled whether optional dependencies have been installed
+ * @throws NullPointerException if the provided release is null
+ */
+ public synchronized void installRelease(Release release, boolean optionalDependenciesInstalled) {
+ this.installedRelease.set(Optional.of(Objects.requireNonNull(release)));
+ this.optionalDependenciesInstalled.set(optionalDependenciesInstalled);
+ }
+
+ /**
+ * Indicate that this extension is now uninstalled.
+ */
+ public synchronized void uninstallRelease() {
+ installedRelease.set(Optional.empty());
+ optionalDependenciesInstalled.set(false);
+ }
+
+ /**
+ * Indicate whether this extension is installed, and if the installed version can be updated to a newer release that
+ * is compatible with the provided version.
*
- * @param version the version that the release should be compatible with. It
- * must be specified in the form "v[MAJOR].[MINOR].[PATCH]" or
- * "v[MAJOR].[MINOR].[PATCH]-rc[RELEASE_CANDIDATE]"
- * @return the most up-to-date release compatible with the provided version, or
- * an empty Optional if no release is compatible with the provided version
- * @throws IllegalArgumentException if this extension contains at least one release and
- * the provided version doesn't match the required form
- * @throws NullPointerException if this extension contains at least one release and
- * the provided version is null
+ * @param version the version that the new release should be compatible to
+ * @return a more up-to-date version of this extension, or an empty Optional if this extension is not installed or
+ * is already installed with the latest compatible release
*/
- public Optional getMaxCompatibleRelease(String version) {
+ public Optional getUpdateAvailable(Version version) {
+ Optional installedRelease = getInstalledRelease().getValue();
+
+ if (installedRelease.isEmpty()) {
+ logger.debug("{} not installed, so no update available", this);
+ return Optional.empty();
+ }
+
+ Optional maxCompatibleRelease = getMaxCompatibleRelease(version);
+ if (maxCompatibleRelease.isEmpty()) {
+ logger.debug("{} installed but no compatible release with {} found", this, version);
+ return Optional.empty();
+ }
+
+ if (maxCompatibleRelease.get().getVersion().compareTo(installedRelease.get().getVersion()) <= 0) {
+ logger.debug("{} installed but corresponds to the latest compatible version", this);
+ return Optional.empty();
+ }
+
+ logger.debug("{} installed and updatable to {}", this, maxCompatibleRelease.get());
+ return Optional.of(new UpdateAvailable(
+ name,
+ installedRelease.get().getVersion(),
+ maxCompatibleRelease.get().getVersion()
+ ));
+ }
+
+ private Optional getMaxCompatibleRelease(Version version) {
Release maxCompatibleRelease = null;
for (Release release: releases) {
- if (release.versionRange().isCompatible(version) &&
- (maxCompatibleRelease == null || new Version(release.name()).compareTo(new Version(maxCompatibleRelease.name())) > 0)
+ if (
+ release.isCompatible(version) &&
+ (maxCompatibleRelease == null || release.getVersion().compareTo(maxCompatibleRelease.getVersion()) > 0)
) {
maxCompatibleRelease = release;
}
@@ -77,15 +181,4 @@ public Optional getMaxCompatibleRelease(String version) {
return Optional.ofNullable(maxCompatibleRelease);
}
-
- private void checkValidity() {
- Utils.checkField(name, "name", "Extension");
- Utils.checkField(description, "description", "Extension");
- Utils.checkField(author, "author", "Extension");
- Utils.checkField(homepage, "homepage", "Extension");
- Utils.checkField(releases, "releases", "Extension");
-
- Utils.checkGithubURI(homepage);
- }
}
-
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Release.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Release.java
index 687283b..7aaba7e 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Release.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Release.java
@@ -1,110 +1,109 @@
package qupath.ext.extensionmanager.core.catalog;
import qupath.ext.extensionmanager.core.Version;
+import qupath.ext.extensionmanager.core.model.ReleaseModel;
+import qupath.ext.extensionmanager.core.model.VersionRangeModel;
import java.net.URI;
-import java.util.Collections;
import java.util.List;
/**
- * A description of an extension release hosted on GitHub.
- *
- * @param name the name of this release in the form "v[MAJOR].[MINOR].[PATCH]" or "v[MAJOR].[MINOR].[PATCH]-rc[RELEASE_CANDIDATE]"
- * @param mainUrl the GitHub URL where the main extension jar can be downloaded
- * @param requiredDependencyUrls SciJava Maven, Maven Central, or GitHub URLs where required dependency jars can be downloaded.
- * This list is immutable and won't be null
- * @param optionalDependencyUrls SciJava Maven, Maven Central, or GitHub URLs where optional dependency jars can be downloaded.
- * This list is immutable and won't be null
- * @param javadocUrls SciJava Maven, Maven Central, or GitHub URLs where javadoc jars for the main extension
- * jar and for dependencies can be downloaded. This list is immutable and won't be null
- * @param versionRange a specification of minimum and maximum compatible versions
+ * An extension release hosted on GitHub.
*/
-public record Release(
- String name,
- URI mainUrl,
- List requiredDependencyUrls,
- List optionalDependencyUrls,
- List javadocUrls,
- VersionRange versionRange
-) {
- private static final List VALID_HOSTS = List.of("github.com", "maven.scijava.org", "repo1.maven.org");
- private static final String VALID_SCHEME = "https";
+public class Release {
+
+ private final Version version;
+ private final URI mainUrl;
+ private final List javadocUrls;
+ private final List requiredDependencyUrls;
+ private final List optionalDependencyUrls;
+ private final VersionRangeModel versionRange;
/**
- * Create a Release.
- *
- * It must respect the following requirements:
- *
- * - The 'name', 'mainUrl', and 'versionRange' fields must be defined.
- * -
- * 'name' must be specified in the form "v[MAJOR].[MINOR].[PATCH]" corresponding to semantic versions,
- * although trailing release candidate qualifiers (eg, "-rc1") are also allowed.
- *
- * - The 'versions' object must be valid (see {@link VersionRange#VersionRange(String, String, List)}).
- * - The 'mainURL' field must be a GitHub URL. All other URLs must be SciJava Maven, Maven Central, or GitHub URLs.
- *
+ * Create a release from a {@link ReleaseModel}.
*
- * @param name the name of this release in the form "v[MAJOR].[MINOR].[PATCH]" or "v[MAJOR].[MINOR].[PATCH]-rc[RELEASE_CANDIDATE]"
- * @param mainUrl the GitHub URL where the main extension jar can be downloaded
- * @param requiredDependencyUrls SciJava Maven, Maven Central, or GitHub URLs where required dependency jars can be downloaded.
- * Can be null
- * @param optionalDependencyUrls SciJava Maven, Maven Central, or GitHub URLs where optional dependency jars can be downloaded.
- * Can be null
- * @param javadocUrls SciJava Maven, Maven Central, or GitHub URLs where javadoc jars for the main extension
- * jar and for dependencies can be downloaded. Can be null
- * @param versionRange a specification of minimum and maximum compatible versions
- * @throws IllegalArgumentException when the created release is not valid (see the requirements above)
+ * @param releaseModel information on the release
+ * @throws NullPointerException if the provided parameter is null
*/
- public Release(
- String name,
- URI mainUrl,
- List requiredDependencyUrls,
- List optionalDependencyUrls,
- List javadocUrls,
- VersionRange versionRange
- ) {
- this.name = name;
- this.mainUrl = mainUrl;
- this.requiredDependencyUrls = requiredDependencyUrls == null ? List.of() : Collections.unmodifiableList(requiredDependencyUrls);
- this.optionalDependencyUrls = optionalDependencyUrls == null ? List.of() : Collections.unmodifiableList(optionalDependencyUrls);
- this.javadocUrls = javadocUrls == null ? List.of() : Collections.unmodifiableList(javadocUrls);
- this.versionRange = versionRange;
+ public Release(ReleaseModel releaseModel) {
+ this.version = new Version(releaseModel.name());
+ this.mainUrl = releaseModel.mainUrl();
+ this.javadocUrls = releaseModel.javadocUrls();
+ this.requiredDependencyUrls = releaseModel.requiredDependencyUrls();
+ this.optionalDependencyUrls = releaseModel.optionalDependencyUrls();
+ this.versionRange = releaseModel.versionRange();
+ }
- checkValidity();
+ @Override
+ public String toString() {
+ return version.toString();
}
- private void checkValidity() {
- Utils.checkField(name, "name", "Release");
- Utils.checkField(mainUrl, "mainUrl", "Release");
- Utils.checkField(versionRange, "versionRange", "Release");
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) return false;
- Version.isValid(name, true);
+ Release release = (Release) o;
+ return version.equals(release.version) && mainUrl.equals(release.mainUrl) && javadocUrls.equals(release.javadocUrls) &&
+ requiredDependencyUrls.equals(release.requiredDependencyUrls) && optionalDependencyUrls.equals(release.optionalDependencyUrls) &&
+ versionRange.equals(release.versionRange);
+ }
- Utils.checkGithubURI(mainUrl);
+ @Override
+ public int hashCode() {
+ int result = version.hashCode();
+ result = 31 * result + mainUrl.hashCode();
+ result = 31 * result + javadocUrls.hashCode();
+ result = 31 * result + requiredDependencyUrls.hashCode();
+ result = 31 * result + optionalDependencyUrls.hashCode();
+ result = 31 * result + versionRange.hashCode();
+ return result;
+ }
- checkURIHostValidity(requiredDependencyUrls);
- checkURIHostValidity(optionalDependencyUrls);
- checkURIHostValidity(javadocUrls);
+ /**
+ * @return the version of this release
+ */
+ public Version getVersion() {
+ return version;
}
- private static void checkURIHostValidity(List uris) {
- if (uris != null) {
- for (URI uri: uris) {
- if (!VALID_SCHEME.equalsIgnoreCase(uri.getScheme())) {
- throw new IllegalArgumentException(String.format(
- "The URL %s must use %s",
- uri,
- VALID_SCHEME
- ));
- }
+ /**
+ * Indicate whether the provided version is compatible with this release.
+ *
+ * @param version the version that may be compatible with this release
+ * @return whether the provided version is compatible with this release
+ * @throws NullPointerException if the provided version is null
+ */
+ public boolean isCompatible(Version version) {
+ return versionRange.isCompatible(version);
+ }
- if (!VALID_HOSTS.contains(uri.getHost())) {
- throw new IllegalArgumentException(String.format(
- "The host part of %s is not among %s", uri, VALID_HOSTS
- ));
- }
- }
- }
+ /**
+ * @return the GitHub URL where the main extension jar can be downloaded
+ */
+ public URI getMainUrl() {
+ return mainUrl;
}
-}
+ /**
+ * @return SciJava Maven, Maven Central, or GitHub URLs where javadoc jars for the main extension jar and for dependencies
+ * can be downloaded
+ */
+ public List getJavadocUrls() {
+ return javadocUrls;
+ }
+
+ /**
+ * @return SciJava Maven, Maven Central, or GitHub URLs where required dependency jars can be downloaded
+ */
+ public List getRequiredDependencyUrls() {
+ return requiredDependencyUrls;
+ }
+
+ /**
+ * @return SciJava Maven, Maven Central, or GitHub URLs where optional dependency jars can be downloaded
+ */
+ public List getOptionalDependencyUrls() {
+ return optionalDependencyUrls;
+ }
+}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/UpdateAvailable.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/UpdateAvailable.java
new file mode 100644
index 0000000..e28cbc3
--- /dev/null
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/UpdateAvailable.java
@@ -0,0 +1,23 @@
+package qupath.ext.extensionmanager.core.catalog;
+
+import qupath.ext.extensionmanager.core.Version;
+
+import java.util.Objects;
+
+/**
+ * An object indicating an update available.
+ *
+ * A {@link RuntimeException} is thrown if one parameter is null.
+ *
+ * @param extensionName the name of the updatable extension
+ * @param currentVersion the current version of the updatable extension
+ * @param newVersion the most recent and compatible version of the updatable extension
+ */
+public record UpdateAvailable(String extensionName, Version currentVersion, Version newVersion) {
+
+ public UpdateAvailable {
+ Objects.requireNonNull(extensionName);
+ Objects.requireNonNull(currentVersion);
+ Objects.requireNonNull(newVersion);
+ }
+}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/package-info.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/package-info.java
index de6318a..21700a6 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/package-info.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/package-info.java
@@ -1,13 +1,4 @@
/**
- * This package contains the model of a catalog as described on
- * this
- * Pydantic model.
- *
- * However, there is a small difference between the Pydantic model and this package:
- * classes of this package use the camel case naming convention while the Pydantic
- * model uses the snake case naming convention.
- *
- * A class ({@link qupath.ext.extensionmanager.core.catalog.CatalogFetcher CatalogFetcher}) is
- * also provided to fetch such catalog.
+ * This package contains classes to work with catalogs, extensions, and releases.
*/
package qupath.ext.extensionmanager.core.catalog;
\ No newline at end of file
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/CatalogModel.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/CatalogModel.java
new file mode 100644
index 0000000..ea3c792
--- /dev/null
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/CatalogModel.java
@@ -0,0 +1,56 @@
+package qupath.ext.extensionmanager.core.model;
+
+import java.net.URI;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * A catalog describing a collection of extensions.
+ *
+ * @param name the name of the catalog
+ * @param description a short (one sentence or so) description of what the catalog contains and what its purpose is
+ * @param extensions the collection of extensions that the catalog describes. This list is immutable
+ */
+public record CatalogModel(String name, String description, List extensions) {
+
+ /**
+ * Create a catalog.
+ *
+ * It must respect the following requirements:
+ *
+ * - The 'name', 'description', and 'extensions' fields must be defined (but can be empty).
+ * -
+ * Each extension of the 'extensions' list must be a valid object
+ * (see {@link ExtensionModel#ExtensionModel(String, String, String, URI, boolean, List)}).
+ *
+ * - Two extensions of the 'extensions' list cannot have the same name.
+ *
+ *
+ * @param name the name of the catalog
+ * @param description a short (one sentence or so) description of what the catalog contains and what its purpose is
+ * @param extensions the collection of extensions that the catalog describes
+ * @throws IllegalArgumentException when the created catalog is not valid (see the requirements above)
+ */
+ public CatalogModel(String name, String description, List extensions) {
+ this.name = name;
+ this.description = description;
+ this.extensions = extensions == null ? null : Collections.unmodifiableList(extensions);
+
+ checkValidity();
+ }
+
+ private void checkValidity() {
+ Utils.checkField(name, "name", "Catalog");
+ Utils.checkField(description, "description", "Catalog");
+ Utils.checkField(extensions, "extensions", "Catalog");
+
+ if (extensions.stream().map(ExtensionModel::name).collect(Collectors.toSet()).size() < extensions.size()) {
+ throw new IllegalArgumentException(String.format(
+ "At least two extensions of %s have the same name",
+ extensions
+ ));
+ }
+ }
+}
+
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/CatalogFetcher.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/CatalogModelFetcher.java
similarity index 87%
rename from extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/CatalogFetcher.java
rename to extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/CatalogModelFetcher.java
index 9ac0477..8adae25 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/CatalogFetcher.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/CatalogModelFetcher.java
@@ -1,4 +1,4 @@
-package qupath.ext.extensionmanager.core.catalog;
+package qupath.ext.extensionmanager.core.model;
import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
@@ -16,15 +16,15 @@
/**
* A class to fetch a catalog.
*/
-public class CatalogFetcher {
+public class CatalogModelFetcher {
- private static final Logger logger = LoggerFactory.getLogger(CatalogFetcher.class);
+ private static final Logger logger = LoggerFactory.getLogger(CatalogModelFetcher.class);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
private static final Gson gson = new GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) // convert snake case to camel case
.create();
- private CatalogFetcher() {
+ private CatalogModelFetcher() {
throw new AssertionError("This class is not instantiable.");
}
@@ -32,10 +32,10 @@ private CatalogFetcher() {
* Attempt to get a catalog from the provided URL.
*
* @param uri the URI pointing to the raw content of the catalog. It must contain "http" or "https"
- * @return a CompletableFuture with the catalog or a failed CompletableFuture if the provided URL doesn't point to
- * a valid catalog
+ * @return a CompletableFuture with the catalog or a failed CompletableFuture if the provided URL doesn't point to a
+ * valid catalog
*/
- public static CompletableFuture getCatalog(URI uri) {
+ public static CompletableFuture getCatalog(URI uri) {
if (uri == null) {
return CompletableFuture.failedFuture(new NullPointerException("The provided URI is null"));
}
@@ -66,7 +66,7 @@ public static CompletableFuture getCatalog(URI uri) {
}
logger.debug("Got response from {} with status 200:\n{}", uri, response.body());
- Catalog catalog = gson.fromJson(response.body(), Catalog.class);
+ CatalogModel catalog = gson.fromJson(response.body(), CatalogModel.class);
if (catalog == null) {
throw new RuntimeException(String.format("The response to %s is empty.", uri));
}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/ExtensionModel.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/ExtensionModel.java
new file mode 100644
index 0000000..b824a07
--- /dev/null
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/ExtensionModel.java
@@ -0,0 +1,61 @@
+package qupath.ext.extensionmanager.core.model;
+
+import java.net.URI;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A description of an extension.
+ *
+ * @param name the extension's name
+ * @param description a short (one sentence or so) description of what the extension is and what it does
+ * @param author the author or group responsible for the extension
+ * @param homepage a link to the GitHub repository associated with the extension
+ * @param starred whether the extension is generally useful or recommended for most users
+ * @param releases a list of available releases of the extension. This list is immutable
+ */
+public record ExtensionModel(String name, String description, String author, URI homepage, boolean starred, List releases) {
+
+ /**
+ * Create an Extension.
+ *
+ * It must respect the following requirements:
+ *
+ * - The 'name', 'description', 'author', 'homepage', and 'releases' fields must be defined (but can be empty).
+ * -
+ * Each release of the 'version' list must be a valid object
+ * (see {@link ReleaseModel#ReleaseModel(String, URI, List, List, List, VersionRangeModel)}).
+ *
+ * - The 'homepage' field must be a GitHub URL.
+ *
+ *
+ * @param name the extension's name
+ * @param description a short (one sentence or so) description of what the extension is and what it does
+ * @param author the author or group responsible for the extension
+ * @param homepage a link to the GitHub repository associated with the extension
+ * @param starred whether the extension is generally useful or recommended for most users
+ * @param releases a list of available releases of the extension
+ * @throws IllegalArgumentException when the created extension is not valid (see the requirements above)
+ */
+ public ExtensionModel(String name, String description, String author, URI homepage, boolean starred, List releases) {
+ this.name = name;
+ this.description = description;
+ this.author = author;
+ this.homepage = homepage;
+ this.starred = starred;
+ this.releases = releases == null ? null : Collections.unmodifiableList(releases);
+
+ checkValidity();
+ }
+
+ private void checkValidity() {
+ Utils.checkField(name, "name", "Extension");
+ Utils.checkField(description, "description", "Extension");
+ Utils.checkField(author, "author", "Extension");
+ Utils.checkField(homepage, "homepage", "Extension");
+ Utils.checkField(releases, "releases", "Extension");
+
+ Utils.checkGithubURI(homepage);
+ }
+}
+
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/ReleaseModel.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/ReleaseModel.java
new file mode 100644
index 0000000..3dfc18d
--- /dev/null
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/ReleaseModel.java
@@ -0,0 +1,110 @@
+package qupath.ext.extensionmanager.core.model;
+
+import qupath.ext.extensionmanager.core.Version;
+
+import java.net.URI;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A description of an extension release hosted on GitHub.
+ *
+ * @param name the name of this release in the form "v[MAJOR].[MINOR].[PATCH]" or "v[MAJOR].[MINOR].[PATCH]-rc[RELEASE_CANDIDATE]"
+ * @param mainUrl the GitHub URL where the main extension jar can be downloaded
+ * @param requiredDependencyUrls SciJava Maven, Maven Central, or GitHub URLs where required dependency jars can be downloaded.
+ * This list is immutable and won't be null
+ * @param optionalDependencyUrls SciJava Maven, Maven Central, or GitHub URLs where optional dependency jars can be downloaded.
+ * This list is immutable and won't be null
+ * @param javadocUrls SciJava Maven, Maven Central, or GitHub URLs where javadoc jars for the main extension jar and for
+ * dependencies can be downloaded. This list is immutable and won't be null
+ * @param versionRange a specification of minimum and maximum compatible versions
+ */
+public record ReleaseModel(
+ String name,
+ URI mainUrl,
+ List requiredDependencyUrls,
+ List optionalDependencyUrls,
+ List javadocUrls,
+ VersionRangeModel versionRange
+) {
+ private static final List VALID_HOSTS = List.of("github.com", "maven.scijava.org", "repo1.maven.org");
+ private static final String VALID_SCHEME = "https";
+
+ /**
+ * Create a Release.
+ *
+ * It must respect the following requirements:
+ *
+ * - The 'name', 'mainUrl', and 'versionRange' fields must be defined.
+ * -
+ * 'name' must be specified in the form "v[MAJOR].[MINOR].[PATCH]" corresponding to semantic versions,
+ * although trailing release candidate qualifiers (eg, "-rc1") are also allowed.
+ *
+ * - The 'versions' object must be valid (see {@link VersionRangeModel#VersionRangeModel(String, String, List)}).
+ * - The 'mainURL' field must be a GitHub URL. All other URLs must be SciJava Maven, Maven Central, or GitHub URLs.
+ *
+ *
+ * @param name the name of this release in the form "v[MAJOR].[MINOR].[PATCH]" or "v[MAJOR].[MINOR].[PATCH]-rc[RELEASE_CANDIDATE]"
+ * @param mainUrl the GitHub URL where the main extension jar can be downloaded
+ * @param requiredDependencyUrls SciJava Maven, Maven Central, or GitHub URLs where required dependency jars can be
+ * downloaded. Can be null
+ * @param optionalDependencyUrls SciJava Maven, Maven Central, or GitHub URLs where optional dependency jars can be
+ * downloaded. Can be null
+ * @param javadocUrls SciJava Maven, Maven Central, or GitHub URLs where javadoc jars for the main extension jar and
+ * for dependencies can be downloaded. Can be null
+ * @param versionRange a specification of minimum and maximum compatible versions
+ * @throws IllegalArgumentException when the created release is not valid (see the requirements above)
+ */
+ public ReleaseModel(
+ String name,
+ URI mainUrl,
+ List requiredDependencyUrls,
+ List optionalDependencyUrls,
+ List javadocUrls,
+ VersionRangeModel versionRange
+ ) {
+ this.name = name;
+ this.mainUrl = mainUrl;
+ this.requiredDependencyUrls = requiredDependencyUrls == null ? List.of() : Collections.unmodifiableList(requiredDependencyUrls);
+ this.optionalDependencyUrls = optionalDependencyUrls == null ? List.of() : Collections.unmodifiableList(optionalDependencyUrls);
+ this.javadocUrls = javadocUrls == null ? List.of() : Collections.unmodifiableList(javadocUrls);
+ this.versionRange = versionRange;
+
+ checkValidity();
+ }
+
+ private void checkValidity() {
+ Utils.checkField(name, "name", "Release");
+ Utils.checkField(mainUrl, "mainUrl", "Release");
+ Utils.checkField(versionRange, "versionRange", "Release");
+
+ Version.isValid(name, true);
+
+ Utils.checkGithubURI(mainUrl);
+
+ checkURIHostValidity(requiredDependencyUrls);
+ checkURIHostValidity(optionalDependencyUrls);
+ checkURIHostValidity(javadocUrls);
+ }
+
+ private static void checkURIHostValidity(List uris) {
+ if (uris != null) {
+ for (URI uri: uris) {
+ if (!VALID_SCHEME.equalsIgnoreCase(uri.getScheme())) {
+ throw new IllegalArgumentException(String.format(
+ "The URL %s must use %s",
+ uri,
+ VALID_SCHEME
+ ));
+ }
+
+ if (!VALID_HOSTS.contains(uri.getHost())) {
+ throw new IllegalArgumentException(String.format(
+ "The host part of %s is not among %s", uri, VALID_HOSTS
+ ));
+ }
+ }
+ }
+ }
+}
+
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Utils.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/Utils.java
similarity index 96%
rename from extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Utils.java
rename to extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/Utils.java
index e2e0dcf..f655d3c 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/Utils.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/Utils.java
@@ -1,4 +1,4 @@
-package qupath.ext.extensionmanager.core.catalog;
+package qupath.ext.extensionmanager.core.model;
import java.net.URI;
@@ -8,6 +8,7 @@
class Utils {
private static final String GITHUB_HOST = "github.com";
+
private Utils() {
throw new AssertionError("This class is not instantiable.");
}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/VersionRange.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/VersionRangeModel.java
similarity index 80%
rename from extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/VersionRange.java
rename to extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/VersionRangeModel.java
index f85c493..9dff6ca 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/catalog/VersionRange.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/VersionRangeModel.java
@@ -1,4 +1,4 @@
-package qupath.ext.extensionmanager.core.catalog;
+package qupath.ext.extensionmanager.core.model;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -15,9 +15,9 @@
* @param max the maximum/highest version that this extension is known to be compatible with. Can be null
* @param excludes any specific versions that are not compatible. This list is immutable and won't be null
*/
-public record VersionRange(String min, String max, List excludes) {
+public record VersionRangeModel(String min, String max, List excludes) {
- private static final Logger logger = LoggerFactory.getLogger(VersionRange.class);
+ private static final Logger logger = LoggerFactory.getLogger(VersionRangeModel.class);
/**
* Create a version range.
@@ -26,8 +26,10 @@ public record VersionRange(String min, String max, List excludes) {
*
* - The 'min' field must be defined.
* - If 'max' is specified, it must correspond to a version higher than or equal to 'min'.
- * - If 'excludes' is specified, each of its element must correspond to a version higher than or equal to
- * 'min', and lower than or equal to 'max' if 'max' is defined.
+ * -
+ * If 'excludes' is specified, each of its element must correspond to a version higher than or equal to 'min',
+ * and lower than or equal to 'max' if 'max' is defined.
+ *
* -
* All versions must be specified in the form "v[MAJOR].[MINOR].[PATCH]" corresponding to
* semantic versions, although trailing release candidate qualifiers (eg, "-rc1") are also allowed.
@@ -39,7 +41,7 @@ public record VersionRange(String min, String max, List excludes) {
* @param excludes any specific versions that are not compatible. Can be null
* @throws IllegalArgumentException when the created version range is not valid (see the requirements above)
*/
- public VersionRange(String min, String max, List excludes) {
+ public VersionRangeModel(String min, String max, List excludes) {
this.min = min;
this.max = max;
this.excludes = excludes == null ? List.of() : Collections.unmodifiableList(excludes);
@@ -50,18 +52,12 @@ public VersionRange(String min, String max, List excludes) {
/**
* Indicate if this release range is compatible with the provided version.
*
- * @param version the version to check if this release range is compatible with. It
- * must be specified in the form "v[MAJOR].[MINOR].[PATCH]", although
- * trailing release candidate qualifiers (eg, "-rc1") are also allowed.
+ * @param version the version to check if this release range is compatible with
* @return a boolean indicating if the provided version is compatible with this release range
- * @throws IllegalArgumentException if the provided version doesn't match the required form
- * or if this release range is not valid
* @throws NullPointerException if the provided version is null
*/
- public boolean isCompatible(String version) {
- Version versionObject = new Version(version);
-
- if (new Version(min).compareTo(versionObject) > 0) {
+ public boolean isCompatible(Version version) {
+ if (new Version(min).compareTo(version) > 0) {
logger.debug(
"This version range {} is not compatible with {} because of the minimum compatible version",
this,
@@ -70,7 +66,7 @@ public boolean isCompatible(String version) {
return false;
}
- if (max != null && versionObject.compareTo(new Version(max)) > 0) {
+ if (max != null && version.compareTo(new Version(max)) > 0) {
logger.debug(
"This version range {} is not compatible with {} because of the maximum compatible version",
this,
@@ -79,7 +75,7 @@ public boolean isCompatible(String version) {
return false;
}
- if (excludes != null && excludes.stream().map(Version::new).anyMatch(versionObject::equals)) {
+ if (excludes != null && excludes.stream().map(Version::new).anyMatch(version::equals)) {
logger.debug(
"This version range {} is not compatible with {} because of the excluded versions",
this,
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/package-info.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/package-info.java
new file mode 100644
index 0000000..c06adb3
--- /dev/null
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/model/package-info.java
@@ -0,0 +1,11 @@
+/**
+ * This package contains the model of a catalog as described on this
+ * Pydantic model.
+ *
+ * However, there is a small difference between the Pydantic model and this package: classes of this package use the camel
+ * case naming convention while the Pydantic model uses the snake case naming convention.
+ *
+ * A class ({@link qupath.ext.extensionmanager.core.model.CatalogModelFetcher CatalogFetcher}) is also provided to fetch
+ * such catalog.
+ */
+package qupath.ext.extensionmanager.core.model;
\ No newline at end of file
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/registry/Registry.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/registry/Registry.java
new file mode 100644
index 0000000..17f7363
--- /dev/null
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/registry/Registry.java
@@ -0,0 +1,54 @@
+package qupath.ext.extensionmanager.core.registry;
+
+import qupath.ext.extensionmanager.core.catalog.Catalog;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * A registry contains a list of {@link RegistryCatalog catalogs}.
+ *
+ * A {@link RuntimeException} is thrown if one parameter is null.
+ *
+ * @param catalogs the catalogs this registry owns
+ */
+public record Registry(List catalogs) {
+
+ public Registry {
+ Objects.requireNonNull(catalogs);
+ }
+
+ /**
+ * Create a registry from a list of {@link Catalog catalogs}. As this function requires to call {@link Catalog#getExtensions()},
+ * it may take some time to complete.
+ *
+ * Note that exception handling is left to the caller (the returned CompletableFuture may complete exceptionally).
+ *
+ * @param catalogs the catalogs to create the registry from
+ * @return a CompletableFuture with the created registry (that completes exceptionally if the operation failed)
+ */
+ public static CompletableFuture createFromCatalogs(List catalogs) {
+ return CompletableFuture.supplyAsync(() -> new Registry(catalogs.stream()
+ .map(catalog -> new RegistryCatalog(
+ catalog.getName(),
+ catalog.getDescription(),
+ catalog.getUri(),
+ catalog.getRawUri(),
+ catalog.isDeletable(),
+ catalog.getExtensions().join().stream()
+ .map(extension -> extension.getInstalledRelease().getValue()
+ .map(release -> new RegistryExtension(
+ extension.getName(),
+ release.getVersion().toString(),
+ extension.areOptionalDependenciesInstalled().get()
+ ))
+ .orElse(null)
+ )
+ .filter(Objects::nonNull)
+ .toList()
+ ))
+ .toList()
+ ));
+ }
+}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/registry/RegistryCatalog.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/registry/RegistryCatalog.java
new file mode 100644
index 0000000..bfec765
--- /dev/null
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/registry/RegistryCatalog.java
@@ -0,0 +1,34 @@
+package qupath.ext.extensionmanager.core.registry;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A catalog containing a collection of extensions.
+ *
+ * A {@link RuntimeException} is thrown if one parameter is null.
+ *
+ * @param name the name of the catalog
+ * @param description a short (one sentence or so) description of what the catalog contains and what its purpose is
+ * @param uri a URI pointing to the raw content of the catalog, or to a GitHub repository where the catalog can be found
+ * @param rawUri the URI pointing to the raw content of the catalog (can be same as {@link #uri})
+ * @param deletable whether this metadata can be deleted
+ * @param extensions a list of installed extensions this catalogs owns
+ */
+public record RegistryCatalog(
+ String name,
+ String description,
+ URI uri,
+ URI rawUri,
+ boolean deletable,
+ List extensions
+) {
+ public RegistryCatalog {
+ Objects.requireNonNull(name);
+ Objects.requireNonNull(description);
+ Objects.requireNonNull(uri);
+ Objects.requireNonNull(rawUri);
+ Objects.requireNonNull(extensions);
+ }
+}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/registry/RegistryExtension.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/registry/RegistryExtension.java
new file mode 100644
index 0000000..371638a
--- /dev/null
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/registry/RegistryExtension.java
@@ -0,0 +1,20 @@
+package qupath.ext.extensionmanager.core.registry;
+
+import java.util.Objects;
+
+/**
+ * An installed extension.
+ *
+ * A {@link RuntimeException} is thrown if one parameter is null.
+ *
+ * @param name the name of the extension
+ * @param installedVersion the name of the version of this extension that is currently installed
+ * @param optionalDependenciesInstalled whether optional dependencies are currently installed
+ */
+public record RegistryExtension(String name, String installedVersion, boolean optionalDependenciesInstalled) {
+
+ public RegistryExtension {
+ Objects.requireNonNull(name);
+ Objects.requireNonNull(installedVersion);
+ }
+}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/registry/package-info.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/registry/package-info.java
new file mode 100644
index 0000000..16f048a
--- /dev/null
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/registry/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * This package contains a {@link qupath.ext.extensionmanager.core.registry.Registry}, which can be used to store a list
+ * of catalogs and extensions in a JSON-friendly format.
+ */
+package qupath.ext.extensionmanager.core.registry;
\ No newline at end of file
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/InstalledExtension.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/InstalledExtension.java
deleted file mode 100644
index f050fb5..0000000
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/InstalledExtension.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package qupath.ext.extensionmanager.core.savedentities;
-
-/**
- * Installation information on an extension.
- *
- * @param releaseName the name of the installed release that should follow the
- * specifications of {@link qupath.ext.extensionmanager.core.Version}
- * @param optionalDependenciesInstalled whether optional dependencies are installed
- */
-public record InstalledExtension(String releaseName, boolean optionalDependenciesInstalled) {}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/Registry.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/Registry.java
deleted file mode 100644
index 75d2cfe..0000000
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/Registry.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package qupath.ext.extensionmanager.core.savedentities;
-
-import java.util.Collections;
-import java.util.List;
-
-/**
- * A class containing information regarding a list of saved catalogs.
- *
- * @param catalogs the saved catalogs. This list is immutable and won't be null
- */
-public record Registry(List catalogs) {
-
- /**
- * Create a registry containing the provided list of catalogs.
- *
- * @param catalogs the catalogs this registry should contain
- * @throws NullPointerException if the provided list is null
- */
- public Registry(List catalogs) {
- this.catalogs = Collections.unmodifiableList(catalogs);
- }
-}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/SavedCatalog.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/SavedCatalog.java
deleted file mode 100644
index 1edc600..0000000
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/SavedCatalog.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package qupath.ext.extensionmanager.core.savedentities;
-
-import qupath.ext.extensionmanager.core.catalog.Catalog;
-
-import java.net.URI;
-
-/**
- * Basic metadata on a catalog.
- *
- * @param name see {@link Catalog#name()}
- * @param description see {@link Catalog#description()}
- * @param uri a URI pointing to the raw content of the catalog, or to a GitHub repository where the
- * catalog can be found
- * @param rawUri the URI pointing to the raw content of the catalog (can be same as {@link #uri})
- * @param deletable whether this metadata can be deleted
- */
-public record SavedCatalog(String name, String description, URI uri, URI rawUri, boolean deletable) {}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/UpdateAvailable.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/UpdateAvailable.java
deleted file mode 100644
index 190b751..0000000
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/UpdateAvailable.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package qupath.ext.extensionmanager.core.savedentities;
-
-/**
- * An object indicating an update available.
- *
- * @param extensionName the name of the updatable extension
- * @param currentVersion a text describing the current version of the updatable extension
- * @param newVersion a text describing the most recent and compatible version of the updatable extension
- */
-public record UpdateAvailable(String extensionName, String currentVersion, String newVersion) {}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/package-info.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/package-info.java
deleted file mode 100644
index 3d57cd1..0000000
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/savedentities/package-info.java
+++ /dev/null
@@ -1,4 +0,0 @@
-/**
- * Some models used to save / retrieve information on catalogs and extensions.
- */
-package qupath.ext.extensionmanager.core.savedentities;
\ No newline at end of file
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/FileDownloader.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/FileDownloader.java
index fcd8d8e..6c4963f 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/FileDownloader.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/FileDownloader.java
@@ -31,23 +31,21 @@ private FileDownloader() {
}
/**
- * Download a file located at the provided URI and place it on the provided path.
- * This function may take a lot of time depending on the internet connection and the
- * size of the download, but is cancellable.
+ * Download a file located at the provided URI and place it on the provided path. This function may take a lot of time
+ * depending on the internet connection and the size of the download, but can be interrupted.
*
* @param uri the URI pointing to the file to download. It must contain "http" or "https"
* @param outputPath the path the downloaded file should have. It will be overridden if it already exists
- * @param onProgress a function that will be called at different steps during the download. Its parameter
- * will be a float between 0 and 1 indicating the progress of the download (0: beginning,
- * 1: finished). This function will be called from the calling thread
+ * @param onProgress a function that will be called at different steps during the download. Its parameter will be a
+ * float between 0 and 1 indicating the progress of the download (0: beginning, 1: finished). This
+ * function will be called from the calling thread
* @throws NullPointerException if one of the provided parameter is null
- * @throws IOException if an I/O error occurs when sending the request or receiving the file. It can also occur
- * when this function is interrupted, in which case its {@link Exception#getCause() cause} will be an {@link InterruptedException}
+ * @throws IOException if an I/O error occurs when sending the request or receiving the file. It can also occur if this
+ * function is interrupted, in which case its {@link Exception#getCause() cause} will be an {@link InterruptedException}
* @throws InterruptedException if this function is interrupted
* @throws IllegalArgumentException if the provided URI does not contain a valid scheme ("http" or "https")
* @throws java.io.FileNotFoundException if the downloaded file already exists but is a directory rather than a regular
* file, does not exist but cannot be created, or cannot be opened for any other reason
- * @throws SecurityException if the user doesn't have enough rights to write the output file
*/
public static void downloadFile(URI uri, Path outputPath, Consumer onProgress) throws IOException, InterruptedException {
if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme())) {
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/FileTools.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/FileTools.java
index e843c4d..04742f4 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/FileTools.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/FileTools.java
@@ -11,6 +11,9 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import java.util.stream.Stream;
/**
@@ -19,6 +22,7 @@
public class FileTools {
private static final Logger logger = LoggerFactory.getLogger(FileTools.class);
+ private static final Pattern VERSION_PATTERN = Pattern.compile("(-\\d+\\.\\d+\\.\\d+)");
private FileTools() {
throw new AssertionError("This class is not instantiable.");
@@ -30,7 +34,6 @@ private FileTools() {
* @param path the path to check
* @return whether the provided path is a directory and is not empty
* @throws IOException if an I/O error occurs
- * @throws SecurityException if the user doesn't have sufficient rights to read the file
* @throws NullPointerException if the provided path is null
*/
public static boolean isDirectoryNotEmpty(Path path) throws IOException {
@@ -51,15 +54,13 @@ public static boolean isDirectoryNotEmpty(Path path) throws IOException {
}
/**
- * Attempt to move the provided file to trash, or delete it (and all its children
- * recursively if it's a directory) if the current platform does not support moving
- * files to trash.
+ * Attempt to move the provided file to trash, or delete it (and all its children recursively if it's a directory) if
+ * the current platform does not support moving files to trash.
+ *
* This won't do anything if the provided file doesn't exist.
*
* @param directoryToDelete the file or directory to delete
* @throws IOException if an I/O error occurs
- * @throws SecurityException if the user doesn't have sufficient rights to move or
- * delete some files
* @throws NullPointerException if the provided directory is null
*/
public static void moveDirectoryToTrashOrDeleteRecursively(File directoryToDelete) throws IOException {
@@ -82,8 +83,8 @@ public static void moveDirectoryToTrashOrDeleteRecursively(File directoryToDelet
}
/**
- * Strip the provided name from characters that would be invalid
- * in a file name. This is not guaranteed to work for any character.
+ * Strip the provided name from characters that would be invalid in a file name. This is not guaranteed to work for
+ * any character.
*
* @param name the name to strip characters from
* @return the provided name without characters that would be invalid in a file name
@@ -94,22 +95,19 @@ public static String stripInvalidFilenameCharacters(String name) {
}
/**
- * Get the name of the file or directory denoted by the path contained in the
- * provided URI.
+ * Get the name of the file or directory denoted by the path contained in the provided URI.
*
* @param uri the URI containing the file name to retrieve
* @return the file name denoted by the path contained in the provided URI
* @throws NullPointerException if the path of the provided URI is undefined
- * @throws java.nio.file.InvalidPathException if the path of the provided URI cannot be
- * converted to a Path
+ * @throws java.nio.file.InvalidPathException if the path of the provided URI cannot be converted to a Path
*/
public static String getFileNameFromURI(URI uri) {
return Paths.get(uri.getPath()).getFileName().toString();
}
/**
- * Indicate whether a file is a (direct or not) parent of another file. The provided
- * files don't have to exist.
+ * Indicate whether a file is a (direct or not) parent of another file. The provided files don't have to exist.
*
* @param possibleParent the file that may be a parent of the other file
* @param possibleChild the file that may be a child of the other file
@@ -128,6 +126,24 @@ public static boolean isFileParentOfAnotherFile(File possibleParent, File possib
return false;
}
+ /**
+ * Strip the version from a file name. A version is defined as "-x.y.z" where x, y, and z are positive numbers.
+ * If such version is not found, the input name is returned.
+ *
+ * @param fileName the name to strip the version from
+ * @return the input file name without the potential version
+ * @throws NullPointerException if the provided file name is null
+ */
+ public static String stripVersionFromFileName(String fileName) {
+ Matcher versionMatcher = VERSION_PATTERN.matcher(Objects.requireNonNull(fileName));
+
+ if (versionMatcher.find() && versionMatcher.groupCount() > 0) {
+ return fileName.replace(versionMatcher.group(1), "");
+ } else {
+ return fileName;
+ }
+ }
+
private static boolean moveToTrash(Desktop desktop, File fileToDelete) {
if (SwingUtilities.isEventDispatchThread() || !isWindows()) {
// It seems safe to call move to trash from any thread on macOS and Linux
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/FilesWatcher.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/FilesWatcher.java
index db0d1a9..0b1846a 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/FilesWatcher.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/FilesWatcher.java
@@ -12,12 +12,11 @@
import java.util.function.Predicate;
/**
- * A class that returns an ObservableList of paths to certain files that
- * are contained within a specific directory (recursively but with a maximal
- * depth of 8).
- * The list is automatically updated based on changes to the specified directory
- * (or one of its descendant).
- * Note that changes may take a few seconds to be detected.
+ * A class that returns an ObservableList of paths to certain files that are contained within a specific directory (recursively
+ * but with a maximal depth of 8).
+ *
+ * The list is automatically updated based on changes to the specified directory (or one of its descendant). Note that
+ * changes may take a few seconds to be detected.
*
* This watcher must be {@link #close() closed} once no longer used.
*
@@ -72,9 +71,8 @@ public void close() throws Exception {
}
/**
- * @return a read-only list of files that are currently present according to the parameters
- * specified in {@link #FilesWatcher(ObservableValue, Predicate, Predicate)}. This list may be
- * updated from any thread
+ * @return a read-only list of files that are currently present according to the parameters specified in
+ * {@link #FilesWatcher(ObservableValue, Predicate, Predicate)}. This list may be updated from any thread
*/
public ObservableList getFiles() {
return filesImmutable;
@@ -116,7 +114,7 @@ private synchronized void setDirectoryWatcher() {
files.remove(removedFile);
}
);
- } catch (IOException | UnsupportedOperationException | SecurityException e) {
+ } catch (IOException | UnsupportedOperationException e) {
logger.debug(
"Error when creating files watcher for {}. Files added to this directory won't be detected.",
directory,
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/GitHubRawLinkFinder.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/GitHubRawLinkFinder.java
index 83d6f49..d0df090 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/GitHubRawLinkFinder.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/GitHubRawLinkFinder.java
@@ -43,13 +43,12 @@ private GitHubRawLinkFinder() {
* Get the link pointing to the raw content of the provided file within the provided GitHub
* repository.
*
- * @param url the URL pointing to the GitHub repository containing the file to find. It can be a link
- * to any directory or file within the repository. If it's a link to a directory, all direct
- * children of this directory will be searched. If it's a link to a file, only this file will
- * be searched. It must contain "http" or "https"
+ * @param url the URL pointing to the GitHub repository containing the file to find. It can be a link to any directory
+ * or file within the repository. If it's a link to a directory, all direct children of this directory will
+ * be searched. If it's a link to a file, only this file will be searched. It must contain "http" or "https"
* @param filePredicate a predicate on the name of the file to find
- * @return a CompletableFuture with the link pointing to the raw content of the desired file, or a failed
- * CompletableFuture if it couldn't be retrieved
+ * @return a CompletableFuture with the link pointing to the raw content of the desired file, or a failed CompletableFuture
+ * if it couldn't be retrieved
*/
public static CompletableFuture getRawLinkOfFileInRepository(String url, Predicate filePredicate) {
if (url == null) {
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/RecursiveDirectoryWatcher.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/RecursiveDirectoryWatcher.java
index 2ae8962..abd86c9 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/RecursiveDirectoryWatcher.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/RecursiveDirectoryWatcher.java
@@ -45,28 +45,23 @@ class RecursiveDirectoryWatcher implements AutoCloseable {
private final Consumer onFileAdded;
/**
- * Set up some listeners to be called when files are added or removed
- * from the provided directory or one of its children.
- * The listeners may be called only a few seconds after a file was added
- * or removed.
+ * Set up some listeners to be called when files are added or removed from the provided directory or one of its children.
+ *
+ * The listeners may be called only a few seconds after a file was added or removed.
*
* @param directoryToWatch the path of the root directory to watch
* @param depth the maximum number of directory levels to watch
- * @param filesToFind a predicate indicating files to consider. Listeners won't be called for
- * files that don't match this predicate
+ * @param filesToFind a predicate indicating files to consider. Listeners won't be called for files that don't match
+ * this predicate
* @param directoriesToSkip a predicate indicating directories not to watch
- * @param onFileAdded a function that will be called when a new file is added to one of the watched directory.
- * Its parameter will be the path of the new file. This function may be called from
- * any thread. If files already exist in the provided directory to watch, this function
- * will be called on them
- * @param onFileDeleted a function that will be called when a file is removed from one of the watched directory.
- * Its parameter will be the path of the deleted file. This function may be called
- * from any thread. If a folder containing a file to consider is deleted, this function will be
- * called on this file
- * @throws IOException if an I/O error occurs
+ * @param onFileAdded a function that will be called when a new file is added to one of the watched directory. Its
+ * parameter will be the path of the new file. This function may be called from any thread. If
+ * files already exist in the provided directory to watch, this function will be called on them
+ * @param onFileDeleted a function that will be called when a file is removed from one of the watched directory. Its
+ * parameter will be the path of the deleted file. This function may be called from any thread.
+ * If a folder containing a file to consider is deleted, this function will be called on this file
+ * @throws IOException if an I/O error occurs or if there is no directory on the provided path
* @throws UnsupportedOperationException if watching file system is not supported by this system
- * @throws java.nio.file.NotDirectoryException if there is no directory on the provided path
- * @throws SecurityException if the user doesn't have enough rights to read the provided directory
* @throws NullPointerException if one of the parameter is used and null
*/
public RecursiveDirectoryWatcher(
@@ -105,10 +100,22 @@ public RecursiveDirectoryWatcher(
Path filename = (Path) event.context();
Path filePath = parentFolder.resolve(filename);
+ boolean removeFiles = false;
if (Files.isDirectory(filePath) && kind == StandardWatchEventKinds.ENTRY_CREATE) {
- registerDirectory(filePath);
+ try {
+ registerDirectory(filePath);
+ } catch (IOException e) {
+ logger.debug(
+ "Error while registering {} directory. Removing all files belonging to it",
+ filename
+ );
+ removeFiles = true;
+ }
}
if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
+ removeFiles = true;
+ }
+ if (removeFiles) {
List filesToRemove = addedFiles.stream()
.filter(path -> FileTools.isFileParentOfAnotherFile(
filePath.toFile(),
@@ -145,7 +152,7 @@ public RecursiveDirectoryWatcher(
Thread.currentThread().interrupt();
} catch (ClosedWatchServiceException e) {
logger.debug("Service watching {} closed", directoryToWatch, e);
- } catch (IOException | NullPointerException | SecurityException e) {
+ } catch (NullPointerException e) {
logger.error("Error when watching directory {}", directoryToWatch, e);
}
});
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/ZipExtractor.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/ZipExtractor.java
index 58a7229..369a55b 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/ZipExtractor.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/ZipExtractor.java
@@ -16,8 +16,6 @@
/**
* A class to extract a ZIP file and get information on the extraction progress.
- *
- * This class was inspired by this guide.
*/
public class ZipExtractor {
@@ -29,20 +27,18 @@ private ZipExtractor() {
}
/**
- * Extract files from the provided ZIP file path and place them in the provided output folder.
- * This function may take a lot of time depending on the ZIP file size, but is cancellable.
+ * Extract files from the provided ZIP file path and place them in the provided output folder. This function may take
+ * a lot of time depending on the ZIP file size, but is cancellable.
*
* @param inputZipPath the path of the ZIP file to extract
* @param outputFolderPath the path of a folder that should contain the extracted files
- * @param onProgress a function that will be called at different steps during the extraction. Its parameter
- * will be a float between 0 and 1 indicating the progress of the extraction (0: beginning,
- * 1: finished). This function will be called from the calling thread
- * @throws IOException if an I/O error has occurred while opening the ZIP file or extracting the files,
- * or if the output directory cannot be created
+ * @param onProgress a function that will be called at different steps during the extraction. Its parameter will be a
+ * float between 0 and 1 indicating the progress of the extraction (0: beginning, 1: finished). This
+ * function will be called from the calling thread
+ * @throws IOException if an I/O error has occurred while opening the ZIP file or extracting the files, or if the
+ * output directory cannot be created
* @throws InterruptedException if the running thread is interrupted
* @throws java.util.zip.ZipException if a ZIP format error has occurred when opening the ZIP file
- * @throws SecurityException if the user doesn't have sufficient rights to open the ZIP file or
- * write files to the output folder
* @throws NullPointerException if one of the provided parameter is null
*/
public static void extractZipToFolder(Path inputZipPath, Path outputFolderPath, Consumer onProgress) throws IOException, InterruptedException {
@@ -100,16 +96,16 @@ private static int getNumberOfFilesInZip(Path zipPath) throws IOException {
}
/**
- * Create a file object by adding the name of the provided ZIP entry
- * to the provided destination directory.
+ * Create a file object by adding the name of the provided ZIP entry to the provided destination directory.
+ *
* This method guards against writing files to the file system outside the target folder
* (Zip Slip vulnerability).
*
* @param destinationDirectory the parent directory of the file to create
* @param zipEntry the zip entry containing the name of the file to create
* @return the created file
- * @throws IOException if an I/O error has occurred creating the file or when the created file
- * was outside the target folder
+ * @throws IOException if an I/O error has occurred creating the file or when the created file was outside the target
+ * folder
*/
private static File createFile(File destinationDirectory, ZipEntry zipEntry) throws IOException {
File destFile = new File(destinationDirectory, zipEntry.getName());
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/package-info.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/package-info.java
index 270f35c..f222a72 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/package-info.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/core/tools/package-info.java
@@ -1,5 +1,4 @@
/**
- * A set of tools to work with files, directories, GitHub repositories, and ZIP
- * archives.
+ * A set of tools to work with files, directories, GitHub repositories, and ZIP archives.
*/
package qupath.ext.extensionmanager.core.tools;
\ No newline at end of file
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/CatalogManager.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/CatalogManager.java
index 3800c26..90cc636 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/CatalogManager.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/CatalogManager.java
@@ -8,6 +8,8 @@
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
+import javafx.scene.control.ButtonBar;
+import javafx.scene.control.ButtonType;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
@@ -27,17 +29,16 @@
import org.slf4j.LoggerFactory;
import qupath.ext.extensionmanager.core.ExtensionCatalogManager;
import qupath.ext.extensionmanager.core.catalog.Catalog;
-import qupath.ext.extensionmanager.core.catalog.CatalogFetcher;
-import qupath.ext.extensionmanager.core.savedentities.SavedCatalog;
+import qupath.ext.extensionmanager.core.model.CatalogModel;
+import qupath.ext.extensionmanager.core.model.CatalogModelFetcher;
import qupath.ext.extensionmanager.core.tools.GitHubRawLinkFinder;
import qupath.fx.dialogs.Dialogs;
import java.io.IOException;
import java.net.URI;
-import java.nio.file.InvalidPathException;
import java.text.MessageFormat;
+import java.util.ArrayList;
import java.util.List;
-import java.util.Objects;
import java.util.ResourceBundle;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
@@ -55,15 +56,15 @@ class CatalogManager extends Stage {
private final ExtensionCatalogManager extensionCatalogManager;
private final Runnable onInvalidExtensionDirectory;
@FXML
- private TableView catalogTable;
+ private TableView catalogTable;
@FXML
- private TableColumn nameColumn;
+ private TableColumn nameColumn;
@FXML
- private TableColumn urlColumn;
+ private TableColumn urlColumn;
@FXML
- private TableColumn descriptionColumn;
+ private TableColumn descriptionColumn;
@FXML
- private TableColumn removeColumn;
+ private TableColumn removeColumn;
@FXML
private TextField catalogUrl;
@@ -73,12 +74,12 @@ class CatalogManager extends Stage {
* @param extensionCatalogManager the extension catalog manager this window should use
* @param model the model to use when accessing data
* @param onInvalidExtensionDirectory a function that will be called if an operation needs to access the extension
- * directory (see {@link ExtensionCatalogManager#getExtensionDirectoryPath()})
- * but this directory is currently invalid. It lets the possibility to the user to
- * define and create a valid directory before performing the operation (which would
- * fail if the directory is invalid). This function is guaranteed to be called from
- * the JavaFX Application Thread
- * @throws IOException when an error occurs while creating the window
+ * directory (see {@link ExtensionCatalogManager#getExtensionsDirectory()}) but this
+ * directory is currently invalid. It lets the possibility to the user to define
+ * and create a valid directory before performing the operation (which would fail
+ * if the directory is invalid). This function is guaranteed to be called from the
+ * JavaFX Application Thread
+ * @throws IOException if an error occurs while creating the window
*/
public CatalogManager(
ExtensionCatalogManager extensionCatalogManager,
@@ -99,7 +100,7 @@ public CatalogManager(
@FXML
private void onAddClicked(ActionEvent ignored) {
- UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionDirectoryPath(), onInvalidExtensionDirectory);
+ UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionsDirectory(), onInvalidExtensionDirectory);
String catalogUrl = this.catalogUrl.getText();
if (catalogUrl == null || catalogUrl.isBlank()) {
@@ -147,10 +148,10 @@ private void onAddClicked(ActionEvent ignored) {
finalUri.toString()
));
});
- Catalog catalog = CatalogFetcher.getCatalog(uri).get();
+ CatalogModel catalog = CatalogModelFetcher.getCatalog(uri).get();
Platform.runLater(() -> progressWindow.setProgress(1));
- if (extensionCatalogManager.getCatalogs().stream().anyMatch(savedCatalog -> savedCatalog.name().equals(catalog.name()))) {
+ if (extensionCatalogManager.getCatalogs().stream().anyMatch(c -> c.getName().equals(catalog.name()))) {
displayErrorMessage(
resources.getString("CatalogManager.cannotAddCatalog"),
MessageFormat.format(
@@ -161,13 +162,7 @@ private void onAddClicked(ActionEvent ignored) {
return;
}
- extensionCatalogManager.addCatalog(List.of(new SavedCatalog(
- catalog.name(),
- catalog.description(),
- new URI(catalogUrl),
- uri,
- true
- )));
+ extensionCatalogManager.addCatalog(new Catalog(catalog, new URI(catalogUrl), uri, true));
} catch (Exception e) {
logger.debug("Error when fetching catalog at {}", catalogUrl, e);
displayErrorMessage(
@@ -182,23 +177,38 @@ private void onAddClicked(ActionEvent ignored) {
}
private void setColumns() {
- nameColumn.setCellValueFactory(cellData -> new SimpleStringProperty(cellData.getValue().name()));
- urlColumn.setCellValueFactory(cellData -> new SimpleObjectProperty<>(cellData.getValue().uri()));
- descriptionColumn.setCellValueFactory(cellData -> new SimpleStringProperty(cellData.getValue().description()));
+ nameColumn.setCellValueFactory(cellData -> new SimpleStringProperty(cellData.getValue().getName()));
+ urlColumn.setCellValueFactory(cellData -> new SimpleObjectProperty<>(cellData.getValue().getUri()));
+ descriptionColumn.setCellValueFactory(cellData -> new SimpleStringProperty(cellData.getValue().getDescription()));
removeColumn.setCellValueFactory(cellData -> {
Button button = new Button(resources.getString("CatalogManager.remove"));
- button.setDisable(!cellData.getValue().deletable());
- button.setOnAction(event -> {
- UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionDirectoryPath(), onInvalidExtensionDirectory);
- deleteCatalogs(List.of(cellData.getValue()));
+ button.setDisable(!cellData.getValue().isDeletable());
+ button.setOnAction(event -> {
+ UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionsDirectory(), onInvalidExtensionDirectory);
+
+ boolean confirmation = new Dialogs.Builder()
+ .alertType(Alert.AlertType.CONFIRMATION)
+ .title(resources.getString("CatalogManager.deletionConfirmation"))
+ .content(new Label(MessageFormat.format(
+ resources.getString("CatalogManager.confirmation"),
+ extensionCatalogManager.getCatalogDirectory(cellData.getValue().getName())
+ )))
+ .resizable()
+ .showAndWait()
+ .orElse(ButtonType.CANCEL).getButtonData() == ButtonBar.ButtonData.OK_DONE;
+
+ if (confirmation) {
+ deleteCatalogs(List.of(cellData.getValue()));
+ }
});
+
return new SimpleObjectProperty<>(button);
});
nameColumn.setCellFactory(getStringCellFactory());
urlColumn.setCellFactory(column -> {
- TableCell tableCell = new TableCell<>() {
+ TableCell tableCell = new TableCell<>() {
@Override
protected void updateItem(URI item, boolean empty) {
super.updateItem(item, empty);
@@ -213,12 +223,14 @@ protected void updateItem(URI item, boolean empty) {
}
}
};
+
tableCell.setAlignment(Pos.CENTER_LEFT);
+
return tableCell;
});
descriptionColumn.setCellFactory(getStringCellFactory());
removeColumn.setCellFactory(column -> {
- TableCell tableCell = new TableCell<>() {
+ TableCell tableCell = new TableCell<>() {
@Override
protected void updateItem(Button item, boolean empty) {
super.updateItem(item, empty);
@@ -230,17 +242,20 @@ protected void updateItem(Button item, boolean empty) {
}
}
};
+
tableCell.setAlignment(Pos.CENTER);
+
return tableCell;
});
}
private void setDoubleClickHandler() {
catalogTable.setRowFactory(tv -> {
- TableRow row = new TableRow<>();
+ TableRow row = new TableRow<>();
+
row.setOnMouseClicked(event -> {
if (event.getClickCount() == 2 && (!row.isEmpty())) {
- String url = row.getItem().uri().toString();
+ String url = row.getItem().getUri().toString();
UiUtils.openLinkInWebBrowser(url).exceptionally(error -> {
logger.error("Error when opening {} in browser", url, error);
@@ -258,6 +273,7 @@ private void setDoubleClickHandler() {
});
}
});
+
return row;
});
}
@@ -271,18 +287,18 @@ private void setRightClickHandler() {
MenuItem copyItem = new MenuItem(resources.getString("CatalogManager.copyUrl"));
copyItem.setOnAction(ignored -> {
ClipboardContent content = new ClipboardContent();
- content.putString(catalogTable.getSelectionModel().getSelectedItem().uri().toString());
+ content.putString(catalogTable.getSelectionModel().getSelectedItem().getUri().toString());
Clipboard.getSystemClipboard().setContent(content);
});
menu.getItems().add(copyItem);
MenuItem removeItem = new MenuItem(resources.getString("CatalogManager.remove"));
removeItem.setOnAction(ignored -> {
- UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionDirectoryPath(), onInvalidExtensionDirectory);
+ UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionsDirectory(), onInvalidExtensionDirectory);
List nonDeletableCatalogs = catalogTable.getSelectionModel().getSelectedItems().stream()
- .filter(savedCatalog -> !savedCatalog.deletable())
- .map(SavedCatalog::name)
+ .filter(catalog -> !catalog.isDeletable())
+ .map(Catalog::getName)
.toList();
if (!nonDeletableCatalogs.isEmpty()) {
displayErrorMessage(
@@ -316,53 +332,39 @@ private void displayErrorMessage(String title, String text) {
.show();
}
- private void deleteCatalogs(List catalogs) {
- List catalogsToDelete = catalogs.stream()
- .filter(SavedCatalog::deletable)
+ private void deleteCatalogs(List catalogs) {
+ List catalogsToDelete = catalogs.stream()
+ .filter(Catalog::isDeletable)
.toList();
if (catalogsToDelete.isEmpty()) {
return;
}
- boolean deleteExtensions = Dialogs.showConfirmDialog(
- resources.getString("CatalogManager.deleteCatalog"),
- MessageFormat.format(
- resources.getString("CatalogManager.deleteExtensions"),
- catalogsToDelete.stream()
- .map(savedCatalog -> {
- try {
- return String.format("\"%s\"", extensionCatalogManager.getCatalogDirectory(savedCatalog));
- } catch (InvalidPathException | SecurityException | NullPointerException e) {
- logger.error("Cannot retrieve path of {}", savedCatalog.name(), e);
- return null;
- }
- })
- .filter(Objects::nonNull)
- .collect(Collectors.joining("\n"))
- )
- );
+ List errors = new ArrayList<>();
+ for (Catalog catalog: catalogsToDelete) {
+ try {
+ extensionCatalogManager.removeCatalog(catalog);
+ } catch (Exception e) {
+ logger.error("Error when removing {}", catalog, e);
- try {
- extensionCatalogManager.removeCatalogs(
- catalogsToDelete,
- deleteExtensions
- );
- } catch (IOException | SecurityException | NullPointerException e) {
- logger.error("Error when removing {}", catalogsToDelete.stream().map(SavedCatalog::name).toList(), e);
+ errors.add(e);
+ }
+ }
+ if (!errors.isEmpty()) {
displayErrorMessage(
resources.getString("CatalogManager.deleteCatalog"),
MessageFormat.format(
resources.getString("CatalogManager.cannotRemoveSelectedCatalogs"),
- e.getLocalizedMessage()
+ errors.stream().map(Throwable::getLocalizedMessage).collect(Collectors.joining("\n"))
)
);
}
}
- private static Callback, TableCell> getStringCellFactory() {
+ private static Callback, TableCell> getStringCellFactory() {
return column -> {
- TableCell tableCell = new TableCell<>() {
+ TableCell tableCell = new TableCell<>() {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
@@ -376,7 +378,9 @@ protected void updateItem(String item, boolean empty) {
}
}
};
+
tableCell.setAlignment(Pos.CENTER_LEFT);
+
return tableCell;
};
}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ExtensionCatalogModel.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ExtensionCatalogModel.java
index 2df24ae..b3300e1 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ExtensionCatalogModel.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ExtensionCatalogModel.java
@@ -1,33 +1,28 @@
package qupath.ext.extensionmanager.gui;
-import javafx.beans.property.ObjectProperty;
-import javafx.beans.property.ReadOnlyObjectProperty;
-import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
+import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import qupath.ext.extensionmanager.core.ExtensionCatalogManager;
-import qupath.ext.extensionmanager.core.catalog.Extension;
-import qupath.ext.extensionmanager.core.savedentities.InstalledExtension;
-import qupath.ext.extensionmanager.core.savedentities.SavedCatalog;
+import qupath.ext.extensionmanager.core.catalog.Catalog;
import java.nio.file.Path;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Optional;
/**
- * The model of the UI elements of this project. This is basically a wrapper around {@link ExtensionCatalogManager}
- * where listenable properties are propagated to the JavaFX Application Thread.
+ * The model of the UI elements of this project. This is basically a wrapper around {@link ExtensionCatalogManager} where
+ * listenable properties are propagated to the JavaFX Application Thread.
+ *
+ * An instance of this class must be {@link #close() closed} once no longer used.
*/
-public class ExtensionCatalogModel {
+class ExtensionCatalogModel implements AutoCloseable {
- private final ObservableList savedIndices = FXCollections.observableArrayList();
- private final ObservableList catalogsImmutable = FXCollections.unmodifiableObservableList(savedIndices);
- private static final Map>> installedExtensions = new HashMap<>();
+ private final ObservableList catalogs = FXCollections.observableArrayList();
+ private final ObservableList catalogsImmutable = FXCollections.unmodifiableObservableList(catalogs);
private final ObservableList manuallyInstalledJars = FXCollections.observableArrayList();
private final ObservableList manuallyInstalledJarsImmutable = FXCollections.unmodifiableObservableList(manuallyInstalledJars);
private final ExtensionCatalogManager extensionCatalogManager;
- private record CatalogExtension(SavedCatalog savedCatalog, Extension extension) {}
+ private final ListChangeListener super Catalog> catalogsListener;
+ private final ListChangeListener super Path> manuallyInstalledJarsListener;
/**
* Create the model.
@@ -37,44 +32,27 @@ private record CatalogExtension(SavedCatalog savedCatalog, Extension extension)
public ExtensionCatalogModel(ExtensionCatalogManager extensionCatalogManager) {
this.extensionCatalogManager = extensionCatalogManager;
- UiUtils.bindListInUIThread(savedIndices, extensionCatalogManager.getCatalogs());
- UiUtils.bindListInUIThread(manuallyInstalledJars, extensionCatalogManager.getManuallyInstalledJars());
+ this.catalogsListener = UiUtils.bindListInUIThread(catalogs, extensionCatalogManager.getCatalogs());
+ this.manuallyInstalledJarsListener = UiUtils.bindListInUIThread(manuallyInstalledJars, extensionCatalogManager.getManuallyInstalledJars());
}
- /**
- * @return a read-only observable list of all saved catalogs. This list will always be updated from the JavaFX
- * Application Thread
- */
- public ObservableList getCatalogs() {
- return catalogsImmutable;
+ @Override
+ public void close() {
+ extensionCatalogManager.getCatalogs().removeListener(catalogsListener);
+ extensionCatalogManager.getManuallyInstalledJars().removeListener(manuallyInstalledJarsListener);
}
/**
- * Get installed information of an extension.
- *
- * @param savedCatalog the catalog owning the extension
- * @param extension the extension to get installed information on
- * @return a read-only object property containing an Optional of an installed extension.
- * If the Optional is empty, then it means the extension is not installed. This property
- * will always be updated from the JavaFX Application Thread
+ * @return a read-only observable list of all catalogs. This list will always be updated from the JavaFX Application
+ * Thread
*/
- public ReadOnlyObjectProperty> getInstalledExtension(SavedCatalog savedCatalog, Extension extension) {
- return installedExtensions.computeIfAbsent(
- new CatalogExtension(savedCatalog, extension),
- e -> {
- SimpleObjectProperty> installedExtension = new SimpleObjectProperty<>(Optional.empty());
-
- UiUtils.bindPropertyInUIThread(installedExtension, extensionCatalogManager.getInstalledExtension(e.savedCatalog, e.extension));
-
- return installedExtension;
- }
- );
+ public ObservableList getCatalogs() {
+ return catalogsImmutable;
}
/**
- * @return a read-only observable list of paths pointing to JAR files that were manually added
- * (i.e. not with a catalog) to the extension directory. This list will always be updated from the
- * JavaFX Application Thread
+ * @return a read-only observable list of paths pointing to JAR files that were manually added (i.e. not with a catalog)
+ * to the extension directory. This list will always be updated from the JavaFX Application Thread
*/
public ObservableList getManuallyInstalledJars() {
return manuallyInstalledJarsImmutable;
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ExtensionManager.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ExtensionManager.java
index c21745c..c9d4857 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ExtensionManager.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ExtensionManager.java
@@ -1,10 +1,11 @@
package qupath.ext.extensionmanager.gui;
import javafx.beans.binding.Bindings;
-import javafx.beans.property.ReadOnlyObjectProperty;
+import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
+import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.TitledPane;
import javafx.scene.layout.VBox;
@@ -12,7 +13,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import qupath.ext.extensionmanager.core.ExtensionCatalogManager;
-import qupath.ext.extensionmanager.core.savedentities.SavedCatalog;
+import qupath.ext.extensionmanager.core.catalog.Catalog;
import qupath.ext.extensionmanager.gui.catalog.CatalogPane;
import qupath.fx.dialogs.Dialogs;
@@ -34,8 +35,10 @@
/**
* A window that displays information and controls regarding catalogs and their extensions.
+ *
+ * An instance of this class must be {@link #close() closed} once no longer used.
*/
-public class ExtensionManager extends Stage {
+public class ExtensionManager extends Stage implements AutoCloseable {
private static final Logger logger = LoggerFactory.getLogger(ExtensionManager.class);
private static final ResourceBundle resources = UiUtils.getResources();
@@ -57,12 +60,12 @@ public class ExtensionManager extends Stage {
*
* @param extensionCatalogManager the extension catalog manager this window should use
* @param onInvalidExtensionDirectory a function that will be called if an operation needs to access the extension
- * directory (see {@link ExtensionCatalogManager#getExtensionDirectoryPath()})
- * but this directory is currently invalid. It lets the possibility to the user to
- * define and create a valid directory before performing the operation (which would
- * fail if the directory is invalid). This function is guaranteed to be called from
- * the JavaFX Application Thread
- * @throws IOException when an error occurs while creating the container
+ * directory (see {@link ExtensionCatalogManager#getExtensionsDirectory()}) but this
+ * directory is currently invalid. It lets the possibility to the user to define
+ * and create a valid directory before performing the operation (which would fail
+ * if the directory is invalid). This function is guaranteed to be called from the
+ * JavaFX Application Thread
+ * @throws IOException if an error occurs while creating the window
*/
public ExtensionManager(
ExtensionCatalogManager extensionCatalogManager,
@@ -74,10 +77,10 @@ public ExtensionManager(
UiUtils.loadFXML(this, ExtensionManager.class.getResource("extension_manager.fxml"));
- UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionDirectoryPath(), onInvalidExtensionDirectory);
+ UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionsDirectory(), onInvalidExtensionDirectory);
setCatalogs();
- model.getCatalogs().addListener((ListChangeListener super SavedCatalog>) change ->
+ model.getCatalogs().addListener((ListChangeListener super Catalog>) change ->
setCatalogs()
);
@@ -96,26 +99,39 @@ public ExtensionManager(
noCatalogOrExtension.managedProperty().bind(noCatalogOrExtension.visibleProperty());
}
+ @Override
+ public void close() {
+ super.close();
+
+ for (Node child: catalogs.getChildren()) {
+ if (child instanceof CatalogPane catalogPane) {
+ catalogPane.close();
+ }
+ }
+ model.close();
+ }
+
/**
- * Copy the provided files to the provided extension directory. Some error dialogs will be shown to
- * the user if some errors occurs.
- * If at least a destination file already exists, the user is prompted for confirmation.
- * No confirmation dialog is prompted on success.
+ * Copy the provided files to the provided extension directory. Some error dialogs will be shown to the user if some
+ * errors occurs.
+ *
+ * If at least a destination file already exists, the user is prompted for confirmation. No confirmation dialog is
+ * prompted on success.
*
* @param filesToCopy the files to copy. No check is performed on those files
* @param extensionDirectoryPath the path to the extension directory
- * @param onInvalidExtensionDirectory a function that will be called if the provided extension
- * directory is invalid. It lets the possibility to the user to
- * define and create a valid directory before copying the files
+ * @param onInvalidExtensionDirectory a function that will be called if the provided extension directory is invalid.
+ * It lets the possibility to the user to define and create a valid directory before
+ * copying the files
*/
public static void promptToCopyFilesToExtensionDirectory(
List filesToCopy,
- ReadOnlyObjectProperty extensionDirectoryPath,
+ ObservableValue extensionDirectoryPath,
Runnable onInvalidExtensionDirectory
) {
UiUtils.promptExtensionDirectory(extensionDirectoryPath, onInvalidExtensionDirectory);
- Path extensionFolder = extensionDirectoryPath.get();
+ Path extensionFolder = extensionDirectoryPath.getValue();
if (extensionFolder == null) {
Dialogs.showErrorMessage(
resources.getString("ExtensionManager.error"),
@@ -142,17 +158,10 @@ public static void promptToCopyFilesToExtensionDirectory(
}
List destinationFilesAlreadyExisting = sourceToDestinationFiles.values().stream()
- .filter(file -> {
- try {
- return file.exists();
- } catch (SecurityException e) {
- logger.debug("Cannot check if {} exists", file, e);
- return false;
- }
- })
+ .filter(File::exists)
.toList();
if (!destinationFilesAlreadyExisting.isEmpty()) {
- var confirmation = Dialogs.showConfirmDialog(
+ boolean confirmation = Dialogs.showConfirmDialog(
resources.getString("ExtensionManager.copyFiles"),
MessageFormat.format(
resources.getString("ExtensionManager.alreadyExist"),
@@ -192,9 +201,9 @@ public static void promptToCopyFilesToExtensionDirectory(
@FXML
private void onOpenExtensionDirectory(ActionEvent ignored) {
- UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionDirectoryPath(), onInvalidExtensionDirectory);
+ UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionsDirectory(), onInvalidExtensionDirectory);
- Path extensionDirectory = extensionCatalogManager.getExtensionDirectoryPath().get();
+ Path extensionDirectory = extensionCatalogManager.getExtensionsDirectory().getValue();
if (extensionDirectory == null) {
Dialogs.showErrorMessage(
resources.getString("ExtensionManager.error"),
@@ -221,7 +230,7 @@ private void onOpenExtensionDirectory(ActionEvent ignored) {
@FXML
private void onManageCatalogsClicked(ActionEvent ignored) {
- UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionDirectoryPath(), onInvalidExtensionDirectory);
+ UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionsDirectory(), onInvalidExtensionDirectory);
if (catalogManager == null) {
try {
@@ -240,10 +249,16 @@ private void onManageCatalogsClicked(ActionEvent ignored) {
}
private void setCatalogs() {
+ for (Node child: catalogs.getChildren()) {
+ if (child instanceof CatalogPane catalogPane) {
+ catalogPane.close();
+ }
+ }
+
catalogs.getChildren().setAll(model.getCatalogs().stream()
.map(catalog -> {
try {
- return new CatalogPane(extensionCatalogManager, catalog, model, onInvalidExtensionDirectory);
+ return new CatalogPane(extensionCatalogManager, catalog, onInvalidExtensionDirectory);
} catch (IOException e) {
logger.error("Error while creating catalog pane", e);
return null;
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ManuallyInstalledExtensionLine.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ManuallyInstalledExtensionLine.java
index 12a7daf..9391622 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ManuallyInstalledExtensionLine.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ManuallyInstalledExtensionLine.java
@@ -17,8 +17,7 @@
import java.util.ResourceBundle;
/**
- * A container displaying the name of a manually installed extension as well as a
- * button to delete it.
+ * A container displaying the name of a manually installed extension as well as a button to delete it.
*/
class ManuallyInstalledExtensionLine extends HBox {
@@ -34,7 +33,7 @@ class ManuallyInstalledExtensionLine extends HBox {
* Create the container.
*
* @param jarPath the path of the manually installed extension to display
- * @throws IOException when an error occurs while creating the container
+ * @throws IOException if an error occurs while creating the container
*/
public ManuallyInstalledExtensionLine(Path jarPath) throws IOException {
this.jarPath = jarPath;
@@ -51,14 +50,14 @@ public ManuallyInstalledExtensionLine(Path jarPath) throws IOException {
@FXML
private void onDeleteClicked(ActionEvent ignored) {
- var confirmation = Dialogs.showConfirmDialog(
+ boolean confirmation = Dialogs.showConfirmDialog(
resources.getString("ManuallyInstalledExtensionLine.deleteExtension"),
MessageFormat.format(
resources.getString("ManuallyInstalledExtensionLine.remove"),
jarPath.getFileName()
)
);
- if(!confirmation) {
+ if (!confirmation) {
return;
}
@@ -72,7 +71,7 @@ private void onDeleteClicked(ActionEvent ignored) {
jarPath.getFileName()
)
);
- } catch (IOException | SecurityException e) {
+ } catch (IOException e) {
logger.error("Cannot delete extension located at {}", jarPath, e);
Dialogs.showErrorMessage(
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ProgressWindow.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ProgressWindow.java
index 53ce341..f2ce167 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ProgressWindow.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/ProgressWindow.java
@@ -11,6 +11,7 @@
/**
* A window that displays the progress of a long-running and cancellable operation.
+ *
* It is modal to its owning window.
*/
public class ProgressWindow extends Stage {
@@ -25,9 +26,9 @@ public class ProgressWindow extends Stage {
* Create the window.
*
* @param label a text describing the operation
- * @param onCancelClicked a function that will be called when the user cancel the operation. This window is
- * already automatically closed when this happens
- * @throws IOException when an error occurs while creating the window
+ * @param onCancelClicked a function that will be called when the user cancel the operation. This window is automatically
+ * closed when this happens
+ * @throws IOException if an error occurs while creating the window
*/
public ProgressWindow(String label, Runnable onCancelClicked) throws IOException {
this.onCancelClicked = onCancelClicked;
@@ -48,8 +49,7 @@ private void onCancelClicked(ActionEvent ignored) {
/**
* Set the progress displayed by the window.
*
- * @param progress a number between 0 and 1, where 0 means the beginning and 1 the end of
- * the operation
+ * @param progress a number between 0 and 1, where 0 means the beginning and 1 the end of the operation
*/
public void setProgress(float progress) {
progressBar.setProgress(progress);
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/UiUtils.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/UiUtils.java
index 9cf7be6..feb3576 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/UiUtils.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/UiUtils.java
@@ -1,7 +1,7 @@
package qupath.ext.extensionmanager.gui;
import javafx.application.Platform;
-import javafx.beans.property.ReadOnlyObjectProperty;
+import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.beans.value.WritableValue;
import javafx.collections.ListChangeListener;
@@ -11,8 +11,6 @@
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.glyphfont.GlyphFont;
import org.controlsfx.glyphfont.GlyphFontRegistry;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
import java.awt.Desktop;
import java.io.IOException;
@@ -21,6 +19,7 @@
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.List;
import java.util.ResourceBundle;
import java.util.concurrent.CompletableFuture;
@@ -29,7 +28,6 @@
*/
public class UiUtils {
- private static final Logger logger = LoggerFactory.getLogger(UiUtils.class);
private static final ResourceBundle resources = ResourceBundle.getBundle("qupath.ext.extensionmanager.strings");
private static final GlyphFont fontAwesome = GlyphFontRegistry.font("FontAwesome");
@@ -93,12 +91,10 @@ public static String getClassName(CssClass cssClass) {
}
/**
- * Open the provided link in a web browser. This won't do anything if
- * browsing is not supported by the computer.
+ * Open the provided link in a web browser. This won't do anything if browsing is not supported by the computer.
*
* @param url the link to open
- * @return a CompletableFuture that will complete exceptionally if an error occurs while
- * browsing the provided link
+ * @return a CompletableFuture that will complete exceptionally if an error occurs while browsing the provided link
*/
public static CompletableFuture openLinkInWebBrowser(String url) {
return CompletableFuture.runAsync(() -> {
@@ -115,12 +111,11 @@ public static CompletableFuture openLinkInWebBrowser(String url) {
}
/**
- * Open the provided directory with the platform's file explorer. This won't do anything if
- * browsing files is not supported by the computer.
+ * Open the provided directory with the platform's file explorer. This won't do anything if browsing files is not
+ * supported by the computer.
*
* @param directory the path to the directory to browse
- * @return a CompletableFuture that will complete exceptionally if an error occurs while
- * browsing the provided directory
+ * @return a CompletableFuture that will complete exceptionally if an error occurs while browsing the provided directory
*/
public static CompletableFuture openFolderInFileExplorer(Path directory) {
return CompletableFuture.runAsync(() -> {
@@ -137,24 +132,20 @@ public static CompletableFuture openFolderInFileExplorer(Path directory) {
}
/**
- * Run the provided Runnable if the provided extension directory property doesn't point to a valid directory.
- * The provided Runnable can be for example used to prompt the user for a new extension directory path.
+ * Run the provided Runnable if the provided extension directory property doesn't point to a valid directory. The
+ * provided Runnable can be for example used to prompt the user for a new extension directory path.
*
* @param extensionDirectoryProperty the property to check
* @param onInvalidExtensionDirectory the Runnable to call if the provided path is not a valid directory
*/
public static void promptExtensionDirectory(
- ReadOnlyObjectProperty extensionDirectoryProperty,
+ ObservableValue extensionDirectoryProperty,
Runnable onInvalidExtensionDirectory
) {
- Path extensionDirectory = extensionDirectoryProperty.get();
+ Path extensionDirectory = extensionDirectoryProperty.getValue();
- try {
- if (extensionDirectory == null || !Files.isDirectory(extensionDirectory)) {
- onInvalidExtensionDirectory.run();
- }
- } catch (SecurityException e) {
- logger.debug("Cannot check if {} is invalid", extensionDirectory, e);
+ if (extensionDirectory == null || !Files.isDirectory(extensionDirectory)) {
+ onInvalidExtensionDirectory.run();
}
}
@@ -169,22 +160,25 @@ public static Glyph getFontAwesomeIcon(FontAwesome.Glyph glyph) {
}
/**
- * Propagates changes made to an observable list to another observable list.
- * The listening list is updated in the JavaFX Application Thread.
+ * Propagates changes made to an observable list to another observable list. The listening list is updated in the
+ * JavaFX Application Thread.
*
* @param listToUpdate the list to update
* @param listToListen the list to listen
+ * @return the listener that was added to the list to listen, so that it can be removed when needed
* @param the type of the elements of the lists
*/
- public static void bindListInUIThread(ObservableList listToUpdate, ObservableList listToListen) {
+ public static ListChangeListener super T> bindListInUIThread(List listToUpdate, ObservableList listToListen) {
listToUpdate.addAll(listToListen);
- listToListen.addListener((ListChangeListener super T>) change -> Platform.runLater(() -> {
+ ListChangeListener super T> listener = change -> Platform.runLater(() -> {
if (Platform.isFxApplicationThread()) {
while (change.next()) {
if (change.wasAdded()) {
listToUpdate.addAll(change.getAddedSubList());
- } else {
+ }
+
+ if (change.wasRemoved()) {
listToUpdate.removeAll(change.getRemoved());
}
}
@@ -196,33 +190,43 @@ public static void bindListInUIThread(ObservableList listToUpdate, Observ
while (change.next()) {
if (change.wasAdded()) {
listToUpdate.addAll(change.getAddedSubList());
- } else {
+ }
+
+ if (change.wasRemoved()) {
listToUpdate.removeAll(change.getRemoved());
}
}
change.reset();
});
}
- }));
+ });
+ listToListen.addListener(listener);
+
+ return listener;
}
/**
* Propagates changes made to a property to another property.
- * The listening property is updated in the JavaFX Application Thread.
+ *
+ * The listening property is updated in the UI thread.
*
* @param propertyToUpdate the property to update
* @param propertyToListen the property to listen
+ * @return the listener that was added to the property to listen, so that it can be removed when needed
* @param the type of the property
*/
- public static void bindPropertyInUIThread(WritableValue propertyToUpdate, ObservableValue propertyToListen) {
+ public static ChangeListener super T> bindPropertyInUIThread(WritableValue propertyToUpdate, ObservableValue propertyToListen) {
propertyToUpdate.setValue(propertyToListen.getValue());
- propertyToListen.addListener((p, o, n) -> {
+ ChangeListener super T> listener = (p, o, n) -> {
if (Platform.isFxApplicationThread()) {
propertyToUpdate.setValue(n);
} else {
Platform.runLater(() -> propertyToUpdate.setValue(n));
}
- });
+ };
+ propertyToListen.addListener(listener);
+
+ return listener;
}
}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/CatalogPane.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/CatalogPane.java
index 7c5553b..4a2b2c8 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/CatalogPane.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/CatalogPane.java
@@ -2,26 +2,31 @@
import javafx.application.Platform;
import javafx.fxml.FXML;
+import javafx.scene.Node;
+import javafx.scene.control.Label;
import javafx.scene.control.TitledPane;
import javafx.scene.layout.VBox;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import qupath.ext.extensionmanager.core.ExtensionCatalogManager;
-import qupath.ext.extensionmanager.core.catalog.CatalogFetcher;
-import qupath.ext.extensionmanager.core.savedentities.SavedCatalog;
-import qupath.ext.extensionmanager.gui.ExtensionCatalogModel;
+import qupath.ext.extensionmanager.core.catalog.Catalog;
import qupath.ext.extensionmanager.gui.UiUtils;
import java.io.IOException;
+import java.text.MessageFormat;
import java.util.Objects;
+import java.util.ResourceBundle;
import java.util.stream.IntStream;
/**
* A pane that displays information and controls regarding a catalog and its extensions.
+ *
+ * An instance of this class must be {@link #close() closed} once no longer used.
*/
-public class CatalogPane extends TitledPane {
+public class CatalogPane extends TitledPane implements AutoCloseable {
private static final Logger logger = LoggerFactory.getLogger(CatalogPane.class);
+ private static final ResourceBundle resources = UiUtils.getResources();
@FXML
private VBox extensions;
@@ -29,60 +34,77 @@ public class CatalogPane extends TitledPane {
* Create the pane.
*
* @param extensionCatalogManager the extension catalog manager this pane should use
- * @param savedCatalog the catalog to display
- * @param model the model to use when accessing data
+ * @param catalog the catalog to display
* @param onInvalidExtensionDirectory a function that will be called if an operation needs to access the extension
- * directory (see {@link ExtensionCatalogManager#getExtensionDirectoryPath()})
- * but this directory is currently invalid. It lets the possibility to the user to
- * define and create a valid directory before performing the operation (which would
- * fail if the directory is invalid). This function is guaranteed to be called from
- * the JavaFX Application Thread
- * @throws IOException when an error occurs while loading a FXML file
+ * directory (see {@link ExtensionCatalogManager#getExtensionsDirectory()}) but this
+ * directory is currently invalid. It lets the possibility to the user to define
+ * and create a valid directory before performing the operation (which would fail
+ * if the directory is invalid). This function is guaranteed to be called from the
+ * JavaFX Application Thread
+ * @throws IOException if an error occurs while creating the pane
*/
public CatalogPane(
ExtensionCatalogManager extensionCatalogManager,
- SavedCatalog savedCatalog,
- ExtensionCatalogModel model,
+ Catalog catalog,
Runnable onInvalidExtensionDirectory
) throws IOException {
UiUtils.loadFXML(this, CatalogPane.class.getResource("catalog_pane.fxml"));
- setText(savedCatalog.name());
+ setText(catalog.getName());
+
+ catalog.getExtensions().handle((extensions, error) -> {
+ if (error != null) {
+ logger.error("Error when getting extensions of {}", catalog, error);
- CatalogFetcher.getCatalog(savedCatalog.rawUri()).handle((fetchedCatalog, error) -> {
- if (error == null) {
Platform.runLater(() -> {
setExpanded(true);
- extensions.getChildren().addAll(IntStream.range(0, fetchedCatalog.extensions().size())
- .mapToObj(i -> {
- try {
- ExtensionLine extensionLine = new ExtensionLine(
- extensionCatalogManager,
- model,
- savedCatalog,
- fetchedCatalog.extensions().get(i),
- onInvalidExtensionDirectory
- );
+ this.extensions.getChildren().add(new Label(MessageFormat.format(
+ resources.getString("Catalog.CatalogPane.errorFetchingExtensions"),
+ error.getLocalizedMessage()
+ )));
+ });
+
+ return null;
+ }
+
+ Platform.runLater(() -> {
+ setExpanded(true);
- if (i % 2 == 0) {
- extensionLine.getStyleClass().add(UiUtils.getClassName(UiUtils.CssClass.ODD_ROW));
- }
+ this.extensions.getChildren().addAll(IntStream.range(0, extensions.size())
+ .mapToObj(i -> {
+ try {
+ ExtensionLine extensionLine = new ExtensionLine(
+ extensionCatalogManager,
+ catalog,
+ extensions.get(i),
+ onInvalidExtensionDirectory
+ );
- return extensionLine;
- } catch (IOException e) {
- logger.error("Error while creating extension line", e);
- return null;
+ if (i % 2 == 0) {
+ extensionLine.getStyleClass().add(UiUtils.getClassName(UiUtils.CssClass.ODD_ROW));
}
- })
- .filter(Objects::nonNull)
- .toList()
- );
- });
- } else {
- logger.warn("Error when fetching catalog at {}", savedCatalog.rawUri(), error);
- }
+
+ return extensionLine;
+ } catch (IOException e) {
+ logger.error("Error while creating extension line", e);
+ return null;
+ }
+ })
+ .filter(Objects::nonNull)
+ .toList()
+ );
+ });
return null;
});
}
+
+ @Override
+ public void close() {
+ for (Node child: extensions.getChildren()) {
+ if (child instanceof ExtensionLine extensionLine) {
+ extensionLine.close();
+ }
+ }
+ }
}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionDetails.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionDetails.java
index 25ffac8..e3bd4f8 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionDetails.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionDetails.java
@@ -19,6 +19,7 @@
/**
* A window displaying the description and the clickable homepage of an extension.
+ *
* It is modal to its owning window and can be easily closed with shortcuts.
*/
class ExtensionDetails extends Stage {
@@ -37,7 +38,7 @@ class ExtensionDetails extends Stage {
*
* @param extension the extension whose information should be displayed
* @param noAvailableRelease whether no release of this extension can be installed
- * @throws IOException when an error occurs while creating the container
+ * @throws IOException if an error occurs while creating the container
*/
public ExtensionDetails(Extension extension, boolean noAvailableRelease) throws IOException {
UiUtils.loadFXML(this, ExtensionDetails.class.getResource("extension_details.fxml"));
@@ -45,11 +46,11 @@ public ExtensionDetails(Extension extension, boolean noAvailableRelease) throws
FXUtils.addCloseWindowShortcuts(this);
initModality(Modality.WINDOW_MODAL);
- setTitle(extension.name());
+ setTitle(extension.getName());
- description.setText(extension.description());
+ description.setText(extension.getDescription());
- homepage.setText(extension.homepage().toString());
+ homepage.setText(extension.getHomepage().toString());
notCompatible.setVisible(noAvailableRelease);
notCompatible.setManaged(notCompatible.isVisible());
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionLine.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionLine.java
index bca22fd..189f605 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionLine.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionLine.java
@@ -1,7 +1,7 @@
package qupath.ext.extensionmanager.gui.catalog;
import javafx.beans.binding.Bindings;
-import javafx.beans.property.ReadOnlyObjectProperty;
+import javafx.beans.value.ObservableValue;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
@@ -17,34 +17,34 @@
import org.slf4j.LoggerFactory;
import qupath.ext.extensionmanager.core.ExtensionCatalogManager;
import qupath.ext.extensionmanager.core.Version;
+import qupath.ext.extensionmanager.core.catalog.Catalog;
import qupath.ext.extensionmanager.core.catalog.Extension;
import qupath.ext.extensionmanager.core.catalog.Release;
-import qupath.ext.extensionmanager.core.savedentities.InstalledExtension;
-import qupath.ext.extensionmanager.core.savedentities.SavedCatalog;
-import qupath.ext.extensionmanager.gui.ExtensionCatalogModel;
import qupath.ext.extensionmanager.gui.UiUtils;
import qupath.fx.dialogs.Dialogs;
import java.io.IOException;
-import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.text.MessageFormat;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
/**
* A container that displays information and controls of an extension.
+ *
+ * An instance of this class must be {@link #close() closed} once no longer used.
*/
-class ExtensionLine extends HBox {
+class ExtensionLine extends HBox implements AutoCloseable {
private static final Logger logger = LoggerFactory.getLogger(ExtensionLine.class);
private static final ResourceBundle resources = UiUtils.getResources();
private final ExtensionCatalogManager extensionCatalogManager;
- private final ExtensionCatalogModel model;
- private final SavedCatalog savedCatalog;
+ private final Catalog catalog;
private final Extension extension;
private final Runnable onInvalidExtensionDirectory;
+ private final ExtensionModel extensionModel;
@FXML
private Label name;
@FXML
@@ -70,59 +70,54 @@ class ExtensionLine extends HBox {
* Create the container.
*
* @param extensionCatalogManager the extension catalog manager this window should use
- * @param model the model to use when accessing data
- * @param savedCatalog the catalog owning the extension to display
+ * @param catalog the catalog owning the extension to display
* @param extension the extension to display
* @param onInvalidExtensionDirectory a function that will be called if an operation needs to access the extension
- * directory (see {@link ExtensionCatalogManager#getExtensionDirectoryPath()})
- * but this directory is currently invalid. It lets the possibility to the user to
- * define and create a valid directory before performing the operation (which would
- * fail if the directory is invalid). This function is guaranteed to be called from
- * the JavaFX Application Thread
+ * directory (see {@link ExtensionCatalogManager#getExtensionsDirectory()}) but this
+ * directory is currently invalid. It lets the possibility to the user to define
+ * and create a valid directory before performing the operation (which would fail
+ * if the directory is invalid). This function is guaranteed to be called from the
+ * JavaFX Application Thread
* @throws IOException when an error occurs while creating the container
*/
public ExtensionLine(
ExtensionCatalogManager extensionCatalogManager,
- ExtensionCatalogModel model,
- SavedCatalog savedCatalog,
+ Catalog catalog,
Extension extension,
Runnable onInvalidExtensionDirectory
) throws IOException {
this.extensionCatalogManager = extensionCatalogManager;
- this.model = model;
- this.savedCatalog = savedCatalog;
+ this.catalog = catalog;
this.extension = extension;
this.onInvalidExtensionDirectory = onInvalidExtensionDirectory;
+ this.extensionModel = new ExtensionModel(extension);
UiUtils.loadFXML(this, ExtensionLine.class.getResource("extension_line.fxml"));
- ReadOnlyObjectProperty> installedExtension = model.getInstalledExtension(
- savedCatalog,
- extension
- );
- if (installedExtension.get().isPresent()) {
- name.setText(String.format("%s %s", extension.name(), installedExtension.get().get().releaseName()));
+ ObservableValue> installedRelease = extensionModel.getInstalledRelease();
+ if (installedRelease.getValue().isPresent()) {
+ name.setText(String.format("%s %s", extension.getName(), installedRelease.getValue().get().getVersion().toString()));
} else {
- name.setText(extension.name());
+ name.setText(extension.getName());
}
- installedExtension.addListener((p, o, n) -> {
+ installedRelease.addListener((p, o, n) -> {
if (n.isPresent()) {
- name.setText(String.format("%s %s", extension.name(), n.get().releaseName()));
+ name.setText(String.format("%s %s", extension.getName(), n.get().getVersion().toString()));
} else {
- name.setText(extension.name());
+ name.setText(extension.getName());
}
});
Glyph star = UiUtils.getFontAwesomeIcon(FontAwesome.Glyph.STAR);
- star.getStyleClass().add(extension.starred() ?
+ star.getStyleClass().add(extension.isStarred() ?
UiUtils.getClassName(UiUtils.CssClass.STAR) :
UiUtils.getClassName(UiUtils.CssClass.INVISIBLE)
);
name.setContentDisplay(ContentDisplay.LEFT);
name.setGraphic(star);
- StringBuilder descriptionText = new StringBuilder(extension.description());
- if (extension.starred()) {
+ StringBuilder descriptionText = new StringBuilder(extension.getDescription());
+ if (extension.isStarred()) {
descriptionText.append("\n");
descriptionText.append(resources.getString("Catalog.ExtensionLine.starredExtension"));
}
@@ -135,16 +130,17 @@ public ExtensionLine(
updateAvailable.visibleProperty().bind(Bindings.createBooleanBinding(
() -> {
- if (installedExtension.get().isEmpty()) {
+ if (installedRelease.getValue().isEmpty()) {
return false;
}
- Version installedVersion = new Version(installedExtension.get().get().releaseName());
+ Version installedVersion = installedRelease.getValue().get().getVersion();
- Optional availableVersion = extension.releases().stream()
- .filter(release -> release.versionRange().isCompatible(extensionCatalogManager.getVersion()))
- .map(Release::name)
- .filter(named -> new Version(named).compareTo(installedVersion) > 0)
+ Optional availableVersion = extension.getReleases().stream()
+ .filter(release -> release.isCompatible(extensionCatalogManager.getVersion()))
+ .filter(release -> release.getVersion().compareTo(installedVersion) > 0)
+ .map(release -> release.getVersion().toString())
.findAny();
+
availableVersion.ifPresent(version -> updateAvailableTooltip.setText(MessageFormat.format(
resources.getString("Catalog.ExtensionLine.updateAvailableDetails"),
version
@@ -152,7 +148,7 @@ public ExtensionLine(
return availableVersion.isPresent();
},
- installedExtension
+ installedRelease
));
updateAvailable.managedProperty().bind(updateAvailable.visibleProperty());
@@ -167,16 +163,16 @@ public ExtensionLine(
info.getGraphic().getStyleClass().add("other-buttons");
add.visibleProperty().bind(Bindings.createBooleanBinding(
- () -> installedExtension.get().isEmpty(),
- installedExtension
+ () -> installedRelease.getValue().isEmpty(),
+ installedRelease
));
settings.visibleProperty().bind(Bindings.createBooleanBinding(
- () -> installedExtension.get().isPresent(),
- installedExtension
+ () -> installedRelease.getValue().isPresent(),
+ installedRelease
));
delete.visibleProperty().bind(Bindings.createBooleanBinding(
- () -> installedExtension.get().isPresent(),
- installedExtension
+ () -> installedRelease.getValue().isPresent(),
+ installedRelease
));
add.managedProperty().bind(add.visibleProperty());
@@ -185,7 +181,12 @@ public ExtensionLine(
add.setDisable(noAvailableRelease());
- infoTooltip.setText(String.format("%s\n%s", extension.description(), extension.homepage()));
+ infoTooltip.setText(String.format("%s\n%s", extension.getDescription(), extension.getHomepage()));
+ }
+
+ @Override
+ public void close() {
+ extensionModel.close();
}
@FXML
@@ -198,9 +199,8 @@ private void onAddClicked(ActionEvent ignored) {
try {
ExtensionModificationWindow extensionModificationWindow = new ExtensionModificationWindow(
extensionCatalogManager,
- savedCatalog,
+ catalog,
extension,
- model.getInstalledExtension(savedCatalog, extension).get().orElse(null),
onInvalidExtensionDirectory
);
extensionModificationWindow.initOwner(getScene().getWindow());
@@ -215,9 +215,8 @@ private void onSettingsClicked(ActionEvent ignored) {
try {
ExtensionModificationWindow extensionModificationWindow = new ExtensionModificationWindow(
extensionCatalogManager,
- savedCatalog,
+ catalog,
extension,
- model.getInstalledExtension(savedCatalog, extension).get().orElse(null),
onInvalidExtensionDirectory
);
extensionModificationWindow.initOwner(getScene().getWindow());
@@ -231,8 +230,8 @@ private void onSettingsClicked(ActionEvent ignored) {
private void onDeleteClicked(ActionEvent ignored) {
Path directoryToDelete;
try {
- directoryToDelete = extensionCatalogManager.getExtensionDirectory(savedCatalog, extension);
- } catch (IOException | InvalidPathException | SecurityException | NullPointerException e) {
+ directoryToDelete = extensionCatalogManager.getExtensionDirectory(catalog.getName(), extension.getName());
+ } catch (Exception e) {
logger.error("Cannot retrieve directory containing the files of the extension to delete", e);
Dialogs.showErrorMessage(
@@ -245,11 +244,11 @@ private void onDeleteClicked(ActionEvent ignored) {
return;
}
- var confirmation = Dialogs.showConfirmDialog(
+ boolean confirmation = Dialogs.showConfirmDialog(
resources.getString("Catalog.ExtensionLine.removeExtension"),
MessageFormat.format(
resources.getString("Catalog.ExtensionLine.remove"),
- extension.name(),
+ extension.getName(),
directoryToDelete
)
);
@@ -259,17 +258,17 @@ private void onDeleteClicked(ActionEvent ignored) {
CompletableFuture.runAsync(() -> {
try {
- extensionCatalogManager.removeExtension(savedCatalog, extension);
- } catch (IOException e) {
+ extensionCatalogManager.removeExtension(catalog, extension);
+ } catch (IOException | ExecutionException | InterruptedException e) {
throw new RuntimeException(e);
}
}).handle((v, error) -> {
if (error == null) {
- Dialogs.showInfoNotification(
+ Dialogs.showMessageDialog(
resources.getString("Catalog.ExtensionLine.extensionManager"),
MessageFormat.format(
resources.getString("Catalog.ExtensionLine.removed"),
- extension.name()
+ extension.getName()
)
);
} else {
@@ -299,7 +298,6 @@ private void onInfoClicked(ActionEvent ignored) {
}
private boolean noAvailableRelease() {
- return extension.releases().stream()
- .noneMatch(release -> release.versionRange().isCompatible(extensionCatalogManager.getVersion()));
+ return extension.getReleases().stream().noneMatch(release -> release.isCompatible(extensionCatalogManager.getVersion()));
}
}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionModel.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionModel.java
new file mode 100644
index 0000000..6bcf8e5
--- /dev/null
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionModel.java
@@ -0,0 +1,38 @@
+package qupath.ext.extensionmanager.gui.catalog;
+
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import qupath.ext.extensionmanager.core.catalog.Extension;
+import qupath.ext.extensionmanager.core.catalog.Release;
+import qupath.ext.extensionmanager.gui.UiUtils;
+
+import java.util.Optional;
+
+/**
+ * The model of UI elements representing an {@link Extension}. This is basically a wrapper around an {@link Extension}
+ * where listenable properties are propagated to the JavaFX Application Thread.
+ *
+ * An instance of this class must be {@link #close() closed} once no longer used.
+ */
+class ExtensionModel implements AutoCloseable {
+
+ private final ObjectProperty> installedRelease = new SimpleObjectProperty<>();
+ private final Extension extension;
+ private final ChangeListener super Optional> installedReleaseListener;
+
+ public ExtensionModel(Extension extension) {
+ this.extension = extension;
+ this.installedReleaseListener = UiUtils.bindPropertyInUIThread(installedRelease, extension.getInstalledRelease());
+ }
+
+ @Override
+ public void close() {
+ extension.getInstalledRelease().removeListener(installedReleaseListener);
+ }
+
+ public ObservableValue> getInstalledRelease() {
+ return installedRelease;
+ }
+}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionModificationWindow.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionModificationWindow.java
index 8f40d34..63c2230 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionModificationWindow.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/catalog/ExtensionModificationWindow.java
@@ -21,10 +21,9 @@
import org.slf4j.LoggerFactory;
import qupath.ext.extensionmanager.core.ExtensionCatalogManager;
import qupath.ext.extensionmanager.core.Version;
+import qupath.ext.extensionmanager.core.catalog.Catalog;
import qupath.ext.extensionmanager.core.catalog.Extension;
import qupath.ext.extensionmanager.core.catalog.Release;
-import qupath.ext.extensionmanager.core.savedentities.InstalledExtension;
-import qupath.ext.extensionmanager.core.savedentities.SavedCatalog;
import qupath.ext.extensionmanager.core.tools.FileTools;
import qupath.ext.extensionmanager.gui.ProgressWindow;
import qupath.ext.extensionmanager.gui.UiUtils;
@@ -32,11 +31,11 @@
import java.io.IOException;
import java.net.URI;
-import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.MessageFormat;
import java.util.Objects;
+import java.util.Optional;
import java.util.ResourceBundle;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -45,6 +44,7 @@
/**
* A window that provide choices to install or modify the installation of an extension.
+ *
* It is modal to its owning window.
*/
class ExtensionModificationWindow extends Stage {
@@ -52,9 +52,8 @@ class ExtensionModificationWindow extends Stage {
private static final Logger logger = LoggerFactory.getLogger(ExtensionModificationWindow.class);
private static final ResourceBundle resources = UiUtils.getResources();
private final ExtensionCatalogManager extensionCatalogManager;
- private final SavedCatalog savedCatalog;
+ private final Catalog catalog;
private final Extension extension;
- private final InstalledExtension installedExtension;
private final Runnable onInvalidExtensionDirectory;
@FXML
private Label name;
@@ -79,62 +78,60 @@ class ExtensionModificationWindow extends Stage {
* Create the window.
*
* @param extensionCatalogManager the extension catalog manager this window should use
- * @param savedCatalog the catalog owning the extension to modify
+ * @param catalog the catalog owning the extension to modify
* @param extension the extension to modify
- * @param installedExtension information on the already installed extension if this window should allow modifying it,
- * or null if the extension is to be installed by this window
* @param onInvalidExtensionDirectory a function that will be called if an operation needs to access the extension
- * directory (see {@link ExtensionCatalogManager#getExtensionDirectoryPath()})
- * but this directory is currently invalid. It lets the possibility to the user to
- * define and create a valid directory before performing the operation (which would
- * fail if the directory is invalid). This function is guaranteed to be called from
- * the JavaFX Application Thread
+ * directory (see {@link ExtensionCatalogManager#getExtensionsDirectory()}) but this
+ * directory is currently invalid. It lets the possibility to the user to define
+ * and create a valid directory before performing the operation (which would fail
+ * if the directory is invalid). This function is guaranteed to be called from the
+ * JavaFX Application Thread
* @throws IOException when an error occurs while creating the window
*/
public ExtensionModificationWindow(
ExtensionCatalogManager extensionCatalogManager,
- SavedCatalog savedCatalog,
+ Catalog catalog,
Extension extension,
- InstalledExtension installedExtension,
Runnable onInvalidExtensionDirectory
) throws IOException {
this.extensionCatalogManager = extensionCatalogManager;
- this.savedCatalog = savedCatalog;
+ this.catalog = catalog;
this.extension = extension;
- this.installedExtension = installedExtension;
this.onInvalidExtensionDirectory = onInvalidExtensionDirectory;
UiUtils.loadFXML(this, ExtensionModificationWindow.class.getResource("extension_modification_window.fxml"));
- UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionDirectoryPath(), onInvalidExtensionDirectory);
+ UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionsDirectory(), onInvalidExtensionDirectory);
initModality(Modality.WINDOW_MODAL);
- setTitle(resources.getString(installedExtension == null ?
- "Catalog.ExtensionModificationWindow.installExtension" :
- "Catalog.ExtensionModificationWindow.editExtension"
+ Optional installedRelease = extension.getInstalledRelease().getValue();
+
+ setTitle(resources.getString(installedRelease.isPresent() ?
+ "Catalog.ExtensionModificationWindow.editExtension" :
+ "Catalog.ExtensionModificationWindow.installExtension"
));
- name.setText(extension.name());
+ name.setText(extension.getName());
- if (installedExtension == null) {
- currentVersion.setVisible(false);
- currentVersion.setManaged(false);
- } else {
+ if (installedRelease.isPresent()) {
currentVersion.setText(MessageFormat.format(
resources.getString("Catalog.ExtensionModificationWindow.currentVersion"),
- installedExtension.releaseName()
+ installedRelease.get().getVersion().toString()
));
+ } else {
+ currentVersion.setVisible(false);
+ currentVersion.setManaged(false);
}
- release.getItems().addAll(extension.releases().stream()
- .filter(release -> release.versionRange().isCompatible(extensionCatalogManager.getVersion()))
+ release.getItems().addAll(extension.getReleases().stream()
+ .filter(release -> release.isCompatible(extensionCatalogManager.getVersion()))
.toList()
);
release.setConverter(new StringConverter<>() {
@Override
public String toString(Release object) {
- return object == null ? null : object.name();
+ return object == null ? null : object.getVersion().toString();
}
@Override
@@ -146,26 +143,22 @@ public Release fromString(String string) {
optionalDependencies.visibleProperty().bind(release.getSelectionModel()
.selectedItemProperty()
- .map(release -> !release.optionalDependencyUrls().isEmpty())
+ .map(release -> !release.getOptionalDependencyUrls().isEmpty())
);
optionalDependencies.managedProperty().bind(optionalDependencies.visibleProperty());
- optionalDependencies.setSelected(installedExtension != null && installedExtension.optionalDependenciesInstalled());
+ optionalDependencies.setSelected(extension.areOptionalDependenciesInstalled().get());
filesToDownload.textProperty().bind(Bindings.createStringBinding(
() -> {
try {
return extensionCatalogManager.getDownloadLinks(
- savedCatalog,
- extension,
- new InstalledExtension(
- release.getSelectionModel().getSelectedItem().name(),
- optionalDependencies.isSelected()
- )
+ release.getSelectionModel().getSelectedItem(),
+ optionalDependencies.isSelected()
)
.stream()
.map(URI::toString)
.collect(Collectors.joining("\n"));
- } catch (NullPointerException | SecurityException | IllegalArgumentException e) {
+ } catch (Exception e) {
logger.error("Error while retrieving download links", e);
return resources.getString("Catalog.ExtensionModificationWindow.cannotRetrieveLinks");
}
@@ -175,10 +168,7 @@ public Release fromString(String string) {
));
try {
- Path extensionDirectory = extensionCatalogManager.getExtensionDirectory(
- savedCatalog,
- extension
- );
+ Path extensionDirectory = extensionCatalogManager.getExtensionDirectory(catalog.getName(), extension.getName());
if (FileTools.isDirectoryNotEmpty(extensionDirectory)) {
replaceDirectoryLabel.setText(resources.getString("Catalog.ExtensionModificationWindow.replaceDirectory"));
@@ -187,7 +177,7 @@ public Release fromString(String string) {
replaceDirectory.setVisible(false);
replaceDirectory.setManaged(false);
}
- } catch (IOException | InvalidPathException | SecurityException | NullPointerException e) {
+ } catch (Exception e) {
logger.error("Cannot see if extension directory is not empty", e);
replaceDirectoryLabel.setText(resources.getString("Catalog.ExtensionModificationWindow.extensionDirectoryNotRetrieved"));
@@ -213,11 +203,11 @@ private void onSubmitClicked(ActionEvent ignored) {
}
Release selectedRelease = release.getSelectionModel().getSelectedItem();
- UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionDirectoryPath(), onInvalidExtensionDirectory);
+ UiUtils.promptExtensionDirectory(extensionCatalogManager.getExtensionsDirectory(), onInvalidExtensionDirectory);
try {
- if (installedExtension == null && isJarAlreadyDownloaded(selectedRelease)) {
- var confirmation = new Dialogs.Builder()
+ if (extension.getInstalledRelease().getValue().isEmpty() && isJarAlreadyDownloaded(selectedRelease)) {
+ boolean confirmation = new Dialogs.Builder()
.alertType(Alert.AlertType.CONFIRMATION)
.buttons(
new ButtonType(resources.getString("Catalog.ExtensionModificationWindow.continueAnyway"), ButtonBar.ButtonData.OK_DONE),
@@ -233,12 +223,12 @@ private void onSubmitClicked(ActionEvent ignored) {
return;
}
}
- } catch (NullPointerException | InvalidPathException e) {
+ } catch (Exception e) {
logger.debug(
"Cannot get file name from {}. Assuming {} with release {} is not already installed",
- selectedRelease.mainUrl(),
- extension.name(),
- selectedRelease.name()
+ selectedRelease.getMainUrl(),
+ extension,
+ selectedRelease
);
}
@@ -247,12 +237,12 @@ private void onSubmitClicked(ActionEvent ignored) {
try {
progressWindow = new ProgressWindow(
MessageFormat.format(
- resources.getString(installedExtension == null ?
- "Catalog.ExtensionModificationWindow.installing" :
- "Catalog.ExtensionModificationWindow.updating"
+ resources.getString(extension.getInstalledRelease().getValue().isPresent() ?
+ "Catalog.ExtensionModificationWindow.updating" :
+ "Catalog.ExtensionModificationWindow.installing"
),
- extension.name(),
- selectedRelease.name()
+ extension.getName(),
+ selectedRelease.getVersion().toString()
),
executor::shutdownNow
);
@@ -266,13 +256,13 @@ private void onSubmitClicked(ActionEvent ignored) {
executor.execute(() -> {
try {
+ Optional previousRelease = extension.getInstalledRelease().getValue();
+
extensionCatalogManager.installOrUpdateExtension(
- savedCatalog,
+ catalog,
extension,
- new InstalledExtension(
- selectedRelease.name(),
- optionalDependencies.isSelected()
- ),
+ selectedRelease,
+ optionalDependencies.isSelected(),
progress -> Platform.runLater(() -> progressWindow.setProgress(progress)),
(step, resource) -> Platform.runLater(() -> progressWindow.setStatus(MessageFormat.format(
resources.getString(switch (step) {
@@ -285,30 +275,44 @@ private void onSubmitClicked(ActionEvent ignored) {
Platform.runLater(() -> {
progressWindow.close();
- Dialogs.showInfoNotification(
- resources.getString("Catalog.ExtensionModificationWindow.extensionManager"),
- MessageFormat.format(
- resources.getString("Catalog.ExtensionModificationWindow.installed"),
- extension.name(),
- release.getSelectionModel().getSelectedItem().name()
- )
- );
+
+ if (previousRelease.isPresent()) {
+ Dialogs.showMessageDialog(
+ resources.getString("Catalog.ExtensionModificationWindow.extensionManager"),
+ MessageFormat.format(
+ resources.getString("Catalog.ExtensionModificationWindow.removedAndInstalled"),
+ extension.getName(),
+ previousRelease.get().getVersion().toString(),
+ release.getSelectionModel().getSelectedItem().getVersion().toString()
+ )
+ );
+ } else {
+ Dialogs.showInfoNotification(
+ resources.getString("Catalog.ExtensionModificationWindow.extensionManager"),
+ MessageFormat.format(
+ resources.getString("Catalog.ExtensionModificationWindow.installed"),
+ extension.getName(),
+ release.getSelectionModel().getSelectedItem().getVersion().toString()
+ )
+ );
+ }
+
close();
});
} catch (Exception e) {
Platform.runLater(progressWindow::close);
if (e instanceof InterruptedException || e.getCause() instanceof InterruptedException) {
- logger.debug("Installation of {} interrupted", extension.name(), e);
+ logger.debug("Installation of {} interrupted", extension, e);
} else {
- logger.error("Error while installing {}", extension.name(), e);
+ logger.error("Error while installing {}", extension, e);
Platform.runLater(() -> Dialogs.showErrorMessage(
resources.getString("Catalog.ExtensionModificationWindow.installationError"),
MessageFormat.format(
resources.getString("Catalog.ExtensionModificationWindow.notInstalled"),
- extension.name(),
- release.getSelectionModel().getSelectedItem().name(),
+ extension.getName(),
+ release.getSelectionModel().getSelectedItem().getVersion().toString(),
e.getLocalizedMessage()
)
));
@@ -319,25 +323,20 @@ private void onSubmitClicked(ActionEvent ignored) {
}
private String getSubmitText() {
- if (installedExtension == null) {
+ if (extension.getInstalledRelease().getValue().isEmpty()) {
return "Catalog.ExtensionModificationWindow.install";
} else if (release.getSelectionModel().getSelectedItem() == null) {
return "Catalog.ExtensionModificationWindow.update";
} else {
- try {
- Version selectedVersion = new Version(release.getSelectionModel().getSelectedItem().name());
- Version installedVersion = new Version(installedExtension.releaseName());
+ Version selectedVersion = release.getSelectionModel().getSelectedItem().getVersion();
+ Version installedVersion = extension.getInstalledRelease().getValue().get().getVersion();
- if (selectedVersion.compareTo(installedVersion) < 0) {
- return "Catalog.ExtensionModificationWindow.downgrade";
- } else if (selectedVersion.compareTo(installedVersion) > 0) {
- return "Catalog.ExtensionModificationWindow.update";
- } else {
- return "Catalog.ExtensionModificationWindow.reinstall";
- }
- } catch (IllegalArgumentException e) {
- logger.debug("Cannot create version from selected item or installed release", e);
+ if (selectedVersion.compareTo(installedVersion) < 0) {
+ return "Catalog.ExtensionModificationWindow.downgrade";
+ } else if (selectedVersion.compareTo(installedVersion) > 0) {
return "Catalog.ExtensionModificationWindow.update";
+ } else {
+ return "Catalog.ExtensionModificationWindow.reinstall";
}
}
}
@@ -350,6 +349,6 @@ private boolean isJarAlreadyDownloaded(Release release) {
.map(Path::getFileName)
.filter(Objects::nonNull)
.map(Path::toString)
- .anyMatch(path -> path.equalsIgnoreCase(FileTools.getFileNameFromURI(release.mainUrl())));
+ .anyMatch(path -> path.equalsIgnoreCase(FileTools.getFileNameFromURI(release.getMainUrl())));
}
}
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/package-info.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/package-info.java
index f206572..3a810e6 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/package-info.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/gui/package-info.java
@@ -1,5 +1,5 @@
/**
- * This package contains the {@link qupath.ext.extensionmanager.gui.ExtensionManager ExtensionManager}
- * window, as well as other UI elements internally used by the ExtensionManager.
+ * This package contains the {@link qupath.ext.extensionmanager.gui.ExtensionManager ExtensionManager} window, as well as
+ * other UI elements internally used by the ExtensionManager.
*/
package qupath.ext.extensionmanager.gui;
\ No newline at end of file
diff --git a/extensionmanager/src/main/java/qupath/ext/extensionmanager/package-info.java b/extensionmanager/src/main/java/qupath/ext/extensionmanager/package-info.java
index 878955b..6b79c3b 100644
--- a/extensionmanager/src/main/java/qupath/ext/extensionmanager/package-info.java
+++ b/extensionmanager/src/main/java/qupath/ext/extensionmanager/package-info.java
@@ -3,13 +3,13 @@
*
* -
* The {@link qupath.ext.extensionmanager.core} package contains the
- * {@link qupath.ext.extensionmanager.core.ExtensionCatalogManager ExtensionCatalogManager}.
- * Other classes are only meant to be used internally by the ExtensionCatalogManager.
+ * {@link qupath.ext.extensionmanager.core.ExtensionCatalogManager ExtensionCatalogManager}. Other classes are
+ * only meant to be used internally by the ExtensionCatalogManager.
*
* -
* The {@link qupath.ext.extensionmanager.gui} package contains the
- * {@link qupath.ext.extensionmanager.gui.ExtensionManager ExtensionManager}.
- * Other classes are only meant to be used internally by the ExtensionManager.
+ * {@link qupath.ext.extensionmanager.gui.ExtensionManager ExtensionManager}. Other classes are only meant to be
+ * used internally by the ExtensionManager.
*
*
*/
diff --git a/extensionmanager/src/main/resources/qupath/ext/extensionmanager/strings.properties b/extensionmanager/src/main/resources/qupath/ext/extensionmanager/strings.properties
index e23f033..3d1522e 100644
--- a/extensionmanager/src/main/resources/qupath/ext/extensionmanager/strings.properties
+++ b/extensionmanager/src/main/resources/qupath/ext/extensionmanager/strings.properties
@@ -30,9 +30,10 @@ CatalogManager.browserError = Browser error
CatalogManager.cannotOpen = Cannot open "{0}":\n{1}
CatalogManager.copyUrl = Copy URL
CatalogManager.remove = Remove
+CatalogManager.deletionConfirmation = Deletion confirmation
+CatalogManager.confirmation = This will attempt to delete the following directory:\n{0}\nContinue?
CatalogManager.cannotBeDeleted = {0} cannot be deleted.
CatalogManager.deleteCatalog = Delete catalog
-CatalogManager.deleteExtensions = Also delete extensions belonging to the catalogs to remove?\nThis will remove the following directories and their content:\n\n{0}
CatalogManager.cannotRemoveSelectedCatalogs = Cannot remove selected catalogs:\n{0}
ManuallyInstalledExtensionLine.deleteExtension = Delete extension
@@ -43,6 +44,8 @@ ManuallyInstalledExtensionLine.cannotDeleteExtension = Cannot delete extension:\
ProgressWindow.cancel = Cancel
+Catalog.CatalogPane.errorFetchingExtensions = Error while fetching extensions:\n{0}
+
Catalog.ExtensionDetails.close = Close
Catalog.ExtensionDetails.extensionNotCompatible = This extension is not compatible with the current version of the software.
Catalog.ExtensionDetails.browserError = Browser error
@@ -57,7 +60,7 @@ Catalog.ExtensionLine.updateExtension = Update extension
Catalog.ExtensionLine.removeExtension = Remove extension
Catalog.ExtensionLine.remove = Remove {0}?\nThis will delete the "{1}" directory and all its content.
Catalog.ExtensionLine.extensionManager = Extension manager
-Catalog.ExtensionLine.removed = {0} removed.
+Catalog.ExtensionLine.removed = {0} removed.\n\nYou might need to restart the application to see the changes.
Catalog.ExtensionLine.error = Error
Catalog.ExtensionLine.cannotDeleteExtension = Cannot delete extension:\n{0}
@@ -83,5 +86,6 @@ Catalog.ExtensionModificationWindow.downloading = Downloading {0}...
Catalog.ExtensionModificationWindow.extracting = Extracting {0}...
Catalog.ExtensionModificationWindow.extensionManager = Extension manager
Catalog.ExtensionModificationWindow.installed = {0} {1} installed.
+Catalog.ExtensionModificationWindow.removedAndInstalled = {0} {1} removed and {0} {2} installed.\n\nYou might need to restart the application to see the changes.
Catalog.ExtensionModificationWindow.installationError = Installation error
Catalog.ExtensionModificationWindow.notInstalled = {0} {1} not installed: {2}.
\ No newline at end of file
diff --git a/extensionmanager/src/main/resources/qupath/ext/extensionmanager/strings_fr.properties b/extensionmanager/src/main/resources/qupath/ext/extensionmanager/strings_fr.properties
index d027224..2d50c83 100644
--- a/extensionmanager/src/main/resources/qupath/ext/extensionmanager/strings_fr.properties
+++ b/extensionmanager/src/main/resources/qupath/ext/extensionmanager/strings_fr.properties
@@ -30,9 +30,10 @@ CatalogManager.browserError = Erreur de navigateur
CatalogManager.cannotOpen = Impossible d'ouvrir "{0}":\n{1}
CatalogManager.copyUrl = Copier l'URL
CatalogManager.remove = Supprimer
+CatalogManager.deletionConfirmation = Confirmation de suppression
+CatalogManager.confirmation = Cette opération tentera de supprimer le dossier suivant :\n{0}\nContinuer ?
CatalogManager.cannotBeDeleted = {0} ne peut pas être supprimé.
CatalogManager.deleteCatalog = Suppression du catalogue
-CatalogManager.deleteExtensions = Supprimer également les extensions appartenant à ces catalogues à supprimer ?\nCela supprimera les dossier suivants:\n\n{0}
CatalogManager.cannotRemoveSelectedCatalogs = Impossible de supprimer les catalogues sélectionnés:\n{0}
ManuallyInstalledExtensionLine.deleteExtension = Supprimer l'extension
@@ -43,6 +44,8 @@ ManuallyInstalledExtensionLine.cannotDeleteExtension = Impossible de supprimer l
ProgressWindow.cancel = Annuler
+Catalog.CatalogPane.errorFetchingExtensions = Erreur lors de la récupération des extensions:\n{0}
+
Catalog.ExtensionDetails.close = Fermer
Catalog.ExtensionDetails.extensionNotCompatible = Cette extension n'est pas compatible avec la version actuelle du logiciel.
Catalog.ExtensionDetails.browserError = Erreur de navigateur
@@ -57,7 +60,7 @@ Catalog.ExtensionLine.updateExtension = Mettre
Catalog.ExtensionLine.removeExtension = Supprimer l'extension
Catalog.ExtensionLine.remove = Supprimer {0}?\nCela va supprimer le dossier "{1}" et tout son contenu.
Catalog.ExtensionLine.extensionManager = Gestionnaire d'extensions
-Catalog.ExtensionLine.removed = {0} supprimée.
+Catalog.ExtensionLine.removed = {0} supprimée.\n\nVous devrez peut-être redémarrer l'application pour voir les modifications.
Catalog.ExtensionLine.error = Erreur
Catalog.ExtensionLine.cannotDeleteExtension = Impossible de supprimer l'extension:\n{0}
@@ -83,5 +86,6 @@ Catalog.ExtensionModificationWindow.downloading = T
Catalog.ExtensionModificationWindow.extracting = Extraction de {0}...
Catalog.ExtensionModificationWindow.extensionManager = Gestionnaire d'extensions
Catalog.ExtensionModificationWindow.installed = {0} {1} installée.
+Catalog.ExtensionModificationWindow.removedAndInstalled = {0} {1} supprimée et {0} {2} installée.\n\nVous devrez peut-être redémarrer l'application pour voir les modifications.
Catalog.ExtensionModificationWindow.installationError = Erreur d'installation
Catalog.ExtensionModificationWindow.notInstalled = {0} {1} pas installée: {2}.
\ No newline at end of file
diff --git a/extensionmanager/src/test/java/qupath/ext/extensionmanager/SimpleServer.java b/extensionmanager/src/test/java/qupath/ext/extensionmanager/SimpleServer.java
index 92ce407..57a1e7e 100644
--- a/extensionmanager/src/test/java/qupath/ext/extensionmanager/SimpleServer.java
+++ b/extensionmanager/src/test/java/qupath/ext/extensionmanager/SimpleServer.java
@@ -3,6 +3,7 @@
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.SimpleFileServer;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
@@ -13,8 +14,7 @@
import java.util.concurrent.atomic.AtomicInteger;
/**
- * A web server that serves static files.
- * It must be {@link #close() closed} once no longer used.
+ * A web server that serves static files. It must be {@link #close() closed} once no longer used.
*/
public class SimpleServer implements AutoCloseable {
@@ -35,7 +35,6 @@ public record FileToServe(String name, InputStream content) {}
*
* @param files the files to serve
* @throws IOException if the files cannot be copied
- * @throws SecurityException if the user doesn't have enough rights to create a temporary directory
* @throws java.nio.file.InvalidPathException if a path to a file cannot be created
* @throws java.io.UncheckedIOException if an I/O error occurs
*/
@@ -62,8 +61,8 @@ public void close() {
/**
* Get the link from where this server serves the file with the provided name.
- * The link is not guaranteed to work if the provided file is not served by this
- * server.
+ *
+ * The link is not guaranteed to work if the provided file is not served by this server.
*
* @param fileName the name of the file that should be served from the given URI
* @return the URI pointing to the provided file
diff --git a/extensionmanager/src/test/java/qupath/ext/extensionmanager/TestUtils.java b/extensionmanager/src/test/java/qupath/ext/extensionmanager/TestUtils.java
index f1f2ab5..67ad1d2 100644
--- a/extensionmanager/src/test/java/qupath/ext/extensionmanager/TestUtils.java
+++ b/extensionmanager/src/test/java/qupath/ext/extensionmanager/TestUtils.java
@@ -1,6 +1,8 @@
package qupath.ext.extensionmanager;
import org.junit.jupiter.api.Assertions;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.util.Collection;
@@ -9,23 +11,69 @@
*/
public class TestUtils {
+ private static final Logger logger = LoggerFactory.getLogger(TestUtils.class);
+ private static final long TIME_BETWEEN_ATTEMPTS_MS = 200;
private TestUtils() {
throw new AssertionError("This class is not instantiable.");
}
/**
- * Assert that two lists are equal without taking the order
- * of elements into account.
- * This function doesn't work if some duplicates are present in one
- * of the list.
+ * Assert that two collections are equal without taking the order of elements into account.
+ *
+ * Warning: this function doesn't work if some duplicates are present in one of the collection.
*
* @param expectedCollection the expected values
* @param actualCollection the actual values
* @param the type of the elements of the collection
*/
- public static void assertCollectionsEqualsWithoutOrder(Collection extends T> expectedCollection, Collection extends T> actualCollection) {
+ public static void assertCollectionsEqualsWithoutOrder(
+ Collection extends T> expectedCollection,
+ Collection extends T> actualCollection
+ ) {
Assertions.assertEquals(expectedCollection.size(), actualCollection.size());
+
Assertions.assertTrue(expectedCollection.containsAll(actualCollection));
Assertions.assertTrue(actualCollection.containsAll(expectedCollection));
}
+
+ /**
+ * Assert that two collections are equal without taking the order of elements into account. This method will retry the
+ * assertion for a specified waiting period if the collections are not immediately equal.
+ *
+ * Warning: this function doesn't work if some duplicates are present in one of the collection.
+ *
+ * @param expectedCollection the expected values
+ * @param actualCollection the actual values
+ * @param waitingTimeMs the maximum time (in milliseconds) to wait for the collections to become equal
+ * @param the type of the elements of the collection
+ * @throws InterruptedException if the calling thread is interrupted
+ */
+ public static void assertCollectionsEqualsWithoutOrder(
+ Collection extends T> expectedCollection,
+ Collection extends T> actualCollection,
+ int waitingTimeMs
+ ) throws InterruptedException {
+ long startTime = System.currentTimeMillis();
+ long time = System.currentTimeMillis();
+
+ while (time < startTime + waitingTimeMs) {
+ try {
+ assertCollectionsEqualsWithoutOrder(expectedCollection, actualCollection);
+ return;
+ } catch (Throwable e) {
+ logger.debug(
+ "Expected: {} and actual: {} are not equal. Waiting {} and attempting to compare again",
+ expectedCollection,
+ actualCollection,
+ TIME_BETWEEN_ATTEMPTS_MS
+ );
+
+ Thread.sleep(TIME_BETWEEN_ATTEMPTS_MS);
+
+ time = System.currentTimeMillis();
+ }
+ }
+
+ assertCollectionsEqualsWithoutOrder(expectedCollection, actualCollection);
+ }
}
diff --git a/extensionmanager/src/test/java/qupath/ext/extensionmanager/core/TestExtensionCatalogManager.java b/extensionmanager/src/test/java/qupath/ext/extensionmanager/core/TestExtensionCatalogManager.java
index d55be27..61208dc 100644
--- a/extensionmanager/src/test/java/qupath/ext/extensionmanager/core/TestExtensionCatalogManager.java
+++ b/extensionmanager/src/test/java/qupath/ext/extensionmanager/core/TestExtensionCatalogManager.java
@@ -2,29 +2,33 @@
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.value.ObservableValue;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import qupath.ext.extensionmanager.SimpleServer;
import qupath.ext.extensionmanager.TestUtils;
-import qupath.ext.extensionmanager.core.catalog.CatalogFetcher;
+import qupath.ext.extensionmanager.core.catalog.Catalog;
+import qupath.ext.extensionmanager.core.catalog.DefaultCatalog;
import qupath.ext.extensionmanager.core.catalog.Extension;
import qupath.ext.extensionmanager.core.catalog.Release;
-import qupath.ext.extensionmanager.core.catalog.VersionRange;
-import qupath.ext.extensionmanager.core.savedentities.InstalledExtension;
-import qupath.ext.extensionmanager.core.savedentities.Registry;
-import qupath.ext.extensionmanager.core.savedentities.SavedCatalog;
-import qupath.ext.extensionmanager.core.savedentities.UpdateAvailable;
+import qupath.ext.extensionmanager.core.model.CatalogModel;
+import qupath.ext.extensionmanager.core.model.ExtensionModel;
+import qupath.ext.extensionmanager.core.model.ReleaseModel;
+import qupath.ext.extensionmanager.core.model.VersionRangeModel;
+import qupath.ext.extensionmanager.core.catalog.UpdateAvailable;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
-import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
import java.util.stream.Stream;
/**
@@ -36,8 +40,7 @@
public class TestExtensionCatalogManager {
private static final String CATALOG_NAME = "catalog.json";
- private static final int CHANGE_WAITING_TIME_CATALOG_MS = 100;
- private static final int CHANGE_WAITING_TIME_MANUAL_MS = 10000;
+ private static final int JAR_LOADED_TIMEOUT_MS = 10000;
private static SimpleServer server;
@BeforeAll
@@ -56,897 +59,1045 @@ static void stopServer() {
}
@Test
- void Check_Creation_When_Extension_Directory_Null() {
+ void Check_Creation_When_Extensions_Directory_Observable_Null() {
+ ObservableValue extensionsDirectory = null;
+ ClassLoader classLoader = TestExtensionCatalogManager.class.getClassLoader();
+ String version = "v1.2.3";
+ List defaultCatalogs = createDefaultCatalogs();
+
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new ExtensionCatalogManager(extensionsDirectory, classLoader, version, defaultCatalogs)
+ );
+ }
+
+ @Test
+ void Check_Creation_When_Extensions_Directory_Null() {
+ ObservableValue extensionsDirectory = new SimpleObjectProperty<>(null);
+ ClassLoader classLoader = TestExtensionCatalogManager.class.getClassLoader();
+ String version = "v1.2.3";
+ List defaultCatalogs = createDefaultCatalogs();
+
Assertions.assertDoesNotThrow(() -> {
ExtensionCatalogManager extensionCatalogManager = new ExtensionCatalogManager(
- new SimpleObjectProperty<>(null),
- TestExtensionCatalogManager.class.getClassLoader(),
- "v1.2.3",
- createSampleRegistry()
+ extensionsDirectory,
+ classLoader,
+ version,
+ defaultCatalogs
);
extensionCatalogManager.close();
});
}
@Test
- void Check_Creation_When_Extension_Directory_Property_Null() {
- Assertions.assertThrows(
- NullPointerException.class,
- () -> {
- ExtensionCatalogManager extensionCatalogManager = new ExtensionCatalogManager(
- null,
- TestExtensionCatalogManager.class.getClassLoader(),
- "v1.2.3",
- createSampleRegistry()
- );
- extensionCatalogManager.close();
- }
- );
- }
+ void Check_Creation_When_Parent_Class_Loader_Null() throws IOException {
+ ObservableValue extensionsDirectory = new SimpleObjectProperty<>(Files.createTempDirectory(null));
+ ClassLoader classLoader = null;
+ String version = "v1.2.3";
+ List defaultCatalogs = createDefaultCatalogs();
- @Test
- void Check_Creation_When_Parent_Class_Loader_Null() {
Assertions.assertDoesNotThrow(() -> {
ExtensionCatalogManager extensionCatalogManager = new ExtensionCatalogManager(
- new SimpleObjectProperty<>(Files.createTempDirectory(null)),
- null,
- "v1.2.3",
- createSampleRegistry()
+ extensionsDirectory,
+ classLoader,
+ version,
+ defaultCatalogs
);
extensionCatalogManager.close();
});
}
@Test
- void Check_Creation_When_Version_Null() {
+ void Check_Creation_When_Version_Null() throws IOException {
+ ObservableValue extensionsDirectory = new SimpleObjectProperty<>(Files.createTempDirectory(null));
+ ClassLoader classLoader = TestExtensionCatalogManager.class.getClassLoader();
+ String version = null;
+ List defaultCatalogs = createDefaultCatalogs();
+
Assertions.assertThrows(
NullPointerException.class,
- () -> {
- ExtensionCatalogManager extensionCatalogManager = new ExtensionCatalogManager(
- new SimpleObjectProperty<>(Files.createTempDirectory(null)),
- TestExtensionCatalogManager.class.getClassLoader(),
- null,
- createSampleRegistry()
- );
- extensionCatalogManager.close();
- }
+ () -> new ExtensionCatalogManager(extensionsDirectory, classLoader, version, defaultCatalogs)
);
}
@Test
- void Check_Creation_When_Version_Not_Valid() {
+ void Check_Creation_When_Version_Not_Valid() throws IOException {
+ ObservableValue extensionsDirectory = new SimpleObjectProperty<>(Files.createTempDirectory(null));
+ ClassLoader classLoader = TestExtensionCatalogManager.class.getClassLoader();
+ String version = "invalid_version";
+ List defaultCatalogs = createDefaultCatalogs();
+
Assertions.assertThrows(
IllegalArgumentException.class,
- () -> {
- ExtensionCatalogManager extensionCatalogManager = new ExtensionCatalogManager(
- new SimpleObjectProperty<>(Files.createTempDirectory(null)),
- TestExtensionCatalogManager.class.getClassLoader(),
- "invalid_version",
- createSampleRegistry()
- );
- extensionCatalogManager.close();
- }
+ () -> new ExtensionCatalogManager(extensionsDirectory, classLoader, version, defaultCatalogs)
);
}
@Test
- void Check_Creation_When_Default_Registry_Null() {
- Assertions.assertDoesNotThrow(() -> {
- ExtensionCatalogManager extensionCatalogManager = new ExtensionCatalogManager(
- new SimpleObjectProperty<>(Files.createTempDirectory(null)),
- TestExtensionCatalogManager.class.getClassLoader(),
- "v1.2.3",
- null
- );
- extensionCatalogManager.close();
- });
+ void Check_Creation_When_Default_Catalogs_Null() throws IOException {
+ ObservableValue extensionsDirectory = new SimpleObjectProperty<>(Files.createTempDirectory(null));
+ ClassLoader classLoader = TestExtensionCatalogManager.class.getClassLoader();
+ String version = "v1.2.3";
+ List defaultCatalogs = null;
+
+ Assertions.assertThrows(
+ NullPointerException.class,
+ () -> new ExtensionCatalogManager(extensionsDirectory, classLoader, version, defaultCatalogs)
+ );
}
@Test
- void Check_Extension_Directory_Path() throws Exception {
- Path expectedExtensionDirectory = Files.createTempDirectory(null);
+ void Check_Version() throws Exception {
+ ObservableValue extensionsDirectory = new SimpleObjectProperty<>(Files.createTempDirectory(null));
+ ClassLoader classLoader = TestExtensionCatalogManager.class.getClassLoader();
+ Version expectedVersion = new Version("v1.2.3");
+ List defaultCatalogs = createDefaultCatalogs();
try (ExtensionCatalogManager extensionCatalogManager = new ExtensionCatalogManager(
- new SimpleObjectProperty<>(expectedExtensionDirectory),
- TestExtensionCatalogManager.class.getClassLoader(),
- "v1.2.3",
- createSampleRegistry()
+ extensionsDirectory,
+ classLoader,
+ expectedVersion.toString(),
+ defaultCatalogs
)) {
- Path extensionDirectory = extensionCatalogManager.getExtensionDirectoryPath().get();
+ Version version = extensionCatalogManager.getVersion();
- Assertions.assertEquals(expectedExtensionDirectory, extensionDirectory);
+ Assertions.assertEquals(expectedVersion, version);
}
}
@Test
- void Check_Extension_Directory_Path_After_Changed() throws Exception {
- Path firstExtensionDirectory = Files.createTempDirectory(null);
- Path expectedExtensionDirectory = Files.createTempDirectory(null);
- ObjectProperty extensionDirectoryProperty = new SimpleObjectProperty<>(firstExtensionDirectory);
+ void Check_Extensions_Directory_Path() throws Exception {
+ Path expectedExtensionsDirectory = Files.createTempDirectory(null);
+ ObservableValue extensionsDirectory = new SimpleObjectProperty<>(expectedExtensionsDirectory);
+ ClassLoader classLoader = TestExtensionCatalogManager.class.getClassLoader();
+ String version = "v1.2.3";
+ List defaultCatalogs = createDefaultCatalogs();
try (ExtensionCatalogManager extensionCatalogManager = new ExtensionCatalogManager(
- extensionDirectoryProperty,
- TestExtensionCatalogManager.class.getClassLoader(),
- "v1.2.3",
- createSampleRegistry()
+ extensionsDirectory,
+ classLoader,
+ version,
+ defaultCatalogs
)) {
- extensionDirectoryProperty.set(expectedExtensionDirectory);
- Path extensionDirectory = extensionCatalogManager.getExtensionDirectoryPath().get();
+ Path extensionsDirectoryValue = extensionCatalogManager.getExtensionsDirectory().getValue();
- Assertions.assertEquals(expectedExtensionDirectory, extensionDirectory);
+ Assertions.assertEquals(expectedExtensionsDirectory, extensionsDirectoryValue);
}
}
@Test
- void Check_Version() throws Exception {
- String expectedVersion = "v1.2.4";
+ void Check_Extensions_Directory_Path_After_Changed() throws Exception {
+ ObjectProperty extensionsDirectory = new SimpleObjectProperty<>(Files.createTempDirectory(null));
+ Path expectedExtensionsDirectory = Files.createTempDirectory(null);
+ ClassLoader classLoader = TestExtensionCatalogManager.class.getClassLoader();
+ String version = "v1.2.3";
+ List