This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 582ae8d4af fix tooltips in webdav editor, add support for address book 
folders. fixes #7285 (#7286)
582ae8d4af is described below

commit 582ae8d4afd0567c8ac584a3bebf7a4bd047448e
Author: Bart Maertens <[email protected]>
AuthorDate: Mon Jun 15 12:45:41 2026 +0200

    fix tooltips in webdav editor, add support for address book folders. fixes 
#7285 (#7286)
---
 .../hop/vfs/webdav/HopRawWebDavFileSystem.java     |  12 +-
 .../hop/vfs/webdav/HopWireWebDavFileObject.java    | 205 +++++++++++++++++++++
 .../webdav/metadata/WebDavConnectionEditor.java    |   7 +-
 .../vfs/webdav/HopWireWebDavFileObjectTest.java    |  68 +++++++
 4 files changed, 287 insertions(+), 5 deletions(-)

diff --git 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/HopRawWebDavFileSystem.java
 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/HopRawWebDavFileSystem.java
index 05d817f0b9..19c1bcfb39 100644
--- 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/HopRawWebDavFileSystem.java
+++ 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/HopRawWebDavFileSystem.java
@@ -17,14 +17,17 @@
 package org.apache.hop.vfs.webdav;
 
 import org.apache.commons.vfs2.FileName;
+import org.apache.commons.vfs2.FileObject;
 import org.apache.commons.vfs2.FileSystemOptions;
+import org.apache.commons.vfs2.provider.AbstractFileName;
 import org.apache.commons.vfs2.provider.webdav4.Webdav4FileSystem;
 import org.apache.http.client.HttpClient;
 import org.apache.http.client.protocol.HttpClientContext;
 
 /**
- * Subclass solely to expose {@link Webdav4FileSystem}'s protected constructor 
from application
- * code.
+ * Exposes {@link Webdav4FileSystem}'s protected constructor from application 
code and produces
+ * {@link HopWireWebDavFileObject}s so extended DAV collections (CardDAV 
address books, CalDAV
+ * calendars) are typed correctly.
  */
 final class HopRawWebDavFileSystem extends Webdav4FileSystem {
 
@@ -35,4 +38,9 @@ final class HopRawWebDavFileSystem extends Webdav4FileSystem {
       HttpClientContext httpClientContext) {
     super(rootName, fileSystemOptions, httpClient, httpClientContext);
   }
+
+  @Override
+  protected FileObject createFile(AbstractFileName name) throws Exception {
+    return new HopWireWebDavFileObject(name, this);
+  }
 }
diff --git 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/HopWireWebDavFileObject.java
 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/HopWireWebDavFileObject.java
