/** * WebDAV Client * Handles all WebDAV protocol operations */ class WebDAVClient { constructor(baseUrl) { this.baseUrl = baseUrl; this.currentCollection = null; } setCollection(collection) { this.currentCollection = collection; } getFullUrl(path) { if (!this.currentCollection) { throw new Error('No collection selected'); } const cleanPath = path.startsWith('/') ? path.slice(1) : path; return `${this.baseUrl}${this.currentCollection}/${cleanPath}`; } async getCollections() { const response = await fetch(this.baseUrl); if (!response.ok) { throw new Error('Failed to get collections'); } return await response.json(); } async propfind(path = '', depth = '1') { const url = this.getFullUrl(path); const response = await fetch(url, { method: 'PROPFIND', headers: { 'Depth': depth, 'Content-Type': 'application/xml' } }); if (!response.ok) { throw new Error(`PROPFIND failed: ${response.statusText}`); } const xml = await response.text(); return this.parseMultiStatus(xml); } async get(path) { const url = this.getFullUrl(path); const response = await fetch(url); if (!response.ok) { throw new Error(`GET failed: ${response.statusText}`); } return await response.text(); } async getBinary(path) { const url = this.getFullUrl(path); const response = await fetch(url); if (!response.ok) { throw new Error(`GET failed: ${response.statusText}`); } return await response.blob(); } async put(path, content) { const url = this.getFullUrl(path); const response = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'text/plain' }, body: content }); if (!response.ok) { throw new Error(`PUT failed: ${response.statusText}`); } return true; } async putBinary(path, content) { const url = this.getFullUrl(path); const response = await fetch(url, { method: 'PUT', body: content }); if (!response.ok) { throw new Error(`PUT failed: ${response.statusText}`); } return true; } async delete(path) { const url = this.getFullUrl(path); const response = await fetch(url, { method: 'DELETE' }); if (!response.ok) { throw new Error(`DELETE failed: ${response.statusText}`); } return true; } async copy(sourcePath, destPath) { const sourceUrl = this.getFullUrl(sourcePath); const destUrl = this.getFullUrl(destPath); const response = await fetch(sourceUrl, { method: 'COPY', headers: { 'Destination': destUrl } }); if (!response.ok) { throw new Error(`COPY failed: ${response.statusText}`); } return true; } async move(sourcePath, destPath) { const sourceUrl = this.getFullUrl(sourcePath); const destUrl = this.getFullUrl(destPath); const response = await fetch(sourceUrl, { method: 'MOVE', headers: { 'Destination': destUrl } }); if (!response.ok) { throw new Error(`MOVE failed: ${response.statusText}`); } return true; } async mkcol(path) { const url = this.getFullUrl(path); const response = await fetch(url, { method: 'MKCOL' }); if (!response.ok && response.status !== 405) { // 405 means already exists throw new Error(`MKCOL failed: ${response.statusText}`); } return true; } async includeFile(path) { try { // Parse path: "collection:path/to/file" or "path/to/file" let targetCollection = this.currentCollection; let targetPath = path; if (path.includes(':')) { [targetCollection, targetPath] = path.split(':'); } // Temporarily switch collection const originalCollection = this.currentCollection; this.currentCollection = targetCollection; const content = await this.get(targetPath); // Restore collection this.currentCollection = originalCollection; return content; } catch (error) { throw new Error(`Cannot include file "${path}": ${error.message}`); } } parseMultiStatus(xml) { const parser = new DOMParser(); const doc = parser.parseFromString(xml, 'text/xml'); const responses = doc.getElementsByTagNameNS('DAV:', 'response'); const items = []; for (let i = 0; i < responses.length; i++) { const response = responses[i]; const href = response.getElementsByTagNameNS('DAV:', 'href')[0].textContent; const propstat = response.getElementsByTagNameNS('DAV:', 'propstat')[0]; const prop = propstat.getElementsByTagNameNS('DAV:', 'prop')[0]; // Check if it's a collection (directory) const resourcetype = prop.getElementsByTagNameNS('DAV:', 'resourcetype')[0]; const isDirectory = resourcetype.getElementsByTagNameNS('DAV:', 'collection').length > 0; // Get size const contentlengthEl = prop.getElementsByTagNameNS('DAV:', 'getcontentlength')[0]; const size = contentlengthEl ? parseInt(contentlengthEl.textContent) : 0; // Extract path relative to collection const pathParts = href.split(`/${this.currentCollection}/`); const relativePath = pathParts.length > 1 ? pathParts[1] : ''; // Skip the collection root itself if (!relativePath) continue; // Remove trailing slash from directories const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; items.push({ path: cleanPath, name: cleanPath.split('/').pop(), isDirectory, size }); } return items; } buildTree(items) { const root = []; const map = {}; // Sort items by path depth and name items.sort((a, b) => { const depthA = a.path.split('/').length; const depthB = b.path.split('/').length; if (depthA !== depthB) return depthA - depthB; return a.path.localeCompare(b.path); }); items.forEach(item => { const parts = item.path.split('/'); const parentPath = parts.slice(0, -1).join('/'); const node = { ...item, children: [] }; map[item.path] = node; if (parentPath && map[parentPath]) { map[parentPath].children.push(node); } else { root.push(node); } }); return root; } }