- 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
568 lines
22 KiB
JavaScript
568 lines
22 KiB
JavaScript
/**
|
|
* File Tree Actions Manager
|
|
* Centralized handling of all tree operations
|
|
*/
|
|
|
|
class FileTreeActions {
|
|
constructor(webdavClient, fileTree, editor) {
|
|
this.webdavClient = webdavClient;
|
|
this.fileTree = fileTree;
|
|
this.editor = editor;
|
|
this.clipboard = null;
|
|
}
|
|
|
|
/**
|
|
* Validate and sanitize filename/folder name
|
|
* Returns { valid: boolean, sanitized: string, message: string }
|
|
* Now uses ValidationUtils from utils.js
|
|
*/
|
|
validateFileName(name, isFolder = false) {
|
|
return ValidationUtils.validateFileName(name, isFolder);
|
|
}
|
|
|
|
async execute(action, targetPath, isDirectory) {
|
|
const handler = this.actions[action];
|
|
if (!handler) {
|
|
console.error(`Unknown action: ${action}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await handler.call(this, targetPath, isDirectory);
|
|
} catch (error) {
|
|
console.error(`Action failed: ${action}`, error);
|
|
showNotification(`Failed to ${action}`, 'error');
|
|
}
|
|
}
|
|
|
|
actions = {
|
|
open: async function (path, isDir) {
|
|
if (!isDir) {
|
|
await this.editor.loadFile(path);
|
|
}
|
|
},
|
|
|
|
'new-file': async function (path, isDir) {
|
|
if (!isDir) return;
|
|
|
|
const filename = await window.ModalManager.prompt(
|
|
'Enter filename (lowercase, underscore only):',
|
|
'new_file.md',
|
|
'New File'
|
|
);
|
|
|
|
if (!filename) return;
|
|
|
|
let finalFilename = filename;
|
|
const validation = this.validateFileName(filename, false);
|
|
|
|
if (!validation.valid) {
|
|
showNotification(validation.message, 'warning');
|
|
|
|
// Ask if user wants to use sanitized version
|
|
if (validation.sanitized) {
|
|
const useSanitized = await window.ModalManager.confirm(
|
|
`${filename} → ${validation.sanitized}`,
|
|
'Use sanitized name?',
|
|
false
|
|
);
|
|
if (useSanitized) {
|
|
finalFilename = validation.sanitized;
|
|
} else {
|
|
return;
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const fullPath = `${path}/${finalFilename}`.replace(/\/+/g, '/');
|
|
await this.webdavClient.put(fullPath, '# New File\n\n');
|
|
|
|
// Clear undo history since new file was created
|
|
if (this.fileTree.lastMoveOperation) {
|
|
this.fileTree.lastMoveOperation = null;
|
|
}
|
|
|
|
await this.fileTree.load();
|
|
showNotification(`Created ${finalFilename}`, 'success');
|
|
await this.editor.loadFile(fullPath);
|
|
},
|
|
|
|
'new-folder': async function (path, isDir) {
|
|
if (!isDir) return;
|
|
|
|
const foldername = await window.ModalManager.prompt(
|
|
'Enter folder name (lowercase, underscore only):',
|
|
'new_folder',
|
|
'New Folder'
|
|
);
|
|
|
|
if (!foldername) return;
|
|
|
|
let finalFoldername = foldername;
|
|
const validation = this.validateFileName(foldername, true);
|
|
|
|
if (!validation.valid) {
|
|
showNotification(validation.message, 'warning');
|
|
|
|
if (validation.sanitized) {
|
|
const useSanitized = await window.ModalManager.confirm(
|
|
`${foldername} → ${validation.sanitized}`,
|
|
'Use sanitized name?',
|
|
false
|
|
);
|
|
if (useSanitized) {
|
|
finalFoldername = validation.sanitized;
|
|
} else {
|
|
return;
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const fullPath = `${path}/${finalFoldername}`.replace(/\/+/g, '/');
|
|
await this.webdavClient.mkcol(fullPath);
|
|
|
|
// Clear undo history since new folder was created
|
|
if (this.fileTree.lastMoveOperation) {
|
|
this.fileTree.lastMoveOperation = null;
|
|
}
|
|
|
|
await this.fileTree.load();
|
|
showNotification(`Created folder ${finalFoldername}`, 'success');
|
|
},
|
|
|
|
rename: async function (path, isDir) {
|
|
const oldName = path.split('/').pop();
|
|
const newName = await window.ModalManager.prompt(
|
|
'Rename to:',
|
|
oldName,
|
|
'Rename'
|
|
);
|
|
|
|
if (newName && newName !== oldName) {
|
|
const parentPath = path.substring(0, path.lastIndexOf('/'));
|
|
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
|
|
await this.webdavClient.move(path, newPath);
|
|
|
|
// Clear undo history since manual rename occurred
|
|
if (this.fileTree.lastMoveOperation) {
|
|
this.fileTree.lastMoveOperation = null;
|
|
}
|
|
|
|
await this.fileTree.load();
|
|
showNotification('Renamed', 'success');
|
|
}
|
|
},
|
|
|
|
copy: async function (path, isDir) {
|
|
this.clipboard = { path, operation: 'copy', isDirectory: isDir };
|
|
// No notification for copy - it's a quick operation
|
|
this.updatePasteMenuItem();
|
|
},
|
|
|
|
cut: async function (path, isDir) {
|
|
this.clipboard = { path, operation: 'cut', isDirectory: isDir };
|
|
// No notification for cut - it's a quick operation
|
|
this.updatePasteMenuItem();
|
|
},
|
|
|
|
paste: async function (targetPath, isDir) {
|
|
if (!this.clipboard || !isDir) return;
|
|
|
|
const itemName = this.clipboard.path.split('/').pop();
|
|
const destPath = `${targetPath}/${itemName}`.replace(/\/+/g, '/');
|
|
|
|
if (this.clipboard.operation === 'copy') {
|
|
await this.webdavClient.copy(this.clipboard.path, destPath);
|
|
// No notification for paste - file tree updates show the result
|
|
} else {
|
|
await this.webdavClient.move(this.clipboard.path, destPath);
|
|
this.clipboard = null;
|
|
this.updatePasteMenuItem();
|
|
// No notification for move - file tree updates show the result
|
|
}
|
|
|
|
await this.fileTree.load();
|
|
},
|
|
|
|
delete: async function (path, isDir) {
|
|
const name = path.split('/').pop();
|
|
const type = isDir ? 'folder' : 'file';
|
|
|
|
const confirmed = await window.ModalManager.confirm(
|
|
`Are you sure you want to delete ${name}?`,
|
|
`Delete this ${type}?`,
|
|
true
|
|
);
|
|
|
|
if (!confirmed) return;
|
|
|
|
await this.webdavClient.delete(path);
|
|
|
|
// Clear undo history since manual delete occurred
|
|
if (this.fileTree.lastMoveOperation) {
|
|
this.fileTree.lastMoveOperation = null;
|
|
}
|
|
|
|
await this.fileTree.load();
|
|
showNotification(`Deleted ${name}`, 'success');
|
|
},
|
|
|
|
download: async function (path, isDir) {
|
|
Logger.info(`Downloading ${isDir ? 'folder' : 'file'}: ${path}`);
|
|
|
|
if (isDir) {
|
|
await this.fileTree.downloadFolder(path);
|
|
} else {
|
|
await this.fileTree.downloadFile(path);
|
|
}
|
|
},
|
|
|
|
upload: async function (path, isDir) {
|
|
if (!isDir) return;
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.multiple = true;
|
|
|
|
input.onchange = async (e) => {
|
|
const files = Array.from(e.target.files);
|
|
for (const file of files) {
|
|
const fullPath = `${path}/${file.name}`.replace(/\/+/g, '/');
|
|
const content = await file.arrayBuffer();
|
|
await this.webdavClient.putBinary(fullPath, content);
|
|
showNotification(`Uploaded ${file.name}`, 'success');
|
|
}
|
|
await this.fileTree.load();
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
// Old deprecated modal methods removed - all modals now use window.ModalManager
|
|
|
|
updatePasteMenuItem() {
|
|
const pasteItem = document.getElementById('pasteMenuItem');
|
|
if (pasteItem) {
|
|
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);
|
|
}
|
|
} |