new file mode 100644
index 0000000000..706c6a6894
--- /dev/null
+++ 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/HopWireWebDavFileObject.java
@@ -0,0 +1,205 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.vfs.webdav;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.vfs2.FileNotFolderException;
+import org.apache.commons.vfs2.FileNotFoundException;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.commons.vfs2.FileSystemException;
+import org.apache.commons.vfs2.FileType;
+import org.apache.commons.vfs2.NameScope;
+import org.apache.commons.vfs2.provider.AbstractFileName;
+import org.apache.commons.vfs2.provider.webdav4.Webdav4FileObject;
+import org.apache.commons.vfs2.provider.webdav4.Webdav4FileSystem;
+import org.apache.http.HttpStatus;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.util.EntityUtils;
+import org.apache.jackrabbit.webdav.DavConstants;
+import org.apache.jackrabbit.webdav.DavException;
+import org.apache.jackrabbit.webdav.MultiStatusResponse;
+import org.apache.jackrabbit.webdav.client.methods.HttpPropfind;
+import org.apache.jackrabbit.webdav.property.DavProperty;
+import org.apache.jackrabbit.webdav.property.DavPropertyName;
+import org.apache.jackrabbit.webdav.property.DavPropertyNameSet;
+import org.apache.jackrabbit.webdav.property.DavPropertySet;
+import org.w3c.dom.Node;
+
+/**
+ * Wire WebDAV4 file object with a {@code DAV:resourcetype} check that 
supports extended
+ * collections.
+ *
+ * <p>Stock {@link Webdav4FileObject} casts the {@code resourcetype} property 
value to a single
+ * {@link Node}. CardDAV address books and CalDAV calendars (e.g. Nextcloud 
{@code
+ * /remote.php/dav/addressbooks/…}) report multiple resource types ({@code
+ * <collection/><card:addressbook/>}), for which Jackrabbit yields a {@code 
List<Node>} instead. The
+ * stock cast then fails with {@link ClassCastException} and VFS reports 
"Could not determine the
+ * type of file". This subclass inspects all resource type nodes, so such 
collections behave as
+ * regular folders.
+ */
+final class HopWireWebDavFileObject extends Webdav4FileObject {
+
+  HopWireWebDavFileObject(AbstractFileName name, Webdav4FileSystem fileSystem)
+      throws FileSystemException {
+    super(name, fileSystem);
+  }
+
+  @Override
+  protected FileType doGetType() throws Exception {
+    try {
+      return isDavCollection() ? FileType.FOLDER : FileType.FILE;
+    } catch (FileNotFoundException | FileNotFolderException e) {
+      return FileType.IMAGINARY;
+    }
+  }
+
+  @Override
+  protected FileObject[] doListChildrenResolved() throws Exception {
+    try {
+      if (!isDavCollection()) {
+        throw new FileNotFolderException(getName());
+      }
+      DavPropertyNameSet nameSet = new DavPropertyNameSet();
+      nameSet.add(DavPropertyName.create(DavConstants.PROPERTY_DISPLAYNAME));
+      HttpPropfind request =
+          new HttpPropfind(getInternalURI().toString(), nameSet, 
DavConstants.DEPTH_1);
+      try (CloseableHttpResponse res = executePropfind(request)) {
+        List<FileObject> children = new ArrayList<>();
+        MultiStatusResponse[] responses = 
request.getResponseBodyAsMultiStatus(res).getResponses();
+        for (MultiStatusResponse response : responses) {
+          if (isSelf(response.getHref())) {
+            continue;
+          }
+          String resourceName = lastPathSegment(response.getHref());
+          if (!resourceName.isEmpty()) {
+            children.add(
+                getFileSystem()
+                    .resolveFile(
+                        getFileSystem()
+                            .getFileSystemManager()
+                            .resolveName(getName(), resourceName, 
NameScope.CHILD)));
+          }
+        }
+        return children.toArray(new FileObject[0]);
+      }
+    } catch (FileNotFoundException e) {
+      throw new FileNotFolderException(getName(), e);
+    } catch (FileNotFolderException e) {
+      throw e;
+    } catch (DavException | IOException e) {
+      throw new FileSystemException(e.getMessage(), e);
+    }
+  }
+
+  /** PROPFIND Depth:0 for {@code resourcetype}; true when any reported type 
is a collection. */
+  private boolean isDavCollection() throws IOException, DavException {
+    DavPropertyNameSet nameSet = new DavPropertyNameSet();
+    nameSet.add(DavPropertyName.create(DavConstants.PROPERTY_RESOURCETYPE));
+    HttpPropfind request =
+        new HttpPropfind(getInternalURI().toString(), nameSet, 
DavConstants.DEPTH_0);
+    try (CloseableHttpResponse res = executePropfind(request)) {
+      MultiStatusResponse[] responses = 
request.getResponseBodyAsMultiStatus(res).getResponses();
+      if (responses.length == 0) {
+        return false;
+      }
+      DavPropertySet props = responses[0].getProperties(HttpStatus.SC_OK);
+      DavProperty<?> resourceType =
+          props.get(DavConstants.PROPERTY_RESOURCETYPE, 
DavConstants.NAMESPACE);
+      return resourceTypeIndicatesCollection(resourceType == null ? null : 
resourceType.getValue());
+    }
+  }
+
+  /**
+   * Jackrabbit materializes {@code resourcetype} as {@code null} (no 
content), a single {@link
+   * Node}, or a {@code List<Node>} when the collection advertises several 
types.
+   */
+  static boolean resourceTypeIndicatesCollection(Object propertyValue) {
+    if (propertyValue instanceof Node node) {
+      return isCollectionElement(node);
+    }
+    if (propertyValue instanceof Iterable<?> nodes) {
+      for (Object item : nodes) {
+        if (item instanceof Node node && isCollectionElement(node)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  private static boolean isCollectionElement(Node node) {
+    return DavConstants.XML_COLLECTION.equals(node.getLocalName());
+  }
+
+  private CloseableHttpResponse executePropfind(HttpPropfind request)
+      throws IOException, DavException {
+    request.addHeader("Cache-control", "no-cache");
+    request.addHeader("Pragma", "no-cache");
+    CloseableHttpResponse res = (CloseableHttpResponse) 
executeHttpUriRequest(request);
+    boolean usable = false;
+    try {
+      int status = res.getStatusLine().getStatusCode();
+      if (status == HttpURLConnection.HTTP_NOT_FOUND || status == 
HttpURLConnection.HTTP_GONE) {
+        EntityUtils.consume(res.getEntity());
+        throw new FileNotFoundException(getName());
+      }
+      request.checkSuccess(res);
+      usable = true;
+      return res;
+    } finally {
+      if (!usable) {
+        res.close();
+      }
+    }
+  }
+
+  /** Multistatus hrefs may be absolute URLs or absolute paths, with or 
without trailing slash. */
+  private boolean isSelf(String href) {
+    return stripTrailingSlash(decodedPath(href))
+        .equals(stripTrailingSlash(getInternalURI().getPath()));
+  }
+
+  private static String decodedPath(String href) {
+    try {
+      String path = URI.create(href).getPath();
+      return path != null ? path : href;
+    } catch (IllegalArgumentException e) {
+      return href;
+    }
+  }
+
+  private static String stripTrailingSlash(String path) {
+    String p = path;
+    while (p.length() > 1 && p.endsWith("/")) {
+      p = p.substring(0, p.length() - 1);
+    }
+    return p;
+  }
+
+  private static String lastPathSegment(String href) {
+    String path = href;
+    if (path.endsWith("/")) {
+      path = path.substring(0, path.length() - 1);
+    }
+    int i = path.lastIndexOf('/');
+    return i >= 0 ? path.substring(i + 1) : path;
+  }
+}
diff --git 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnectionEditor.java
 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnectionEditor.java
index 349fa4719f..6f6c9d6fbc 100644
--- 
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnectionEditor.java
+++ 
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnectionEditor.java
@@ -104,7 +104,7 @@ public class WebDavConnectionEditor extends 
MetadataEditor<WebDavConnection> {
     fdlRootUrl.left = new FormAttachment(0, 0);
     fdlRootUrl.right = new FormAttachment(middle, -margin);
     wlRootUrl.setLayoutData(fdlRootUrl);
-    wRootUrl = new TextVar(getVariables(), parent, SWT.SINGLE | SWT.LEFT | 
SWT.BORDER);
+    wRootUrl = new TextVar(manager.getVariables(), parent, SWT.SINGLE | 
SWT.LEFT | SWT.BORDER);
     PropsUi.setLook(wRootUrl);
     FormData fdRootUrl = new FormData();
     fdRootUrl.top = new FormAttachment(wlRootUrl, 0, SWT.CENTER);
@@ -121,7 +121,7 @@ public class WebDavConnectionEditor extends 
MetadataEditor<WebDavConnection> {
     fdlUsername.left = new FormAttachment(0, 0);
     fdlUsername.right = new FormAttachment(middle, -margin);
     wlUsername.setLayoutData(fdlUsername);
-    wUsername = new TextVar(getVariables(), parent, SWT.SINGLE | SWT.LEFT | 
SWT.BORDER);
+    wUsername = new TextVar(manager.getVariables(), parent, SWT.SINGLE | 
SWT.LEFT | SWT.BORDER);
     PropsUi.setLook(wUsername);
     FormData fdUsername = new FormData();
     fdUsername.top = new FormAttachment(wlUsername, 0, SWT.CENTER);
@@ -138,7 +138,8 @@ public class WebDavConnectionEditor extends 
MetadataEditor<WebDavConnection> {
     fdlPassword.left = new FormAttachment(0, 0);
     fdlPassword.right = new FormAttachment(middle, -margin);
     wlPassword.setLayoutData(fdlPassword);
-    wPassword = new PasswordTextVar(getVariables(), parent, SWT.SINGLE | 
SWT.LEFT | SWT.BORDER);
+    wPassword =
+        new PasswordTextVar(manager.getVariables(), parent, SWT.SINGLE | 
SWT.LEFT | SWT.BORDER);
     PropsUi.setLook(wPassword);
     FormData fdPassword = new FormData();
     fdPassword.top = new FormAttachment(wlPassword, 0, SWT.CENTER);
diff --git 
a/plugins/tech/webdav/src/test/java/org/apache/hop/vfs/webdav/HopWireWebDavFileObjectTest.java
 
b/plugins/tech/webdav/src/test/java/org/apache/hop/vfs/webdav/HopWireWebDavFileObjectTest.java
new file mode 100644
index 0000000000..195fb0bfe5
--- /dev/null
+++ 
b/plugins/tech/webdav/src/test/java/org/apache/hop/vfs/webdav/HopWireWebDavFileObjectTest.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.vfs.webdav;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import javax.xml.parsers.DocumentBuilderFactory;
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+
+class HopWireWebDavFileObjectTest {
+
+  private static Element davElement(String localName) throws Exception {
+    DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+    factory.setNamespaceAware(true);
+    Document doc = factory.newDocumentBuilder().newDocument();
+    return doc.createElementNS("DAV:", "d:" + localName);
+  }
+
+  private static Element cardDavElement(String localName) throws Exception {
+    DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+    factory.setNamespaceAware(true);
+    Document doc = factory.newDocumentBuilder().newDocument();
+    return doc.createElementNS("urn:ietf:params:xml:ns:carddav", "card:" + 
localName);
+  }
+
+  @Test
+  void plainCollection_singleNodeValue() throws Exception {
+    
assertTrue(HopWireWebDavFileObject.resourceTypeIndicatesCollection(davElement("collection")));
+  }
+
+  @Test
+  void nextcloudAddressbook_listValueWithCollectionAndAddressbook() throws 
Exception {
+    List<Node> value = List.of(davElement("collection"), 
cardDavElement("addressbook"));
+    assertTrue(HopWireWebDavFileObject.resourceTypeIndicatesCollection(value));
+  }
+
+  @Test
+  void plainFile_emptyResourceType() throws Exception {
+    assertFalse(HopWireWebDavFileObject.resourceTypeIndicatesCollection(null));
+    
assertFalse(HopWireWebDavFileObject.resourceTypeIndicatesCollection(List.of()));
+  }
+
+  @Test
+  void nonCollectionResourceType_isNotAFolder() throws Exception {
+    assertFalse(
+        HopWireWebDavFileObject.resourceTypeIndicatesCollection(
+            List.of(cardDavElement("addressbook"))));
+  }
+}

Reply via email to