feat: Enhance WebDAV file management and UI

- 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
This commit is contained in:
Mahmoud-Emad
2025-10-26 17:29:45 +03:00
parent 0ed6bcf1f2
commit f319f29d4c
20 changed files with 1679 additions and 113 deletions

View File

@@ -8,11 +8,11 @@ class WebDAVClient {
this.baseUrl = baseUrl;
this.currentCollection = null;
}
setCollection(collection) {
this.currentCollection = collection;
}
getFullUrl(path) {
if (!this.currentCollection) {
throw new Error('No collection selected');
@@ -20,7 +20,7 @@ class WebDAVClient {
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) {
@@ -28,7 +28,25 @@ class WebDAVClient {
}
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, {
@@ -38,37 +56,64 @@ class WebDAVClient {
'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, {
@@ -78,109 +123,144 @@ class WebDAVClient {
},
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}`);
@@ -191,32 +271,32 @@ class WebDAVClient {
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(),
@@ -224,14 +304,14 @@ class WebDAVClient {
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;
@@ -239,26 +319,26 @@ class WebDAVClient {
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;
}
}