- Add functionality to create new collections via API - Implement copy and move operations between collections - Improve image rendering in markdown preview with relative path resolution - Add support for previewing binary files (images, PDFs) - Refactor modal styling to use flat buttons and improve accessibility
		
			
				
	
	
		
			346 lines
		
	
	
		
			9.9 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			346 lines
		
	
	
		
			9.9 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /**
 | |
|  * 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 createCollection(collectionName) {
 | |
|         // Use POST API to create collection (not MKCOL, as collections are managed by the server)
 | |
|         const response = await fetch(this.baseUrl, {
 | |
|             method: 'POST',
 | |
|             headers: {
 | |
|                 'Content-Type': 'application/json'
 | |
|             },
 | |
|             body: JSON.stringify({ name: collectionName })
 | |
|         });
 | |
| 
 | |
|         if (!response.ok) {
 | |
|             const errorData = await response.json().catch(() => ({ error: response.statusText }));
 | |
|             throw new Error(errorData.error || `Failed to create collection: ${response.status} ${response.statusText}`);
 | |
|         }
 | |
| 
 | |
|         return true;
 | |
|     }
 | |
| 
 | |
|     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);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * List files and directories in a path
 | |
|      * Returns only direct children (depth=1) to avoid infinite recursion
 | |
|      * @param {string} path - Path to list
 | |
|      * @param {boolean} recursive - If true, returns all nested items (depth=infinity)
 | |
|      * @returns {Promise<Array>} Array of items
 | |
|      */
 | |
|     async list(path = '', recursive = false) {
 | |
|         const depth = recursive ? 'infinity' : '1';
 | |
|         const items = await this.propfind(path, depth);
 | |
| 
 | |
|         // If not recursive, filter to only direct children
 | |
|         if (!recursive && path) {
 | |
|             // Normalize path (remove trailing slash)
 | |
|             const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path;
 | |
|             const pathDepth = normalizedPath.split('/').length;
 | |
| 
 | |
|             // Filter items to only include direct children
 | |
|             return items.filter(item => {
 | |
|                 const itemDepth = item.path.split('/').length;
 | |
|                 return itemDepth === pathDepth + 1;
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         return items;
 | |
|     }
 | |
| 
 | |
|     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;
 | |
|     }
 | |
| 
 | |
|     // Alias for mkcol
 | |
|     async createFolder(path) {
 | |
|         return await this.mkcol(path);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Ensure all parent directories exist for a given path
 | |
|      * Creates missing parent directories recursively
 | |
|      */
 | |
|     async ensureParentDirectories(filePath) {
 | |
|         const parts = filePath.split('/');
 | |
| 
 | |
|         // Remove the filename (last part)
 | |
|         parts.pop();
 | |
| 
 | |
|         // If no parent directories, nothing to do
 | |
|         if (parts.length === 0) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         // Create each parent directory level
 | |
|         let currentPath = '';
 | |
|         for (const part of parts) {
 | |
|             currentPath = currentPath ? `${currentPath}/${part}` : part;
 | |
| 
 | |
|             try {
 | |
|                 await this.mkcol(currentPath);
 | |
|             } catch (error) {
 | |
|                 // Ignore errors - directory might already exist
 | |
|                 // Only log for debugging
 | |
|                 console.debug(`Directory ${currentPath} might already exist:`, error.message);
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     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;
 | |
|     }
 | |
| }
 | |
| 
 |