feat(WebDAV): enhance listDirectory function for recursive listing and improve XML parsing

This commit is contained in:
Jaime Idolpx 2026-06-12 04:41:01 -04:00
parent 0786fd087e
commit 92a22fe253
2 changed files with 88 additions and 24 deletions

View File

@ -165,26 +165,72 @@ function toEntryInfo(e: WebDAVEntry, baseUrl: string): EntryInfo {
// ----- High-level operations ------------------------------------------- // ----- High-level operations -------------------------------------------
/** List the contents of a collection. Returns a sorted array of entries. */ /**
export async function listDirectory(path: string): Promise<EntryInfo[]> { * List the contents of a collection. Returns a sorted array of entries.
* Pass `recursive: true` to request `Depth: infinity` from the server and
* return all descendants flattened requires the server to support it
* (webdav3.py does after the depth-infinity fix).
*/
export async function listDirectory(path: string, recursive = false): Promise<EntryInfo[]> {
const manager = getWebDAVClient(); const manager = getWebDAVClient();
const base = manager.client.baseUrl; const base = manager.client.baseUrl;
// The manager's `open` requires an absolute URL on the configured server.
const collectionUrl = pathToUrl(normalizePath(path), base); const collectionUrl = pathToUrl(normalizePath(path), base);
const listing = await manager.open(collectionUrl);
const selfPath = normalizePath(path); const selfPath = normalizePath(path);
// Parse the PROPFIND response directly rather than going through
// parsePropfindListing, which keys entries by display name and would
// silently drop any entries that share the same filename. Parsing
// the XML ourselves also avoids mutating the shared WebDAVManager
// navigation state (navToken, this.files).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const depth: any = recursive ? 'infinity' : 1;
const doc = await manager.client.propfind(collectionUrl, PROPFIND_LIST_BODY, depth);
const entries: EntryInfo[] = []; const entries: EntryInfo[] = [];
for (const e of Object.values(listing)) {
if (!e || !e.name) continue; for (const node of Array.from(doc.querySelectorAll('response'))) {
const info = toEntryInfo(e, base); const hrefEl = node.querySelector('href');
// Filter out the listing root itself (it appears as an entry whose if (!hrefEl?.textContent) continue;
// path equals the requested collection) and any entries whose path
// doesn't sit directly under the requested collection. // Find the first 200 propstat.
if (info.path === selfPath) continue; let propsNode: Element | null = null;
const parent = splitPath(info.path).parent; for (const propstat of Array.from(node.querySelectorAll('propstat'))) {
if (parent !== selfPath) continue; const status = propstat.querySelector('status');
entries.push(info); if (status && /200/.test(status.textContent ?? '')) {
propsNode = propstat;
break;
}
}
if (!propsNode) continue;
const fullPath = pathFromUri(hrefEl.textContent, base);
// Skip the collection itself.
if (fullPath === selfPath) continue;
// Non-recursive: only include direct children.
if (!recursive && splitPath(fullPath).parent !== selfPath) continue;
const isDir = !!node.querySelector('resourcetype collection');
const displaynameEl = propsNode.querySelector('displayname');
const lengthEl = propsNode.querySelector('getcontentlength');
const mimeEl = propsNode.querySelector('getcontenttype');
const modifiedEl = propsNode.querySelector('getlastmodified');
// Prefer server displayname unless it looks like a full path.
const nameFromPath = basename(fullPath) || fullPath;
const dn = displaynameEl?.textContent ?? '';
const name = dn && !dn.includes('/') ? dn : nameFromPath;
entries.push({
name,
path: fullPath,
type: isDir ? 'folder' : 'file',
size: !isDir && lengthEl?.textContent ? parseInt(lengthEl.textContent, 10) : 0,
lastModified: modifiedEl?.textContent ? new Date(modifiedEl.textContent) : null,
contentType: !isDir && mimeEl?.textContent ? mimeEl.textContent : null,
});
} }
// Folders first, then alpha (case-insensitive). // Folders first, then alpha (case-insensitive).
entries.sort((a, b) => { entries.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1; if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
@ -334,9 +380,11 @@ export async function getFileRange(path: string, start: number, end: number): Pr
/** Convert a server-relative path to an absolute URL on the configured base. */ /** Convert a server-relative path to an absolute URL on the configured base. */
function pathToUrl(p: string, baseUrl: string): string { function pathToUrl(p: string, baseUrl: string): string {
// baseUrl may already end with `/`; we just want "<base><path>". // Encode each segment so spaces and special characters become valid URL
const sep = baseUrl.endsWith('/') || p.startsWith('/') ? '' : '/'; // components, while preserving the '/' separators.
return baseUrl + sep + p; const encoded = p.split('/').map(seg => seg ? encodeURIComponent(seg) : '').join('/');
const sep = baseUrl.endsWith('/') || encoded.startsWith('/') ? '' : '/';
return baseUrl + sep + encoded;
} }
/** Convert a raw byte count to a short human string (e.g. "1.4 MB"). */ /** Convert a raw byte count to a short human string (e.g. "1.4 MB"). */

View File

@ -710,13 +710,25 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
if (wp in props) == False: if (wp in props) == False:
w.write(' <D:%s/>\n' % wp) w.write(' <D:%s/>\n' % wp)
else: else:
w.write(' <D:%s>%s</D:%s>\n' % (wp, str(props[wp]), wp)) val = str(props[wp])
if wp != 'resourcetype':
val = val.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
w.write(' <D:%s>%s</D:%s>\n' % (wp, val, wp))
w.write('</D:prop>\n<D:status>HTTP/1.1 200 OK</D:status>\n</D:propstat>\n</D:response>\n') w.write('</D:prop>\n<D:status>HTTP/1.1 200 OK</D:status>\n</D:propstat>\n</D:response>\n')
def write_recursive(m):
write_props_member(w, m)
if m.type == Member.M_COLLECTION:
for child in m.getMembers():
write_recursive(child)
write_props_member(w, elem) write_props_member(w, elem)
if depth == '1' and elem.type == Member.M_COLLECTION: if depth == '1' and elem.type == Member.M_COLLECTION:
for m in elem.getMembers(): for m in elem.getMembers():
write_props_member(w,m) write_props_member(w, m)
elif depth == 'infinity' and elem.type == Member.M_COLLECTION:
for m in elem.getMembers():
write_recursive(m)
w.write('</D:multistatus>') w.write('</D:multistatus>')
self.send_header('Content-Length', str(w.getSize())) self.send_header('Content-Length', str(w.getSize()))
self.end_headers() self.end_headers()
@ -967,19 +979,23 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
class BufWriter: class BufWriter:
def __init__(self, w, debug=False): def __init__(self, w, debug=False):
self.w = w self.w = w
self.buf = '' self.parts = []
self._size = 0
self.debug = debug self.debug = debug
def write(self, s): def write(self, s):
self.buf += s b = s.encode('utf-8')
self.parts.append(b)
self._size += len(b)
def flush(self): def flush(self):
if self.debug: print('<- XML:', self.buf) # sys.stderr.write(self.buf) data = b''.join(self.parts)
self.w.write(self.buf.encode('utf-8')) if self.debug: print('<- XML:', data.decode('utf-8', errors='replace'))
self.w.write(data)
self.w.flush() self.w.flush()
def getSize(self): def getSize(self):
return len(self.buf.encode('utf-8')) return self._size
class DAVServer(ThreadingMixIn, HTTPServer): class DAVServer(ThreadingMixIn, HTTPServer):
def __init__(self, addr, handler, root, userpwd): def __init__(self, addr, handler, root, userpwd):