diff --git a/build.gradle b/build.gradle index f20f41e..9ad6de2 100644 --- a/build.gradle +++ b/build.gradle @@ -1 +1 @@ -version = "0.1.2" \ No newline at end of file +version = "0.1.3" \ No newline at end of file diff --git a/javadocviewer/src/main/java/qupath/ui/javadocviewer/UriUtils.java b/javadocviewer/src/main/java/qupath/ui/javadocviewer/UriUtils.java new file mode 100644 index 0000000..b291ef9 --- /dev/null +++ b/javadocviewer/src/main/java/qupath/ui/javadocviewer/UriUtils.java @@ -0,0 +1,152 @@ +package qupath.ui.javadocviewer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Scanner; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * A collection of utility functions to work with {@link URI}. + */ +public class UriUtils { + + private static final List WEBSITE_SCHEMES = List.of("http", "https"); + private static final int REQUEST_TIMEOUT_SECONDS = 10; + private static final Logger logger = LoggerFactory.getLogger(UriUtils.class); + + private UriUtils() { + throw new AssertionError("This class is not instantiable."); + } + + /** + * Indicate whether the provided URI links to a website. + * + * @param uri the URI to check + * @return whether the provided URI links to a website + */ + public static boolean doesUriLinkToWebsite(URI uri) { + return uri.getScheme() != null && WEBSITE_SCHEMES.contains(uri.getScheme()); + } + + /** + * Indicate whether the provided URI links to a jar file or a file contained in a jar file. + * + * @param uri the URI to check + * @return whether the provided URI links to a jar file or a file contained in a jar file + */ + public static boolean doesUriLinkToJar(URI uri) { + return uri.getScheme() != null && uri.getScheme().contains("jar"); + } + + /** + * Attempt to read the resource pointed by the provided URI. + *

+ * This function supports reading http or https links, files contained in local jars, and local files. + *

+ * The returned CompletableFuture may complete exceptionally. + * + * @param uri the URI pointing to the resource to read + * @return the resource pointed by the provided URI, or a failed CompletableFuture if the reading did not succeed + */ + public static CompletableFuture getContentOfUri(URI uri) { + if (doesUriLinkToWebsite(uri)) { + return getContentOfHttpUri(uri); + } else if (doesUriLinkToJar(uri)) { + return CompletableFuture.supplyAsync(() -> getContentOfJarUri(uri)); + } else if (uri.getScheme().contains("file")) { + return CompletableFuture.supplyAsync(() -> getContentOfFileUri(uri)); + } else { + return CompletableFuture.failedFuture(new IllegalArgumentException(String.format( + "The provided URI %s does not point to a website, jar or file", + uri + ))); + } + } + + private static CompletableFuture getContentOfHttpUri(URI uri) { + HttpClient httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.ALWAYS) + .build(); + + logger.debug("Sending GET request to {}...", uri); + + return httpClient.sendAsync( + HttpRequest.newBuilder() + .uri(uri) + .timeout(Duration.of(REQUEST_TIMEOUT_SECONDS, ChronoUnit.SECONDS)) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ).thenApply(response -> { + logger.debug("Got response {} from {}", response, uri); + return response.body(); + }).whenComplete((b, e) -> httpClient.close()); + } + + private static String getContentOfJarUri(URI uri) { + // The provided URI is expected to be like: jar:file:/path/to/some-javadoc.jar!/index.html#someParameters + String jarUri = uri.toString().substring( + uri.toString().indexOf('/'), + uri.toString().lastIndexOf('!') + ); + logger.debug("Opening {} jar file to read the content of {}...", jarUri, uri); + + try (ZipFile zipFile = new ZipFile(jarUri)) { + String entryName = uri.toString().substring( + uri.toString().lastIndexOf("!/") + 2, + uri.toString().lastIndexOf('#') == -1 ? uri.toString().length() : uri.toString().lastIndexOf('#') + ); + ZipEntry entry = zipFile.getEntry(entryName); + if (entry == null) { + throw new IllegalArgumentException(String.format("%s not found in %s", entryName, jarUri)); + } + + try ( + InputStream inputStream = zipFile.getInputStream(entry); + Scanner scanner = new Scanner(inputStream) + ) { + StringBuilder lines = new StringBuilder(); + while (scanner.hasNextLine()) { + lines.append(scanner.nextLine()); + } + return lines.toString(); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static String getContentOfFileUri(URI uri) { + logger.debug("Reading {} file...", uri); + + URI uriWithoutFragment; + try { + uriWithoutFragment = new URI(uri.getScheme(), uri.getSchemeSpecificPart(), null); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + + try (Stream lines = Files.lines(Paths.get(uriWithoutFragment))) { + return lines.collect(Collectors.joining("\n")); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/javadocviewer/src/main/java/qupath/ui/javadocviewer/core/Javadoc.java b/javadocviewer/src/main/java/qupath/ui/javadocviewer/core/Javadoc.java index b6ee07c..b2f0885 100644 --- a/javadocviewer/src/main/java/qupath/ui/javadocviewer/core/Javadoc.java +++ b/javadocviewer/src/main/java/qupath/ui/javadocviewer/core/Javadoc.java @@ -2,29 +2,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import qupath.ui.javadocviewer.UriUtils; -import java.io.IOException; -import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.time.Duration; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Scanner; import java.util.concurrent.CompletableFuture; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; /** * A Javadoc specified by a {@link URI} and containing {@link JavadocElement JavadocElements}. @@ -42,7 +29,6 @@ public record Javadoc(URI uri, List elements) { private static final Pattern URI_PATTERN = Pattern.compile("href=\"(.+?)\""); private static final Pattern NAME_PATTERN = Pattern.compile("(?:)?(.*?)(?:)?"); private static final Pattern CATEGORY_PATTERN = Pattern.compile(" - (.+?) "); - private static final int REQUEST_TIMEOUT_SECONDS = 10; /** * Create a Javadoc from a URI and Javadoc elements. Take a look at {@link #create(URI)} @@ -77,24 +63,14 @@ public static CompletableFuture create(URI uri) { private static CompletableFuture getIndexAllPage(URI javadocIndexURI) { String link = javadocIndexURI.toString().replace(INDEX_PAGE, INDEX_ALL_PAGE); - URI indexAllURI; + URI indexAllUri; try { - indexAllURI = new URI(link); + indexAllUri = new URI(link); } catch (URISyntaxException e) { return CompletableFuture.failedFuture(e); } - if (Utils.doesUrilinkToWebsite(indexAllURI)) { - return getIndexAllPageContentFromHttp(indexAllURI); - } else { - return CompletableFuture.supplyAsync(() -> { - if (indexAllURI.getScheme().contains("jar")) { - return getIndexAllPageContentFromJar(indexAllURI); - } else { - return getIndexAllPageContentFromNonJar(indexAllURI); - } - }); - } + return UriUtils.getContentOfUri(indexAllUri); } private static List parseJavadocIndexPage(String javadocURI, String indexHTMLPage) { @@ -136,65 +112,6 @@ private static List parseJavadocIndexPage(String javadocURI, Str return elements; } - private static CompletableFuture getIndexAllPageContentFromHttp(URI uri) { - HttpClient httpClient = HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.ALWAYS) - .build(); - - logger.debug("Sending GET request to {} to read the index-all page content...", uri); - - return httpClient.sendAsync( - HttpRequest.newBuilder() - .uri(uri) - .timeout(Duration.of(REQUEST_TIMEOUT_SECONDS, ChronoUnit.SECONDS)) - .GET() - .build(), - HttpResponse.BodyHandlers.ofString() - ).thenApply(response -> { - logger.debug("Got response {} from {}", response, uri); - return response.body(); - }).whenComplete((b, e) -> httpClient.close()); - } - - private static String getIndexAllPageContentFromJar(URI uri) { - String jarURI = uri.toString().substring( - uri.toString().indexOf('/'), - uri.toString().lastIndexOf('!') - ); - logger.debug("Opening {} jar file to read the index-all page content...", jarURI); - - try (ZipFile zipFile = new ZipFile(jarURI)) { - ZipEntry entry = zipFile.getEntry(INDEX_ALL_PAGE); - - if (entry == null) { - throw new IllegalArgumentException(String.format("The provided jar file %s doesn't contain any %s entry", jarURI, INDEX_ALL_PAGE)); - } else { - try ( - InputStream inputStream = zipFile.getInputStream(entry); - Scanner scanner = new Scanner(inputStream) - ) { - StringBuilder lines = new StringBuilder(); - while (scanner.hasNextLine()) { - lines.append(scanner.nextLine()); - } - return lines.toString(); - } - } - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - private static String getIndexAllPageContentFromNonJar(URI uri) { - logger.debug("Reading {} file to get the index-all page content...", uri); - - try (Stream lines = Files.lines(Paths.get(uri))) { - return lines.collect(Collectors.joining("\n")); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - private static String correctNameIfConstructor(String name, String category) { // Constructor are usually written in the following way: "Class.Class(Parameter)" // This function transforms them into "Class(Parameter)" diff --git a/javadocviewer/src/main/java/qupath/ui/javadocviewer/core/JavadocsFinder.java b/javadocviewer/src/main/java/qupath/ui/javadocviewer/core/JavadocsFinder.java index 0686d6c..90ab51a 100644 --- a/javadocviewer/src/main/java/qupath/ui/javadocviewer/core/JavadocsFinder.java +++ b/javadocviewer/src/main/java/qupath/ui/javadocviewer/core/JavadocsFinder.java @@ -2,6 +2,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import qupath.ui.javadocviewer.UriUtils; import java.io.File; import java.io.IOException; @@ -51,7 +52,7 @@ public static CompletableFuture> findJavadocs(URI... urisToSearch) if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); } - logger.debug("Error when creating javadoc of {}. Skipping it", uri, e); + logger.warn("Error when creating javadoc of {}. Skipping it", uri, e); return null; } @@ -63,7 +64,7 @@ public static CompletableFuture> findJavadocs(URI... urisToSearch) } private static List findJavadocUrisFromUri(URI uri) { - if (Utils.doesUrilinkToWebsite(uri)) { + if (UriUtils.doesUriLinkToWebsite(uri)) { logger.debug("URI {} retrieved", uri); return List.of(uri); } else { diff --git a/javadocviewer/src/main/java/qupath/ui/javadocviewer/core/Utils.java b/javadocviewer/src/main/java/qupath/ui/javadocviewer/core/Utils.java deleted file mode 100644 index 94c2814..0000000 --- a/javadocviewer/src/main/java/qupath/ui/javadocviewer/core/Utils.java +++ /dev/null @@ -1,26 +0,0 @@ -package qupath.ui.javadocviewer.core; - -import java.net.URI; -import java.util.List; - -/** - * A collection of utility functions. - */ -class Utils { - - private static final List WEBSITE_SCHEMES = List.of("http", "https"); - - private Utils() { - throw new AssertionError("This class is not instantiable."); - } - - /** - * Indicate whether the provided URI links to a website. - * - * @param uri the URI to check - * @return whether the provided URI links to a website - */ - public static boolean doesUrilinkToWebsite(URI uri) { - return uri.getScheme() != null && WEBSITE_SCHEMES.contains(uri.getScheme()); - } -} diff --git a/javadocviewer/src/main/java/qupath/ui/javadocviewer/gui/viewer/JavadocViewer.java b/javadocviewer/src/main/java/qupath/ui/javadocviewer/gui/viewer/JavadocViewer.java index 9ddf3bc..4d4e890 100644 --- a/javadocviewer/src/main/java/qupath/ui/javadocviewer/gui/viewer/JavadocViewer.java +++ b/javadocviewer/src/main/java/qupath/ui/javadocviewer/gui/viewer/JavadocViewer.java @@ -3,6 +3,7 @@ import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.ReadOnlyStringProperty; +import javafx.beans.value.ChangeListener; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; @@ -12,12 +13,16 @@ import javafx.scene.layout.BorderPane; import javafx.scene.web.WebHistory; import javafx.scene.web.WebView; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import qupath.ui.javadocviewer.UriUtils; import qupath.ui.javadocviewer.gui.components.AutoCompletionTextField; import qupath.ui.javadocviewer.core.Javadoc; import qupath.ui.javadocviewer.core.JavadocsFinder; import java.io.IOException; import java.net.URI; +import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; import java.util.Comparator; @@ -36,7 +41,9 @@ public class JavadocViewer extends BorderPane { private static final ResourceBundle resources = ResourceBundle.getBundle("qupath.ui.javadocviewer.strings"); private static final Pattern REDIRECTION_PATTERN = Pattern.compile("window\\.location\\.replace\\(['\"](.*?)['\"]\\)"); private static final List CATEGORIES_TO_SKIP = List.of("package", "module", "Variable", "Exception", "Annotation", "Element"); + private static final Logger logger = LoggerFactory.getLogger(JavadocViewer.class); private final WebView webView = new WebView(); + private final ChangeListener urisListener = (p, o, n) -> updateWebViewContent(n); @FXML private Button back; @FXML @@ -140,7 +147,8 @@ protected void updateItem(URI item, boolean empty) { javadocElement, () -> { updateSelectedUri(javadocElement.uri()); - webView.getEngine().load(javadocElement.uri().toString()); + + updateWebViewContent(javadocElement.uri()); } )) .filter(javadocEntry -> !CATEGORIES_TO_SKIP.contains(javadocEntry.getCategory())) @@ -154,26 +162,7 @@ private void setUpListeners() { Bindings.size(webView.getEngine().getHistory().getEntries()).subtract(1) )); - uris.getSelectionModel().selectedItemProperty().addListener((p, o, n) -> { - if (n != null && !webView.getEngine().getLocation().equals(n.toString())) { - webView.getEngine().load(n.toString()); - } - }); - - // Sometimes, redirection is not automatically performed - // (see https://github.com/qupath/qupath/pull/1513#issuecomment-2095553840) - // This code enforces redirection - webView.getEngine().documentProperty().addListener((p, o, n) -> { - if (n != null) { - Matcher redirectionMatcher = REDIRECTION_PATTERN.matcher(n.getDocumentElement().getTextContent()); - - if (redirectionMatcher.find() && redirectionMatcher.groupCount() > 0) { - changeLocation(webView.getEngine().getLocation(), redirectionMatcher.group(1)).ifPresent(newLocation -> - webView.getEngine().load(newLocation) - ); - } - } - }); + uris.getSelectionModel().selectedItemProperty().addListener(urisListener); } private void offset(int offset) { @@ -186,9 +175,14 @@ private void offset(int offset) { } private static String getName(URI uri) { - if ("jar".equals(uri.getScheme())) + if (UriUtils.doesUriLinkToWebsite(uri)) { + return uri.getHost(); + } + + if (UriUtils.doesUriLinkToJar(uri)) { uri = URI.create(uri.getRawSchemeSpecificPart()); - var path = Paths.get(uri); + } + Path path = Paths.get(uri); String name = path.getFileName().toString().toLowerCase(); // If we have index.html, we want to take the name of the parent @@ -215,17 +209,60 @@ private void updateSelectedUri(URI uri) { } if (closestUri != null) { + // Prevent urisListener from being triggered + uris.getSelectionModel().selectedItemProperty().removeListener(urisListener); + uris.getSelectionModel().select(closestUri); + + uris.getSelectionModel().selectedItemProperty().addListener(urisListener); } } - private static Optional changeLocation(String currentLocation, String newLocation) { - int index = currentLocation.lastIndexOf("/"); + private void updateWebViewContent(URI uri) { + if (uri != null && !webView.getEngine().getLocation().equals(uri.toString())) { + // Sometimes, redirection is not automatically performed + // (see https://github.com/qupath/qupath/pull/1513#issuecomment-2095553840) + // This code enforces redirection only if the file is local + if (UriUtils.doesUriLinkToWebsite(uri)) { + logger.debug("{} links to a website, so no redirection is checked", uri); - if (index == -1) { - return Optional.empty(); - } else { - return Optional.of(currentLocation.substring(0, currentLocation.lastIndexOf("/")) + "/" + newLocation); + webView.getEngine().load(uri.toString()); + } else { + UriUtils.getContentOfUri(uri).handle((body, error) -> { + if (body == null) { + logger.error("Error while getting content of {}. Cannot check for redirection", uri, error); + + Platform.runLater(() -> webView.getEngine().load(uri.toString())); + return null; + } + + Matcher redirectionMatcher = REDIRECTION_PATTERN.matcher(body); + if (redirectionMatcher.find() && redirectionMatcher.groupCount() > 0) { + String redirectionLink = redirectionMatcher.group(1); + logger.debug( + "Redirection detected from {} to {}. Attempting to create new link", + uri, + redirectionLink + ); + + Optional newLocation = changeLocation(uri.toString(), redirectionLink); + if (newLocation.isPresent()) { + logger.debug("New link created. Redirecting from {} to {}", uri, newLocation.get()); + + Platform.runLater(() -> webView.getEngine().load(newLocation.get())); + } else { + logger.debug("Cannot create new link from {} to {}. No redirection performed", uri, redirectionLink); + + Platform.runLater(() -> webView.getEngine().load(uri.toString())); + } + } else { + logger.debug("No redirection detected in body of {}. No redirection performed", uri); + + Platform.runLater(() -> webView.getEngine().load(uri.toString())); + } + return null; + }); + } } } @@ -240,4 +277,14 @@ private static int getNumberOfCommonCharactersFromBeginning(String text1, String return n; } + + private static Optional changeLocation(String currentLocation, String newLocation) { + int index = currentLocation.lastIndexOf("/"); + + if (index == -1) { + return Optional.empty(); + } else { + return Optional.of(currentLocation.substring(0, currentLocation.lastIndexOf("/")) + "/" + newLocation); + } + } }