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:
183
static/js/app.js
183
static/js/app.js
@@ -208,10 +208,17 @@ async function loadFileFromURL(collection, filePath) {
|
||||
await showDirectoryPreview(filePath);
|
||||
fileTree.selectAndExpandPath(filePath);
|
||||
} else if (node) {
|
||||
// It's a file, load it
|
||||
// It's a file, check if it's binary
|
||||
console.log('[loadFileFromURL] Loading file');
|
||||
await editor.loadFile(filePath);
|
||||
fileTree.selectAndExpandPath(filePath);
|
||||
|
||||
// Use the fileTree.onFileSelect callback to handle both text and binary files
|
||||
if (fileTree.onFileSelect) {
|
||||
fileTree.onFileSelect({ path: filePath, isDirectory: false });
|
||||
} else {
|
||||
// Fallback to direct loading
|
||||
await editor.loadFile(filePath);
|
||||
fileTree.selectAndExpandPath(filePath);
|
||||
}
|
||||
} else {
|
||||
console.error(`[loadFileFromURL] Path not found in file tree: ${filePath}`);
|
||||
}
|
||||
@@ -269,6 +276,37 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
collectionSelector = new CollectionSelector('collectionSelect', webdavClient);
|
||||
await collectionSelector.load();
|
||||
|
||||
// Setup New Collection button
|
||||
document.getElementById('newCollectionBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
const collectionName = await window.ModalManager.prompt(
|
||||
'Enter new collection name (lowercase, underscore only):',
|
||||
'new_collection'
|
||||
);
|
||||
|
||||
if (!collectionName) return;
|
||||
|
||||
// Validate collection name
|
||||
const validation = ValidationUtils.validateFileName(collectionName, true);
|
||||
if (!validation.valid) {
|
||||
window.showNotification(validation.message, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the collection
|
||||
await webdavClient.createCollection(validation.sanitized);
|
||||
|
||||
// Reload collections and switch to the new one
|
||||
await collectionSelector.load();
|
||||
await collectionSelector.setCollection(validation.sanitized);
|
||||
|
||||
window.showNotification(`Collection "${validation.sanitized}" created`, 'success');
|
||||
} catch (error) {
|
||||
Logger.error('Failed to create collection:', error);
|
||||
window.showNotification('Failed to create collection', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Setup URL routing
|
||||
setupPopStateListener();
|
||||
|
||||
@@ -281,11 +319,102 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
fileTree = new FileTree('fileTree', webdavClient);
|
||||
fileTree.onFileSelect = async (item) => {
|
||||
try {
|
||||
const currentCollection = collectionSelector.getCurrentCollection();
|
||||
|
||||
// Check if the file is a binary/non-editable file
|
||||
if (PathUtils.isBinaryFile(item.path)) {
|
||||
const fileType = PathUtils.getFileType(item.path);
|
||||
const fileName = PathUtils.getFileName(item.path);
|
||||
|
||||
Logger.info(`Previewing binary file: ${item.path}`);
|
||||
|
||||
// Set flag to prevent auto-update of preview
|
||||
editor.isShowingCustomPreview = true;
|
||||
|
||||
// In edit mode, show a warning notification
|
||||
if (isEditMode) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(
|
||||
`"${fileName}" is read-only. Showing preview only.`,
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
// Hide the editor pane temporarily
|
||||
const editorPane = document.getElementById('editorPane');
|
||||
const resizer1 = document.getElementById('resizer1');
|
||||
if (editorPane) editorPane.style.display = 'none';
|
||||
if (resizer1) resizer1.style.display = 'none';
|
||||
}
|
||||
|
||||
// Clear the editor (but don't trigger preview update due to flag)
|
||||
if (editor.editor) {
|
||||
editor.editor.setValue('');
|
||||
}
|
||||
editor.filenameInput.value = item.path;
|
||||
editor.currentFile = item.path;
|
||||
|
||||
// Build the file URL using the WebDAV client's method
|
||||
const fileUrl = webdavClient.getFullUrl(item.path);
|
||||
Logger.debug(`Binary file URL: ${fileUrl}`);
|
||||
|
||||
// Generate preview HTML based on file type
|
||||
let previewHtml = '';
|
||||
|
||||
if (fileType === 'Image') {
|
||||
// Preview images
|
||||
previewHtml = `
|
||||
<div style="padding: 20px; text-align: center;">
|
||||
<h3>${fileName}</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 20px;">Image Preview (Read-only)</p>
|
||||
<img src="${fileUrl}" alt="${fileName}" style="max-width: 100%; height: auto; border: 1px solid var(--border-color); border-radius: 4px;">
|
||||
</div>
|
||||
`;
|
||||
} else if (fileType === 'PDF') {
|
||||
// Preview PDFs
|
||||
previewHtml = `
|
||||
<div style="padding: 20px;">
|
||||
<h3>${fileName}</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 20px;">PDF Preview (Read-only)</p>
|
||||
<iframe src="${fileUrl}" style="width: 100%; height: 80vh; border: 1px solid var(--border-color); border-radius: 4px;"></iframe>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// For other binary files, show download link
|
||||
previewHtml = `
|
||||
<div style="padding: 20px;">
|
||||
<h3>${fileName}</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 20px;">${fileType} File (Read-only)</p>
|
||||
<p>This file cannot be previewed in the browser.</p>
|
||||
<a href="${fileUrl}" download="${fileName}" class="btn btn-primary">Download ${fileName}</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Display in preview pane
|
||||
editor.previewElement.innerHTML = previewHtml;
|
||||
|
||||
// Highlight the file in the tree
|
||||
fileTree.selectAndExpandPath(item.path);
|
||||
|
||||
// Update URL to reflect current file
|
||||
updateURL(currentCollection, item.path, isEditMode);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For text files, restore the editor pane if it was hidden
|
||||
if (isEditMode) {
|
||||
const editorPane = document.getElementById('editorPane');
|
||||
const resizer1 = document.getElementById('resizer1');
|
||||
if (editorPane) editorPane.style.display = '';
|
||||
if (resizer1) resizer1.style.display = '';
|
||||
}
|
||||
|
||||
await editor.loadFile(item.path);
|
||||
// Highlight the file in the tree and expand parent directories
|
||||
fileTree.selectAndExpandPath(item.path);
|
||||
// Update URL to reflect current file
|
||||
const currentCollection = collectionSelector.getCurrentCollection();
|
||||
updateURL(currentCollection, item.path, isEditMode);
|
||||
} catch (error) {
|
||||
Logger.error('Failed to select file:', error);
|
||||
@@ -332,9 +461,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
const { collection: urlCollection, filePath: urlFilePath } = parseURLPath();
|
||||
console.log('[URL PARSE]', { urlCollection, urlFilePath });
|
||||
|
||||
if (urlCollection && urlFilePath) {
|
||||
console.log('[URL LOAD] Loading from URL:', urlCollection, urlFilePath);
|
||||
|
||||
if (urlCollection) {
|
||||
// First ensure the collection is set
|
||||
const currentCollection = collectionSelector.getCurrentCollection();
|
||||
if (currentCollection !== urlCollection) {
|
||||
@@ -343,11 +470,17 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
await fileTree.load();
|
||||
}
|
||||
|
||||
// Now load the file from URL
|
||||
console.log('[URL LOAD] Calling loadFileFromURL');
|
||||
await loadFileFromURL(urlCollection, urlFilePath);
|
||||
// If there's a file path in the URL, load it
|
||||
if (urlFilePath) {
|
||||
console.log('[URL LOAD] Loading file from URL:', urlCollection, urlFilePath);
|
||||
await loadFileFromURL(urlCollection, urlFilePath);
|
||||
} else if (!isEditMode) {
|
||||
// Collection-only URL in view mode: auto-load last viewed page
|
||||
console.log('[URL LOAD] Collection-only URL, auto-loading page');
|
||||
await autoLoadPageInViewMode();
|
||||
}
|
||||
} else if (!isEditMode) {
|
||||
// In view mode, auto-load last viewed page if no URL file specified
|
||||
// No URL collection specified, in view mode: auto-load last viewed page
|
||||
await autoLoadPageInViewMode();
|
||||
}
|
||||
|
||||
@@ -405,11 +538,34 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
// Initialize file tree actions manager
|
||||
window.fileTreeActions = new FileTreeActions(webdavClient, fileTree, editor);
|
||||
|
||||
// Setup Exit Edit Mode button
|
||||
document.getElementById('exitEditModeBtn').addEventListener('click', () => {
|
||||
// Switch to view mode by removing edit=true from URL
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('edit');
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
|
||||
// Hide Edit Mode button in edit mode
|
||||
document.getElementById('editModeBtn').style.display = 'none';
|
||||
} else {
|
||||
// In view mode, hide editor buttons
|
||||
document.getElementById('newBtn').style.display = 'none';
|
||||
document.getElementById('saveBtn').style.display = 'none';
|
||||
document.getElementById('deleteBtn').style.display = 'none';
|
||||
document.getElementById('exitEditModeBtn').style.display = 'none';
|
||||
|
||||
// Show Edit Mode button in view mode
|
||||
document.getElementById('editModeBtn').style.display = 'block';
|
||||
|
||||
// Setup Edit Mode button
|
||||
document.getElementById('editModeBtn').addEventListener('click', () => {
|
||||
// Switch to edit mode by adding edit=true to URL
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('edit', 'true');
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
|
||||
// Auto-load last viewed page or first file
|
||||
await autoLoadPageInViewMode();
|
||||
@@ -498,10 +654,11 @@ async function handleEditorFileDrop(file) {
|
||||
const uploadedPath = await fileTree.uploadFile(targetDir, file);
|
||||
|
||||
// Insert markdown link at cursor
|
||||
// Use relative path (without collection name) so the image renderer can resolve it correctly
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const link = isImage
|
||||
? ``
|
||||
: `[${file.name}](/${webdavClient.currentCollection}/${uploadedPath})`;
|
||||
? ``
|
||||
: `[${file.name}](${uploadedPath})`;
|
||||
|
||||
editor.insertAtCursor(link);
|
||||
showNotification(`Uploaded and inserted link`, 'success');
|
||||
|
||||
@@ -26,12 +26,21 @@ class CollectionSelector {
|
||||
this.select.appendChild(option);
|
||||
});
|
||||
|
||||
// Try to restore previously selected collection from localStorage
|
||||
const savedCollection = localStorage.getItem(this.storageKey);
|
||||
// Determine which collection to select (priority: URL > localStorage > first)
|
||||
let collectionToSelect = collections[0]; // Default to first
|
||||
|
||||
if (savedCollection && collections.includes(savedCollection)) {
|
||||
collectionToSelect = savedCollection;
|
||||
// Check URL first (highest priority)
|
||||
const urlCollection = this.getCollectionFromURL();
|
||||
if (urlCollection && collections.includes(urlCollection)) {
|
||||
collectionToSelect = urlCollection;
|
||||
Logger.info(`Using collection from URL: ${urlCollection}`);
|
||||
} else {
|
||||
// Fall back to localStorage
|
||||
const savedCollection = localStorage.getItem(this.storageKey);
|
||||
if (savedCollection && collections.includes(savedCollection)) {
|
||||
collectionToSelect = savedCollection;
|
||||
Logger.info(`Using collection from localStorage: ${savedCollection}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (collections.length > 0) {
|
||||
@@ -48,14 +57,17 @@ class CollectionSelector {
|
||||
// Save to localStorage
|
||||
localStorage.setItem(this.storageKey, collection);
|
||||
this.webdavClient.setCollection(collection);
|
||||
|
||||
|
||||
Logger.info(`Collection changed to: ${collection}`);
|
||||
|
||||
|
||||
// Update URL to reflect collection change
|
||||
this.updateURLForCollection(collection);
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(collection);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Logger.debug(`Loaded ${collections.length} collections`);
|
||||
} catch (error) {
|
||||
Logger.error('Failed to load collections:', error);
|
||||
@@ -83,9 +95,12 @@ class CollectionSelector {
|
||||
this.select.value = collection;
|
||||
localStorage.setItem(this.storageKey, collection);
|
||||
this.webdavClient.setCollection(collection);
|
||||
|
||||
|
||||
Logger.info(`Collection set to: ${collection}`);
|
||||
|
||||
|
||||
// Update URL to reflect collection change
|
||||
this.updateURLForCollection(collection);
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(collection);
|
||||
}
|
||||
@@ -93,6 +108,43 @@ class CollectionSelector {
|
||||
Logger.warn(`Collection "${collection}" not found in available collections`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the browser URL to reflect the current collection
|
||||
* @param {string} collection - The collection name
|
||||
*/
|
||||
updateURLForCollection(collection) {
|
||||
// Get current URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isEditMode = urlParams.get('edit') === 'true';
|
||||
|
||||
// Build new URL with collection
|
||||
let url = `/${collection}/`;
|
||||
if (isEditMode) {
|
||||
url += '?edit=true';
|
||||
}
|
||||
|
||||
// Use pushState to update URL without reloading
|
||||
window.history.pushState({ collection, filePath: null }, '', url);
|
||||
Logger.debug(`Updated URL to: ${url}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract collection name from current URL
|
||||
* URL format: /<collection>/ or /<collection>/<file_path>
|
||||
* @returns {string|null} The collection name or null if not found
|
||||
*/
|
||||
getCollectionFromURL() {
|
||||
const pathname = window.location.pathname;
|
||||
const parts = pathname.split('/').filter(p => p); // Remove empty parts
|
||||
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// First part is the collection
|
||||
return parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Make CollectionSelector globally available
|
||||
|
||||
@@ -49,11 +49,11 @@ class ModalManager {
|
||||
|
||||
// Update button styling based on danger level
|
||||
if (isDangerous) {
|
||||
this.confirmButton.className = 'btn btn-danger';
|
||||
this.confirmButton.textContent = 'Delete';
|
||||
this.confirmButton.className = 'btn-flat btn-flat-danger';
|
||||
this.confirmButton.innerHTML = '<i class="bi bi-trash"></i> Delete';
|
||||
} else {
|
||||
this.confirmButton.className = 'btn btn-primary';
|
||||
this.confirmButton.textContent = 'OK';
|
||||
this.confirmButton.className = 'btn-flat btn-flat-primary';
|
||||
this.confirmButton.innerHTML = '<i class="bi bi-check-circle"></i> OK';
|
||||
}
|
||||
|
||||
// Set up event handlers
|
||||
@@ -74,6 +74,8 @@ class ModalManager {
|
||||
|
||||
// Focus confirm button after modal is shown
|
||||
this.modalElement.addEventListener('shown.bs.modal', () => {
|
||||
// Ensure aria-hidden is removed (Bootstrap should do this, but be explicit)
|
||||
this.modalElement.removeAttribute('aria-hidden');
|
||||
this.confirmButton.focus();
|
||||
}, { once: true });
|
||||
});
|
||||
@@ -103,8 +105,8 @@ class ModalManager {
|
||||
this.inputElement.value = defaultValue;
|
||||
|
||||
// Reset button to primary style for prompts
|
||||
this.confirmButton.className = 'btn btn-primary';
|
||||
this.confirmButton.textContent = 'OK';
|
||||
this.confirmButton.className = 'btn-flat btn-flat-primary';
|
||||
this.confirmButton.innerHTML = '<i class="bi bi-check-circle"></i> OK';
|
||||
|
||||
// Set up event handlers
|
||||
this.confirmButton.onclick = (e) => {
|
||||
@@ -132,6 +134,8 @@ class ModalManager {
|
||||
|
||||
// Focus and select input after modal is shown
|
||||
this.modalElement.addEventListener('shown.bs.modal', () => {
|
||||
// Ensure aria-hidden is removed (Bootstrap should do this, but be explicit)
|
||||
this.modalElement.removeAttribute('aria-hidden');
|
||||
this.inputElement.focus();
|
||||
this.inputElement.select();
|
||||
}, { once: true });
|
||||
@@ -161,6 +165,11 @@ class ModalManager {
|
||||
this.currentResolver = null;
|
||||
this.isShowing = false;
|
||||
this.modal.hide();
|
||||
|
||||
// Restore aria-hidden after modal is hidden
|
||||
this.modalElement.addEventListener('hidden.bs.modal', () => {
|
||||
this.modalElement.setAttribute('aria-hidden', 'true');
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class MarkdownEditor {
|
||||
this.lastViewedStorageKey = 'lastViewedPage'; // localStorage key for tracking last viewed page
|
||||
this.readOnly = readOnly; // Whether editor is in read-only mode
|
||||
this.editor = null; // Will be initialized later
|
||||
this.isShowingCustomPreview = false; // Flag to prevent auto-update when showing binary files
|
||||
|
||||
// Only initialize CodeMirror if not in read-only mode (view mode)
|
||||
if (!readOnly) {
|
||||
@@ -87,9 +88,88 @@ class MarkdownEditor {
|
||||
initMarkdown() {
|
||||
if (window.marked) {
|
||||
this.marked = window.marked;
|
||||
|
||||
// Create custom renderer for images
|
||||
const renderer = new marked.Renderer();
|
||||
|
||||
renderer.image = (token) => {
|
||||
// Handle both old API (string params) and new API (token object)
|
||||
let href, title, text;
|
||||
|
||||
if (typeof token === 'object' && token !== null) {
|
||||
// New API: token is an object
|
||||
href = token.href || '';
|
||||
title = token.title || '';
|
||||
text = token.text || '';
|
||||
} else {
|
||||
// Old API: separate parameters (href, title, text)
|
||||
href = arguments[0] || '';
|
||||
title = arguments[1] || '';
|
||||
text = arguments[2] || '';
|
||||
}
|
||||
|
||||
// Ensure all are strings
|
||||
href = String(href || '');
|
||||
title = String(title || '');
|
||||
text = String(text || '');
|
||||
|
||||
Logger.debug(`Image renderer called with href="${href}", title="${title}", text="${text}"`);
|
||||
|
||||
// Check if href contains binary data (starts with non-printable characters)
|
||||
if (href && href.length > 100 && /^[\x00-\x1F\x7F-\xFF]/.test(href)) {
|
||||
Logger.error('Image href contains binary data - this should not happen!');
|
||||
Logger.error('First 50 chars:', href.substring(0, 50));
|
||||
// Return a placeholder image
|
||||
return `<div class="alert alert-warning">⚠️ Invalid image data detected. Please re-upload the image.</div>`;
|
||||
}
|
||||
|
||||
// Fix relative image paths to use WebDAV base URL
|
||||
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) {
|
||||
// Get the directory of the current file
|
||||
const currentDir = this.currentFile ? PathUtils.getParentPath(this.currentFile) : '';
|
||||
|
||||
// Resolve relative path
|
||||
let imagePath = href;
|
||||
if (href.startsWith('./')) {
|
||||
// Relative to current directory
|
||||
imagePath = PathUtils.joinPaths(currentDir, href.substring(2));
|
||||
} else if (href.startsWith('../')) {
|
||||
// Relative to parent directory
|
||||
imagePath = PathUtils.joinPaths(currentDir, href);
|
||||
} else if (!href.startsWith('/')) {
|
||||
// Relative to current directory (no ./)
|
||||
imagePath = PathUtils.joinPaths(currentDir, href);
|
||||
} else {
|
||||
// Absolute path from collection root
|
||||
imagePath = href.substring(1); // Remove leading /
|
||||
}
|
||||
|
||||
// Build WebDAV URL - ensure no double slashes
|
||||
if (this.webdavClient && this.webdavClient.currentCollection) {
|
||||
// Remove trailing slash from baseUrl if present
|
||||
const baseUrl = this.webdavClient.baseUrl.endsWith('/')
|
||||
? this.webdavClient.baseUrl.slice(0, -1)
|
||||
: this.webdavClient.baseUrl;
|
||||
|
||||
// Ensure imagePath doesn't start with /
|
||||
const cleanImagePath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath;
|
||||
|
||||
href = `${baseUrl}/${this.webdavClient.currentCollection}/${cleanImagePath}`;
|
||||
|
||||
Logger.debug(`Resolved image URL: ${href}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate HTML directly
|
||||
const titleAttr = title ? ` title="${title}"` : '';
|
||||
const altAttr = text ? ` alt="${text}"` : '';
|
||||
return `<img src="${href}"${altAttr}${titleAttr}>`;
|
||||
};
|
||||
|
||||
this.marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
renderer: renderer,
|
||||
highlight: (code, lang) => {
|
||||
if (lang && window.Prism.languages[lang]) {
|
||||
return window.Prism.highlight(code, window.Prism.languages[lang], lang);
|
||||
@@ -131,6 +211,9 @@ class MarkdownEditor {
|
||||
*/
|
||||
async loadFile(path) {
|
||||
try {
|
||||
// Reset custom preview flag when loading text files
|
||||
this.isShowingCustomPreview = false;
|
||||
|
||||
const content = await this.webdavClient.get(path);
|
||||
this.currentFile = path;
|
||||
|
||||
@@ -337,6 +420,12 @@ class MarkdownEditor {
|
||||
* Calls renderPreview with content from editor
|
||||
*/
|
||||
async updatePreview() {
|
||||
// Skip auto-update if showing custom preview (e.g., binary files)
|
||||
if (this.isShowingCustomPreview) {
|
||||
Logger.debug('Skipping auto-update: showing custom preview');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
await this.renderPreview();
|
||||
}
|
||||
|
||||
@@ -240,6 +240,56 @@ class FileTreeActions {
|
||||
};
|
||||
|
||||
input.click();
|
||||
},
|
||||
|
||||
'copy-to-collection': async function (path, isDir) {
|
||||
// Get list of available collections
|
||||
const collections = await this.webdavClient.getCollections();
|
||||
const currentCollection = this.webdavClient.currentCollection;
|
||||
|
||||
// Filter out current collection
|
||||
const otherCollections = collections.filter(c => c !== currentCollection);
|
||||
|
||||
if (otherCollections.length === 0) {
|
||||
showNotification('No other collections available', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show collection selection dialog
|
||||
const targetCollection = await this.showCollectionSelectionDialog(
|
||||
otherCollections,
|
||||
`Copy ${PathUtils.getFileName(path)} to collection:`
|
||||
);
|
||||
|
||||
if (!targetCollection) return;
|
||||
|
||||
// Copy the file/folder
|
||||
await this.copyToCollection(path, isDir, currentCollection, targetCollection);
|
||||
},
|
||||
|
||||
'move-to-collection': async function (path, isDir) {
|
||||
// Get list of available collections
|
||||
const collections = await this.webdavClient.getCollections();
|
||||
const currentCollection = this.webdavClient.currentCollection;
|
||||
|
||||
// Filter out current collection
|
||||
const otherCollections = collections.filter(c => c !== currentCollection);
|
||||
|
||||
if (otherCollections.length === 0) {
|
||||
showNotification('No other collections available', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show collection selection dialog
|
||||
const targetCollection = await this.showCollectionSelectionDialog(
|
||||
otherCollections,
|
||||
`Move ${PathUtils.getFileName(path)} to collection:`
|
||||
);
|
||||
|
||||
if (!targetCollection) return;
|
||||
|
||||
// Move the file/folder
|
||||
await this.moveToCollection(path, isDir, currentCollection, targetCollection);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -251,4 +301,268 @@ class FileTreeActions {
|
||||
pasteItem.style.display = this.clipboard ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a dialog to select a collection
|
||||
* @param {Array<string>} collections - List of collection names
|
||||
* @param {string} message - Dialog message
|
||||
* @returns {Promise<string|null>} Selected collection or null if cancelled
|
||||
*/
|
||||
async showCollectionSelectionDialog(collections, message) {
|
||||
// Prevent duplicate modals
|
||||
if (this._collectionModalShowing) {
|
||||
Logger.warn('Collection selection modal is already showing');
|
||||
return null;
|
||||
}
|
||||
this._collectionModalShowing = true;
|
||||
|
||||
// Create a custom modal with radio buttons for collection selection
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal fade';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-folder-symlink"></i> Select Collection</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-3">${message}</p>
|
||||
<div class="collection-list" style="max-height: 300px; overflow-y: auto;">
|
||||
${collections.map((c, i) => `
|
||||
<div class="form-check p-2 mb-2 rounded border collection-option" style="cursor: pointer; transition: all 0.2s;">
|
||||
<input class="form-check-input" type="radio" name="collection" id="collection-${i}" value="${c}" ${i === 0 ? 'checked' : ''}>
|
||||
<label class="form-check-label w-100" for="collection-${i}" style="cursor: pointer;">
|
||||
<i class="bi bi-folder"></i> <strong>${c}</strong>
|
||||
</label>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div id="confirmationPreview" class="alert alert-info mt-3" style="display: none;">
|
||||
<i class="bi bi-info-circle"></i> <span id="confirmationText"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-flat btn-flat-secondary" data-bs-dismiss="modal">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</button>
|
||||
<button type="button" class="btn-flat btn-flat-primary" id="confirmCollectionBtn">
|
||||
<i class="bi bi-check-circle"></i> OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
const bsModal = new bootstrap.Modal(modal);
|
||||
|
||||
// Extract file name and action from message
|
||||
// Message format: "Copy filename to collection:" or "Move filename to collection:"
|
||||
const messageMatch = message.match(/(Copy|Move)\s+(.+?)\s+to collection:/);
|
||||
const action = messageMatch ? messageMatch[1].toLowerCase() : 'copy';
|
||||
const fileName = messageMatch ? messageMatch[2] : 'item';
|
||||
|
||||
// Get confirmation preview elements
|
||||
const confirmationPreview = modal.querySelector('#confirmationPreview');
|
||||
const confirmationText = modal.querySelector('#confirmationText');
|
||||
|
||||
// Function to update confirmation message
|
||||
const updateConfirmation = (collectionName) => {
|
||||
confirmationText.textContent = `"${fileName}" will be ${action}d to "${collectionName}"`;
|
||||
confirmationPreview.style.display = 'block';
|
||||
};
|
||||
|
||||
// Add hover effects and click handlers for collection options
|
||||
const collectionOptions = modal.querySelectorAll('.collection-option');
|
||||
collectionOptions.forEach(option => {
|
||||
// Hover effect
|
||||
option.addEventListener('mouseenter', () => {
|
||||
option.style.backgroundColor = 'var(--bs-light)';
|
||||
option.style.borderColor = 'var(--bs-primary)';
|
||||
});
|
||||
option.addEventListener('mouseleave', () => {
|
||||
const radio = option.querySelector('input[type="radio"]');
|
||||
if (!radio.checked) {
|
||||
option.style.backgroundColor = '';
|
||||
option.style.borderColor = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Click on the whole div to select
|
||||
option.addEventListener('click', () => {
|
||||
const radio = option.querySelector('input[type="radio"]');
|
||||
radio.checked = true;
|
||||
|
||||
// Update confirmation message
|
||||
updateConfirmation(radio.value);
|
||||
|
||||
// Update all options styling
|
||||
collectionOptions.forEach(opt => {
|
||||
const r = opt.querySelector('input[type="radio"]');
|
||||
if (r.checked) {
|
||||
opt.style.backgroundColor = 'var(--bs-primary-bg-subtle)';
|
||||
opt.style.borderColor = 'var(--bs-primary)';
|
||||
} else {
|
||||
opt.style.backgroundColor = '';
|
||||
opt.style.borderColor = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Set initial styling for checked option
|
||||
const radio = option.querySelector('input[type="radio"]');
|
||||
if (radio.checked) {
|
||||
option.style.backgroundColor = 'var(--bs-primary-bg-subtle)';
|
||||
option.style.borderColor = 'var(--bs-primary)';
|
||||
// Show initial confirmation
|
||||
updateConfirmation(radio.value);
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const confirmBtn = modal.querySelector('#confirmCollectionBtn');
|
||||
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
const selected = modal.querySelector('input[name="collection"]:checked');
|
||||
this._collectionModalShowing = false;
|
||||
bsModal.hide();
|
||||
resolve(selected ? selected.value : null);
|
||||
});
|
||||
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
modal.remove();
|
||||
this._collectionModalShowing = false;
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
bsModal.show();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file or folder to another collection
|
||||
*/
|
||||
async copyToCollection(path, isDir, sourceCollection, targetCollection) {
|
||||
try {
|
||||
Logger.info(`Copying ${path} from ${sourceCollection} to ${targetCollection}`);
|
||||
|
||||
if (isDir) {
|
||||
// Copy folder recursively
|
||||
await this.copyFolderToCollection(path, sourceCollection, targetCollection);
|
||||
} else {
|
||||
// Copy single file
|
||||
await this.copyFileToCollection(path, sourceCollection, targetCollection);
|
||||
}
|
||||
|
||||
showNotification(`Copied to ${targetCollection}`, 'success');
|
||||
} catch (error) {
|
||||
Logger.error('Failed to copy to collection:', error);
|
||||
showNotification('Failed to copy to collection', 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file or folder to another collection
|
||||
*/
|
||||
async moveToCollection(path, isDir, sourceCollection, targetCollection) {
|
||||
try {
|
||||
Logger.info(`Moving ${path} from ${sourceCollection} to ${targetCollection}`);
|
||||
|
||||
// First copy
|
||||
await this.copyToCollection(path, isDir, sourceCollection, targetCollection);
|
||||
|
||||
// Then delete from source
|
||||
await this.webdavClient.delete(path);
|
||||
await this.fileTree.load();
|
||||
|
||||
showNotification(`Moved to ${targetCollection}`, 'success');
|
||||
} catch (error) {
|
||||
Logger.error('Failed to move to collection:', error);
|
||||
showNotification('Failed to move to collection', 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a single file to another collection
|
||||
*/
|
||||
async copyFileToCollection(path, sourceCollection, targetCollection) {
|
||||
// Read file from source collection
|
||||
const content = await this.webdavClient.get(path);
|
||||
|
||||
// Write to target collection
|
||||
const originalCollection = this.webdavClient.currentCollection;
|
||||
this.webdavClient.setCollection(targetCollection);
|
||||
|
||||
// Ensure parent directories exist in target collection
|
||||
await this.webdavClient.ensureParentDirectories(path);
|
||||
|
||||
await this.webdavClient.put(path, content);
|
||||
this.webdavClient.setCollection(originalCollection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a folder recursively to another collection
|
||||
* @param {string} folderPath - Path of the folder to copy
|
||||
* @param {string} sourceCollection - Source collection name
|
||||
* @param {string} targetCollection - Target collection name
|
||||
* @param {Set} visitedPaths - Set of already visited paths to prevent infinite loops
|
||||
*/
|
||||
async copyFolderToCollection(folderPath, sourceCollection, targetCollection, visitedPaths = new Set()) {
|
||||
// Prevent infinite loops by tracking visited paths
|
||||
if (visitedPaths.has(folderPath)) {
|
||||
Logger.warn(`Skipping already visited path: ${folderPath}`);
|
||||
return;
|
||||
}
|
||||
visitedPaths.add(folderPath);
|
||||
|
||||
Logger.info(`Copying folder: ${folderPath} from ${sourceCollection} to ${targetCollection}`);
|
||||
|
||||
// Set to source collection to list items
|
||||
const originalCollection = this.webdavClient.currentCollection;
|
||||
this.webdavClient.setCollection(sourceCollection);
|
||||
|
||||
// Get only direct children (not recursive to avoid infinite loop)
|
||||
const items = await this.webdavClient.list(folderPath, false);
|
||||
Logger.debug(`Found ${items.length} items in ${folderPath}:`, items.map(i => i.path));
|
||||
|
||||
// Create the folder in target collection
|
||||
this.webdavClient.setCollection(targetCollection);
|
||||
|
||||
try {
|
||||
// Ensure parent directories exist first
|
||||
await this.webdavClient.ensureParentDirectories(folderPath + '/dummy.txt');
|
||||
// Then create the folder itself
|
||||
await this.webdavClient.createFolder(folderPath);
|
||||
Logger.debug(`Created folder: ${folderPath}`);
|
||||
} catch (error) {
|
||||
// Folder might already exist (405 Method Not Allowed), ignore error
|
||||
if (error.message && error.message.includes('405')) {
|
||||
Logger.debug(`Folder ${folderPath} already exists (405)`);
|
||||
} else {
|
||||
Logger.debug('Folder might already exist:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy all items
|
||||
for (const item of items) {
|
||||
if (item.isDirectory) {
|
||||
// Recursively copy subdirectory
|
||||
await this.copyFolderToCollection(item.path, sourceCollection, targetCollection, visitedPaths);
|
||||
} else {
|
||||
// Copy file
|
||||
this.webdavClient.setCollection(sourceCollection);
|
||||
const content = await this.webdavClient.get(item.path);
|
||||
this.webdavClient.setCollection(targetCollection);
|
||||
// Ensure parent directories exist before copying file
|
||||
await this.webdavClient.ensureParentDirectories(item.path);
|
||||
await this.webdavClient.put(item.path, content);
|
||||
Logger.debug(`Copied file: ${item.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.webdavClient.setCollection(originalCollection);
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,55 @@ const PathUtils = {
|
||||
isDescendant(path, ancestorPath) {
|
||||
if (!path || !ancestorPath) return false;
|
||||
return path.startsWith(ancestorPath + '/');
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a file is a binary/non-editable file based on extension
|
||||
* @param {string} path - The file path
|
||||
* @returns {boolean} True if the file is binary/non-editable
|
||||
* @example PathUtils.isBinaryFile('image.png') // true
|
||||
* @example PathUtils.isBinaryFile('document.md') // false
|
||||
*/
|
||||
isBinaryFile(path) {
|
||||
const extension = PathUtils.getExtension(path).toLowerCase();
|
||||
const binaryExtensions = [
|
||||
// Images
|
||||
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'tiff', 'tif',
|
||||
// Documents
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||
// Archives
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2',
|
||||
// Executables
|
||||
'exe', 'dll', 'so', 'dylib', 'app',
|
||||
// Media
|
||||
'mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'wav', 'ogg',
|
||||
// Other binary formats
|
||||
'bin', 'dat', 'db', 'sqlite'
|
||||
];
|
||||
return binaryExtensions.includes(extension);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a human-readable file type description
|
||||
* @param {string} path - The file path
|
||||
* @returns {string} The file type description
|
||||
* @example PathUtils.getFileType('image.png') // 'Image'
|
||||
*/
|
||||
getFileType(path) {
|
||||
const extension = PathUtils.getExtension(path).toLowerCase();
|
||||
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'tiff', 'tif'];
|
||||
const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
|
||||
const archiveExtensions = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2'];
|
||||
const mediaExtensions = ['mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'wav', 'ogg'];
|
||||
|
||||
if (imageExtensions.includes(extension)) return 'Image';
|
||||
if (documentExtensions.includes(extension)) return 'Document';
|
||||
if (archiveExtensions.includes(extension)) return 'Archive';
|
||||
if (mediaExtensions.includes(extension)) return 'Media';
|
||||
if (extension === 'pdf') return 'PDF';
|
||||
|
||||
return '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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user