From a71132b6c47e056c4e599d96fa3a3b7b60834ce0 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 8 Nov 2023 09:21:11 +0000 Subject: [PATCH 1/4] Refactoring & UI improvements - More informative method names (e.g. getTorchScriptFile() rather than getTSFile()` - Add directory choosers for selecting the model/user directories - Fix UI bug that could cause the stage to migrate to the screen corner when it was resized, closed & reopened - Remember stage position for when it is reopened --- CHANGELOG.md | 8 +- src/main/java/qupath/ext/wsinfer/WSInfer.java | 2 +- .../ext/wsinfer/models/WSInferModel.java | 24 +- .../ext/wsinfer/models/WSInferModelLocal.java | 10 +- .../ext/wsinfer/models/WSInferUtils.java | 13 +- .../qupath/ext/wsinfer/ui/WSInferCommand.java | 18 +- .../ext/wsinfer/ui/WSInferController.java | 33 +- .../qupath/ext/wsinfer/ui/WSInferPrefs.java | 2 +- .../qupath/ext/wsinfer/ui/strings.properties | 8 +- .../ext/wsinfer/ui/wsinfer_control.fxml | 340 ++++++++++-------- 10 files changed, 272 insertions(+), 186 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index deaf481..db4ffd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,11 @@ ## v0.3.0 -* Add support for QuPath v0.5 -* Enable auto-updating (see https://github.com/qupath/qupath-extension-wsinfer/issues/40) -* Add support for local models (see https://github.com/qupath/qupath-extension-wsinfer/issues/32) +* Compatibility with QuPath v0.5 (now the minimum version required) +* Enable auto-updating the extension (https://github.com/qupath/qupath-extension-wsinfer/issues/40) +* Support for local models (https://github.com/qupath/qupath-extension-wsinfer/issues/32) +* Show model information, where available +* Code refactoring and UI improvements ## v0.2.1 diff --git a/src/main/java/qupath/ext/wsinfer/WSInfer.java b/src/main/java/qupath/ext/wsinfer/WSInfer.java index 89ef853..36c8b0a 100644 --- a/src/main/java/qupath/ext/wsinfer/WSInfer.java +++ b/src/main/java/qupath/ext/wsinfer/WSInfer.java @@ -307,7 +307,7 @@ private static Translator buildTranslator(WSInferModel w private static Criteria buildCriteria(WSInferModel wsiModel, Translator translator, Device device) { return Criteria.builder() .optApplication(Application.CV.IMAGE_CLASSIFICATION) - .optModelPath(wsiModel.getTSFile().toPath()) + .optModelPath(wsiModel.getTorchScriptFile().toPath()) .optEngine("PyTorch") .setTypes(Image.class, Classifications.class) .optTranslator(translator) diff --git a/src/main/java/qupath/ext/wsinfer/models/WSInferModel.java b/src/main/java/qupath/ext/wsinfer/models/WSInferModel.java index 952a449..e7239c8 100644 --- a/src/main/java/qupath/ext/wsinfer/models/WSInferModel.java +++ b/src/main/java/qupath/ext/wsinfer/models/WSInferModel.java @@ -46,6 +46,10 @@ public class WSInferModel { @SerializedName("hf_revision") String hfRevision; + /** + * Get a displayable name for the model. + * @return + */ public String getName() { return hfRepoId; } @@ -65,15 +69,15 @@ public WSInferModelConfiguration getConfiguration() { * Remove the cached model files. */ public synchronized void removeCache() { - getTSFile().delete(); - getCFFile().delete(); + getTorchScriptFile().delete(); + getConfigFile().delete(); } /** * Get the torchscript file. Note that it is not guaranteed that the model has been downloaded. * @return path to torchscript pt file in cache dir */ - public File getTSFile() { + public File getTorchScriptFile() { return getFile("torchscript_model.pt"); } @@ -81,7 +85,7 @@ public File getTSFile() { * Get the configuration file. Note that it is not guaranteed that the model has been downloaded. * @return path to model config file in cache dir */ - public File getCFFile() { + public File getConfigFile() { return getFile("config.json"); } @@ -90,7 +94,7 @@ public File getCFFile() { * Get the configuration file. Note that it is not guaranteed that the model has been downloaded. * @return path to model config file in cache dir */ - public File getREADMEFile() { + public File getReadMeFile() { return getFile("README.md"); } @@ -99,7 +103,7 @@ public File getREADMEFile() { * @return true if the files exist and the SHA matches, and the config is valid. */ public boolean isValid() { - return getTSFile().exists() && checkModifiedTimes() && getConfiguration() != null; + return getTorchScriptFile().exists() && checkModifiedTimes() && getConfiguration() != null; } /** @@ -110,7 +114,7 @@ public boolean isValid() { */ private boolean checkModifiedTimes() { try { - return Files.getLastModifiedTime(getTSFile().toPath()) + return Files.getLastModifiedTime(getTorchScriptFile().toPath()) .compareTo(Files.getLastModifiedTime(getPointerFile().toPath())) < 0; } catch (IOException e) { logger.error("Cannot get last modified time"); @@ -131,7 +135,7 @@ File getModelDirectory() { } private WSInferModelConfiguration tryToLoadConfiguration() { - var cfFile = getCFFile(); + var cfFile = getConfigFile(); if (cfFile.exists()) { try (var reader = Files.newBufferedReader(cfFile.toPath(), StandardCharsets.UTF_8)) { return GsonTools.getInstance().fromJson(reader, WSInferModelConfiguration.class); @@ -155,7 +159,7 @@ private static String checkSumSHA256(File file) throws IOException, NoSuchAlgori */ private boolean checkSHAMatches() { try { - String shaDown = checkSumSHA256(getTSFile()); + String shaDown = checkSumSHA256(getTorchScriptFile()); // this is the format // Result: version https://git-lfs.github.com/spec/v1 // oid sha256:fffeeecb4282b61b2b699c6dfcd8f76c30c8ca1af9800fa78f5d81fc0b78a4e2 @@ -167,7 +171,7 @@ private boolean checkSHAMatches() { return false; } } catch (IOException | NoSuchAlgorithmException e) { - logger.error("Unable to generate SHA for {}", getTSFile(), e); + logger.error("Unable to generate SHA for {}", getTorchScriptFile(), e); return false; } return true; diff --git a/src/main/java/qupath/ext/wsinfer/models/WSInferModelLocal.java b/src/main/java/qupath/ext/wsinfer/models/WSInferModelLocal.java index 0d6c9cd..79600ac 100644 --- a/src/main/java/qupath/ext/wsinfer/models/WSInferModelLocal.java +++ b/src/main/java/qupath/ext/wsinfer/models/WSInferModelLocal.java @@ -43,11 +43,11 @@ private WSInferModelLocal(File modelDirectory) throws IOException { this.modelDirectory = modelDirectory; this.hfRepoId = modelDirectory.getName(); List files = Arrays.asList(Objects.requireNonNull(modelDirectory.listFiles())); - if (!files.contains(getCFFile())) { - throw new IOException(resources.getString("error.localModel") + ": " + getCFFile().toString()); + if (!files.contains(getConfigFile())) { + throw new IOException(resources.getString("error.localModel") + ": " + getConfigFile().toString()); } - if (!files.contains(getTSFile())) { - throw new IOException(resources.getString("error.localModel") + ": " + getTSFile().toString()); + if (!files.contains(getTorchScriptFile())) { + throw new IOException(resources.getString("error.localModel") + ": " + getTorchScriptFile().toString()); } } @@ -58,7 +58,7 @@ File getModelDirectory() { @Override public boolean isValid() { - return getTSFile().exists() && getConfiguration() != null; + return getTorchScriptFile().exists() && getConfiguration() != null; } @Override diff --git a/src/main/java/qupath/ext/wsinfer/models/WSInferUtils.java b/src/main/java/qupath/ext/wsinfer/models/WSInferUtils.java index 8e31d38..59d86d3 100644 --- a/src/main/java/qupath/ext/wsinfer/models/WSInferUtils.java +++ b/src/main/java/qupath/ext/wsinfer/models/WSInferUtils.java @@ -19,7 +19,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import qupath.ext.wsinfer.ui.WSInferPrefs; -import qupath.fx.dialogs.Dialogs; import qupath.lib.io.GsonTools; import java.io.File; @@ -83,9 +82,17 @@ private static void addLocalModels(WSInferModelCollection cachedModelCollection, if (model.isDirectory()) { try { var localModel = WSInferModelLocal.createInstance(model); - cachedModelCollection.getModels().put(localModel.getName(), localModel); + if (cachedModelCollection.getModels().put(localModel.getName(), localModel) != null) { + logger.warn("Replaced model {} with local version", localModel.getName()); + } else { + logger.info("Added local model {}", localModel.getName()); + } } catch (IOException e) { - Dialogs.showErrorNotification(resources.getString("title"), e); + // This can occur if non-model directories are found in the current directory - + // especially if the user is *typing* the directory, so there are intermediate + // steps with directories that don't contain models; that's why we log rather than + // show a dialog or notification. + logger.warn(e.getMessage(), e); } } } diff --git a/src/main/java/qupath/ext/wsinfer/ui/WSInferCommand.java b/src/main/java/qupath/ext/wsinfer/ui/WSInferCommand.java index 6c14ec5..72cab15 100644 --- a/src/main/java/qupath/ext/wsinfer/ui/WSInferCommand.java +++ b/src/main/java/qupath/ext/wsinfer/ui/WSInferCommand.java @@ -23,7 +23,7 @@ import javafx.stage.Stage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import qupath.lib.gui.ExtensionClassLoader; +import qupath.fx.utils.FXUtils; import qupath.lib.gui.QuPathGUI; import qupath.fx.dialogs.Dialogs; @@ -54,14 +54,16 @@ public void run() { if (stage == null) { try { stage = createStage(); + stage.show(); + FXUtils.retainWindowPosition(stage); } catch (IOException e) { Dialogs.showErrorMessage(resources.getString("title"), resources.getString("error.window")); logger.error(e.getMessage(), e); return; } - } - stage.show(); + } else + stage.show(); } private Stage createStage() throws IOException { @@ -87,10 +89,18 @@ private Stage createStage() throws IOException { stage.setScene(scene); stage.setResizable(false); - root.heightProperty().addListener((v, o, n) -> stage.sizeToScene()); + root.heightProperty().addListener((v, o, n) -> handleStageHeightChange()); return stage; } + private void handleStageHeightChange() { + stage.sizeToScene(); + // This fixes a bug where the stage would migrate to the corner of a screen if it is + // resized, hidden, then shown again + if (stage.isShowing() && Double.isFinite(stage.getX()) && Double.isFinite(stage.getY())) + FXUtils.retainWindowPosition(stage); + } + } diff --git a/src/main/java/qupath/ext/wsinfer/ui/WSInferController.java b/src/main/java/qupath/ext/wsinfer/ui/WSInferController.java index 17bfe10..c453cc0 100644 --- a/src/main/java/qupath/ext/wsinfer/ui/WSInferController.java +++ b/src/main/java/qupath/ext/wsinfer/ui/WSInferController.java @@ -25,6 +25,7 @@ import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.StringProperty; import javafx.beans.value.ObservableValue; import javafx.concurrent.Task; import javafx.concurrent.Worker; @@ -56,10 +57,11 @@ import qupath.ext.wsinfer.models.WSInferModelLocal; import qupath.ext.wsinfer.models.WSInferUtils; import qupath.fx.dialogs.Dialogs; +import qupath.fx.dialogs.FileChoosers; +import qupath.fx.utils.FXUtils; import qupath.lib.common.ThreadTools; import qupath.lib.gui.QuPathGUI; import qupath.lib.gui.commands.Commands; -import qupath.lib.gui.tools.IconFactory; import qupath.lib.gui.tools.WebViews; import qupath.lib.images.ImageData; import qupath.lib.objects.PathAnnotationObject; @@ -70,6 +72,7 @@ import qupath.lib.plugins.workflow.DefaultScriptableWorkflowStep; import java.awt.image.BufferedImage; +import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -358,13 +361,39 @@ public void downloadModel() { }); } + public void promptForModelDirectory() { + promptToUpdateDirectory(WSInferPrefs.modelDirectoryProperty()); + } + + public void promptForLocalModelDirectory() { + promptToUpdateDirectory(WSInferPrefs.localDirectoryProperty()); + } + + private void promptToUpdateDirectory(StringProperty dirPath) { + var modelDirPath = dirPath.get(); + var dir = modelDirPath == null || modelDirPath.isEmpty() ? null : new File(modelDirPath); + if (dir != null) { + if (dir.isFile()) + dir = dir.getParentFile(); + else if (!dir.exists()) + dir = null; + } + var newDir = FileChoosers.promptForDirectory( + FXUtils.getWindow(tfModelDirectory), // Get window from any node here + resources.getString("ui.model-directory.choose-directory"), + dir); + if (newDir == null) + return; + dirPath.set(newDir.getAbsolutePath()); + } + public void showInfo() throws IOException { if (infoPopover.isShowing()) { infoPopover.hide(); return; } WSInferModel model = modelChoiceBox.getSelectionModel().getSelectedItem(); - Path mdFile = model.getREADMEFile().toPath(); + Path mdFile = model.getReadMeFile().toPath(); var doc = Parser.builder().build().parse(Files.readString(mdFile)); WebView webView = WebViews.create(true); webView.getEngine().loadContent(HtmlRenderer.builder().build().render(doc)); diff --git a/src/main/java/qupath/ext/wsinfer/ui/WSInferPrefs.java b/src/main/java/qupath/ext/wsinfer/ui/WSInferPrefs.java index 82b0b40..be6ae21 100644 --- a/src/main/java/qupath/ext/wsinfer/ui/WSInferPrefs.java +++ b/src/main/java/qupath/ext/wsinfer/ui/WSInferPrefs.java @@ -41,7 +41,7 @@ public class WSInferPrefs { private static final Property numWorkersProperty = PathPrefs.createPersistentPreference( "wsinfer.numWorkers", - 2 + 1 ).asObject(); private static StringProperty localDirectoryProperty = PathPrefs.createPersistentPreference( "wsinfer.localDirectory", diff --git a/src/main/resources/qupath/ext/wsinfer/ui/strings.properties b/src/main/resources/qupath/ext/wsinfer/ui/strings.properties index f3b70d2..d5e6a3e 100644 --- a/src/main/resources/qupath/ext/wsinfer/ui/strings.properties +++ b/src/main/resources/qupath/ext/wsinfer/ui/strings.properties @@ -58,6 +58,12 @@ ui.options.localModelDirectory.tooltip = Choose the directory where user-created ui.options.pworkers = Number of parallel tile loaders: ui.options.pworkers.tooltip = Choose the desired number of threads used to request tiles for inference +# Model directories +ui.model-directory.choose-directory = Choose directory +ui.model-directory.no-local-models = No local models found +ui.model-directory.found-1-local-model = 1 local model found +ui.model-directory.found-n-local-models = %d local models found + ## Other Windows # Processing Window and progress pop-ups ui.processing = Processing tiles @@ -79,4 +85,4 @@ ui.stop-tasks = Stop all running tasks? error.window = Error initializing WSInfer window.\nAn internet connection is required when running for the first time. error.no-imagedata = Cannot run WSInfer plugin without ImageData. error.downloading = Error downloading files -error.localModel = Cannot find file in user model directory +error.localModel = Can't find file in user model directory diff --git a/src/main/resources/qupath/ext/wsinfer/ui/wsinfer_control.fxml b/src/main/resources/qupath/ext/wsinfer/ui/wsinfer_control.fxml index 8a81b60..a4e09b1 100644 --- a/src/main/resources/qupath/ext/wsinfer/ui/wsinfer_control.fxml +++ b/src/main/resources/qupath/ext/wsinfer/ui/wsinfer_control.fxml @@ -21,184 +21,212 @@ - - - - - - - - + + - - + + + - - + + - - - + + + + + - - + + + + + + + - + - - - - + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + - + - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - + + + + + From 92d46ba6144c430498f4f1d0edd7a1564ffcf792 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 8 Nov 2023 17:21:27 +0000 Subject: [PATCH 2/4] Improve markdown parsing Use commonmark extension to remove yaml frontmatter, and insert it at the end of the doc. Also add name and description (if available) as headings. (This will require the latest QuPath v0.5.0 snapshot to add the yaml support) --- build.gradle | 4 +- .../ext/wsinfer/models/WSInferModel.java | 24 ++++++- .../ext/wsinfer/ui/WSInferController.java | 72 ++++++++++++++++--- .../ext/wsinfer/ui/wsinfer_control.fxml | 5 -- 4 files changed, 86 insertions(+), 19 deletions(-) diff --git a/build.gradle b/build.gradle index e644da4..a774493 100644 --- a/build.gradle +++ b/build.gradle @@ -37,8 +37,8 @@ dependencies { // Main QuPath user interface jar. // Automatically includes other QuPath jars as subdependencies. implementation "io.github.qupath:qupath-gui-fx:${qupathVersion}" - implementation 'io.github.qupath:qupath-fxtras:0.1.0' - implementation 'org.commonmark:commonmark:0.21.0' + implementation libs.qupath.fxtras + implementation libs.bundles.markdown // For logging - the version comes from QuPath's version catalog at // https://github.com/qupath/qupath/blob/main/gradle/libs.versions.toml diff --git a/src/main/java/qupath/ext/wsinfer/models/WSInferModel.java b/src/main/java/qupath/ext/wsinfer/models/WSInferModel.java index e7239c8..10c3f5a 100644 --- a/src/main/java/qupath/ext/wsinfer/models/WSInferModel.java +++ b/src/main/java/qupath/ext/wsinfer/models/WSInferModel.java @@ -54,6 +54,14 @@ public String getName() { return hfRepoId; } + /** + * Get a description, if available (may be null). + * @return + */ + public String getDescription() { + return description; + } + /** * Get the configuration. Note that this may be null. * @return the model configuration, or null. @@ -69,8 +77,20 @@ public WSInferModelConfiguration getConfiguration() { * Remove the cached model files. */ public synchronized void removeCache() { - getTorchScriptFile().delete(); - getConfigFile().delete(); + removeIfFound(getTorchScriptFile(), getConfigFile(), getReadMeFile()); + } + + private static void removeIfFound(File... files) { + for (var file : files) { + if (file != null && file.isFile()) { + try { + logger.debug("Deleting file {}", file); + file.delete(); + } catch (Exception e) { + logger.error("Unable to delete file {}", file, e); + } + } + } } /** diff --git a/src/main/java/qupath/ext/wsinfer/ui/WSInferController.java b/src/main/java/qupath/ext/wsinfer/ui/WSInferController.java index c453cc0..3520ed9 100644 --- a/src/main/java/qupath/ext/wsinfer/ui/WSInferController.java +++ b/src/main/java/qupath/ext/wsinfer/ui/WSInferController.java @@ -42,6 +42,8 @@ import javafx.scene.web.WebView; import javafx.stage.Stage; import javafx.util.StringConverter; +import org.commonmark.ext.front.matter.YamlFrontMatterExtension; +import org.commonmark.ext.front.matter.YamlFrontMatterVisitor; import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; import org.controlsfx.control.PopOver; @@ -75,7 +77,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; -import java.nio.file.Path; +import java.util.Arrays; import java.util.Collection; import java.util.Objects; import java.util.ResourceBundle; @@ -127,8 +129,9 @@ public class WSInferController { private TextField tfModelDirectory; @FXML private TextField localModelDirectory; - @FXML - private PopOver infoPopover; + + private WebView infoWebView = WebViews.create(true); + private PopOver infoPopover = new PopOver(infoWebView); private final static ResourceBundle resources = ResourceBundle.getBundle("qupath.ext.wsinfer.ui.strings"); @@ -187,11 +190,15 @@ private void configureModelChoices() { modelChoiceBox.getSelectionModel().selectedItemProperty().addListener( (v, o, n) -> { downloadButton.setDisable((n == null) || n.isValid()); - infoButton.setDisable((n == null) || (!n.isValid()) || n instanceof WSInferModelLocal); + infoButton.setDisable((n == null) || (!n.isValid()) || !checkFileExists(n.getReadMeFile())); infoPopover.hide(); }); } + private static boolean checkFileExists(File file) { + return file != null && file.isFile(); + } + private void configureAvailableDevices() { var available = PytorchManager.getAvailableDevices(); deviceChoices.getItems().setAll(available); @@ -393,14 +400,59 @@ public void showInfo() throws IOException { return; } WSInferModel model = modelChoiceBox.getSelectionModel().getSelectedItem(); - Path mdFile = model.getReadMeFile().toPath(); - var doc = Parser.builder().build().parse(Files.readString(mdFile)); - WebView webView = WebViews.create(true); - webView.getEngine().loadContent(HtmlRenderer.builder().build().render(doc)); - infoPopover.setContentNode(webView); - infoPopover.show(infoButton); + var file = model.getReadMeFile(); + if (!checkFileExists(file)) { + logger.warn("ReadMe file not available: {}", file); + return; + } + try { + var markdown = Files.readString(file.toPath()); + + // Parse the initial markdown only, to extract any YAML front matter + var parser = Parser.builder() + .extensions( + Arrays.asList(YamlFrontMatterExtension.create()) + ).build(); + var doc = parser.parse(markdown); + var visitor = new YamlFrontMatterVisitor(); + doc.accept(visitor); + var metadata = visitor.getData(); + + // If we have YAML metadata, remove from the start (since it renders weirdly) and append at the end + if (!metadata.isEmpty()) { + doc.getFirstChild().unlink(); + var sb = new StringBuilder(); + sb.append("----\n\n"); + sb.append("### Metadata\n\n"); + for (var entry : metadata.entrySet()) { + sb.append("\n* **").append(entry.getKey()).append("**: ").append(entry.getValue()); + } + doc.appendChild(parser.parse(sb.toString())); + } + + // If the markdown doesn't start with a title, pre-pending the model title & description (if available) + if (!markdown.startsWith("#")) { + var sb = new StringBuilder(); + sb.append("## ").append(model.getName()).append("\n\n"); + var description = model.getDescription(); + if (description != null && !description.isEmpty()) { + sb.append("_").append(description).append("_").append("\n\n"); + } + sb.append("----\n\n"); + doc.prependChild(parser.parse(sb.toString())); + } + + infoWebView.getEngine().loadContent( + HtmlRenderer.builder().build().render(doc)); + infoPopover.show(infoButton); + } catch (IOException e) { + logger.error("Error parsing readme file", e); + } } + + + @FXML private void selectAllAnnotations() { Commands.selectObjectsByClass(imageDataProperty.get(), PathAnnotationObject.class); diff --git a/src/main/resources/qupath/ext/wsinfer/ui/wsinfer_control.fxml b/src/main/resources/qupath/ext/wsinfer/ui/wsinfer_control.fxml index a4e09b1..461bc9d 100644 --- a/src/main/resources/qupath/ext/wsinfer/ui/wsinfer_control.fxml +++ b/src/main/resources/qupath/ext/wsinfer/ui/wsinfer_control.fxml @@ -19,7 +19,6 @@ - @@ -50,10 +49,6 @@ - - - - From 406f7178aac81e7df3013c398f16044d7fa7dc40 Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 8 Nov 2023 17:27:57 +0000 Subject: [PATCH 3/4] Update gradle wrapper --- gradle/wrapper/gradle-wrapper.jar | Bin 59821 -> 60756 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 6 ++++++ gradlew.bat | 14 ++++++++------ settings.gradle | 2 +- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 41d9927a4d4fb3f96a785543079b8df6723c946b..249e5832f090a2944b7473328c07c9755baa3196 100644 GIT binary patch delta 10197 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661..e411586 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c787..a69d9cb 100755 --- a/gradlew +++ b/gradlew @@ -205,6 +205,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index ac1b06f..53a6b23 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +75,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/settings.gradle b/settings.gradle index 5b7f148..d2c8388 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,6 +1,6 @@ pluginManagement { plugins { - id 'org.bytedeco.gradle-javacpp-platform' version '1.5.8' + id 'org.bytedeco.gradle-javacpp-platform' version '1.5.9' } } From 3afb33a38efeb5b08aa257be4d16f74b9d76a9ac Mon Sep 17 00:00:00 2001 From: Pete Date: Wed, 8 Nov 2023 17:32:19 +0000 Subject: [PATCH 4/4] Build in Java 17 This is consistent with the Java version required for QuPath v0.5 --- .github/workflows/gradle.yml | 8 ++++---- .github/workflows/release.yml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 6939668..79f31da 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -16,15 +16,15 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: - java-version: '11' + java-version: '17' distribution: 'temurin' - name: Validate Gradle wrapper - uses: gradle/wrapper-validation-action@v1.1.0 + uses: gradle/wrapper-validation-action@v1 - name: Build with Gradle - uses: gradle/gradle-build-action@v2.7.1 + uses: gradle/gradle-build-action@v2 with: arguments: build - name: Upload artifacts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee960f2..a51cd46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,15 +13,15 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: - java-version: '11' + java-version: '17' distribution: 'temurin' - name: Validate Gradle wrapper - uses: gradle/wrapper-validation-action@v1.1.0 + uses: gradle/wrapper-validation-action@v1 - name: Build with Gradle - uses: gradle/gradle-build-action@v2.7.1 + uses: gradle/gradle-build-action@v2 with: arguments: build - name: Release