feat(WebDAV): enhance listDirectory function for recursive listing and improve XML parsing
This commit is contained in:
parent
0786fd087e
commit
92a22fe253
|
|
@ -165,26 +165,72 @@ function toEntryInfo(e: WebDAVEntry, baseUrl: string): EntryInfo {
|
|||
|
||||
// ----- 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 base = manager.client.baseUrl;
|
||||
// The manager's `open` requires an absolute URL on the configured server.
|
||||
const collectionUrl = pathToUrl(normalizePath(path), base);
|
||||
const listing = await manager.open(collectionUrl);
|
||||
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[] = [];
|
||||
for (const e of Object.values(listing)) {
|
||||
if (!e || !e.name) continue;
|
||||
const info = toEntryInfo(e, base);
|
||||
// Filter out the listing root itself (it appears as an entry whose
|
||||
// path equals the requested collection) and any entries whose path
|
||||
// doesn't sit directly under the requested collection.
|
||||
if (info.path === selfPath) continue;
|
||||
const parent = splitPath(info.path).parent;
|
||||
if (parent !== selfPath) continue;
|
||||
entries.push(info);
|
||||
|
||||
for (const node of Array.from(doc.querySelectorAll('response'))) {
|
||||
const hrefEl = node.querySelector('href');
|
||||
if (!hrefEl?.textContent) continue;
|
||||
|
||||
// Find the first 200 propstat.
|
||||
let propsNode: Element | null = null;
|
||||
for (const propstat of Array.from(node.querySelectorAll('propstat'))) {
|
||||
const status = propstat.querySelector('status');
|
||||
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).
|
||||
entries.sort((a, b) => {
|
||||
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. */
|
||||
function pathToUrl(p: string, baseUrl: string): string {
|
||||
// baseUrl may already end with `/`; we just want "<base><path>".
|
||||
const sep = baseUrl.endsWith('/') || p.startsWith('/') ? '' : '/';
|
||||
return baseUrl + sep + p;
|
||||
// Encode each segment so spaces and special characters become valid URL
|
||||
// components, while preserving the '/' separators.
|
||||
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"). */
|
||||
|
|
|
|||
30
webdav3.py
30
webdav3.py
|
|
@ -710,13 +710,25 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
|
|||
if (wp in props) == False:
|
||||
w.write(' <D:%s/>\n' % wp)
|
||||
else:
|
||||
w.write(' <D:%s>%s</D:%s>\n' % (wp, str(props[wp]), wp))
|
||||
val = str(props[wp])
|
||||
if wp != 'resourcetype':
|
||||
val = val.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
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')
|
||||
|
||||
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)
|
||||
if depth == '1' and elem.type == Member.M_COLLECTION:
|
||||
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>')
|
||||
self.send_header('Content-Length', str(w.getSize()))
|
||||
self.end_headers()
|
||||
|
|
@ -967,19 +979,23 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
|
|||
class BufWriter:
|
||||
def __init__(self, w, debug=False):
|
||||
self.w = w
|
||||
self.buf = ''
|
||||
self.parts = []
|
||||
self._size = 0
|
||||
self.debug = debug
|
||||
|
||||
def write(self, s):
|
||||
self.buf += s
|
||||
b = s.encode('utf-8')
|
||||
self.parts.append(b)
|
||||
self._size += len(b)
|
||||
|
||||
def flush(self):
|
||||
if self.debug: print('<- XML:', self.buf) # sys.stderr.write(self.buf)
|
||||
self.w.write(self.buf.encode('utf-8'))
|
||||
data = b''.join(self.parts)
|
||||
if self.debug: print('<- XML:', data.decode('utf-8', errors='replace'))
|
||||
self.w.write(data)
|
||||
self.w.flush()
|
||||
|
||||
def getSize(self):
|
||||
return len(self.buf.encode('utf-8'))
|
||||
return self._size
|
||||
|
||||
class DAVServer(ThreadingMixIn, HTTPServer):
|
||||
def __init__(self, addr, handler, root, userpwd):
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user