362 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			362 lines
		
	
	
		
			13 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 }
 | |
|      */
 | |
|     validateFileName(name, isFolder = false) {
 | |
|         const type = isFolder ? 'folder' : 'file';
 | |
|         
 | |
|         if (!name || name.trim().length === 0) {
 | |
|             return { valid: false, message: `${type} name cannot be empty` };
 | |
|         }
 | |
|         
 | |
|         // Check for invalid characters
 | |
|         const validPattern = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/;
 | |
|         
 | |
|         if (!validPattern.test(name)) {
 | |
|             const sanitized = name
 | |
|                 .toLowerCase()
 | |
|                 .replace(/[^a-z0-9_.]/g, '_')
 | |
|                 .replace(/_+/g, '_')
 | |
|                 .replace(/^_+|_+$/g, '');
 | |
|             
 | |
|             return {
 | |
|                 valid: false,
 | |
|                 sanitized,
 | |
|                 message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"`
 | |
|             };
 | |
|         }
 | |
|         
 | |
|         return { valid: true, sanitized: name, message: '' };
 | |
|     }
 | |
| 
 | |
|     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;
 | |
|             
 | |
|             await this.showInputDialog('Enter filename (lowercase, underscore only):', 'new_file.md', async (filename) => {
 | |
|                 if (!filename) return;
 | |
|                 
 | |
|                 const validation = this.validateFileName(filename, false);
 | |
|                 
 | |
|                 if (!validation.valid) {
 | |
|                     showNotification(validation.message, 'warning');
 | |
|                     
 | |
|                     // Ask if user wants to use sanitized version
 | |
|                     if (validation.sanitized) {
 | |
|                         if (await this.showConfirmDialog('Use sanitized name?', `${filename} → ${validation.sanitized}`)) {
 | |
|                             filename = validation.sanitized;
 | |
|                         } else {
 | |
|                             return;
 | |
|                         }
 | |
|                     } else {
 | |
|                         return;
 | |
|                     }
 | |
|                 }
 | |
|                 
 | |
|                 const fullPath = `${path}/${filename}`.replace(/\/+/g, '/');
 | |
|                 await this.webdavClient.put(fullPath, '# New File\n\n');
 | |
|                 await this.fileTree.load();
 | |
|                 showNotification(`Created ${filename}`, 'success');
 | |
|                 await this.editor.loadFile(fullPath);
 | |
|             });
 | |
|         },
 | |
| 
 | |
|         'new-folder': async function(path, isDir) {
 | |
|             if (!isDir) return;
 | |
|             
 | |
|             await this.showInputDialog('Enter folder name (lowercase, underscore only):', 'new_folder', async (foldername) => {
 | |
|                 if (!foldername) return;
 | |
|                 
 | |
|                 const validation = this.validateFileName(foldername, true);
 | |
|                 
 | |
|                 if (!validation.valid) {
 | |
|                     showNotification(validation.message, 'warning');
 | |
|                     
 | |
|                     if (validation.sanitized) {
 | |
|                         if (await this.showConfirmDialog('Use sanitized name?', `${foldername} → ${validation.sanitized}`)) {
 | |
|                             foldername = validation.sanitized;
 | |
|                         } else {
 | |
|                             return;
 | |
|                         }
 | |
|                     } else {
 | |
|                         return;
 | |
|                     }
 | |
|                 }
 | |
|                 
 | |
|                 const fullPath = `${path}/${foldername}`.replace(/\/+/g, '/');
 | |
|                 await this.webdavClient.mkcol(fullPath);
 | |
|                 await this.fileTree.load();
 | |
|                 showNotification(`Created folder ${foldername}`, 'success');
 | |
|             });
 | |
|         },
 | |
| 
 | |
|         rename: async function(path, isDir) {
 | |
|             const oldName = path.split('/').pop();
 | |
|             const newName = await this.showInputDialog('Rename to:', oldName);
 | |
|             if (newName && newName !== oldName) {
 | |
|                 const parentPath = path.substring(0, path.lastIndexOf('/'));
 | |
|                 const newPath = parentPath ? `${parentPath}/${newName}` : newName;
 | |
|                 await this.webdavClient.move(path, newPath);
 | |
|                 await this.fileTree.load();
 | |
|                 showNotification('Renamed', 'success');
 | |
|             }
 | |
|         },
 | |
| 
 | |
|         copy: async function(path, isDir) {
 | |
|             this.clipboard = { path, operation: 'copy', isDirectory: isDir };
 | |
|             showNotification(`Copied: ${path.split('/').pop()}`, 'info');
 | |
|             this.updatePasteMenuItem();
 | |
|         },
 | |
| 
 | |
|         cut: async function(path, isDir) {
 | |
|             this.clipboard = { path, operation: 'cut', isDirectory: isDir };
 | |
|             showNotification(`Cut: ${path.split('/').pop()}`, 'warning');
 | |
|             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);
 | |
|                 showNotification('Copied', 'success');
 | |
|             } else {
 | |
|                 await this.webdavClient.move(this.clipboard.path, destPath);
 | |
|                 this.clipboard = null;
 | |
|                 this.updatePasteMenuItem();
 | |
|                 showNotification('Moved', 'success');
 | |
|             }
 | |
|             
 | |
|             await this.fileTree.load();
 | |
|         },
 | |
| 
 | |
|         delete: async function(path, isDir) {
 | |
|             const name = path.split('/').pop();
 | |
|             const type = isDir ? 'folder' : 'file';
 | |
|             
 | |
|             if (!await this.showConfirmDialog(`Delete this ${type}?`, `${name}`)) {
 | |
|                 return;
 | |
|             }
 | |
|             
 | |
|             await this.webdavClient.delete(path);
 | |
|             await this.fileTree.load();
 | |
|             showNotification(`Deleted ${name}`, 'success');
 | |
|         },
 | |
| 
 | |
|         download: async function(path, isDir) {
 | |
|             showNotification('Downloading...', 'info');
 | |
|             // Implementation here
 | |
|         },
 | |
| 
 | |
|         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();
 | |
|         }
 | |
|     };
 | |
| 
 | |
|     // Modern dialog implementations
 | |
|     async showInputDialog(title, placeholder = '', callback) {
 | |
|         return new Promise((resolve) => {
 | |
|             const dialog = this.createInputDialog(title, placeholder);
 | |
|             const input = dialog.querySelector('input');
 | |
|             const confirmBtn = dialog.querySelector('.btn-primary');
 | |
|             const cancelBtn = dialog.querySelector('.btn-secondary');
 | |
| 
 | |
|             const cleanup = (value) => {
 | |
|                 const modalInstance = bootstrap.Modal.getInstance(dialog);
 | |
|                 if (modalInstance) {
 | |
|                     modalInstance.hide();
 | |
|                 }
 | |
|                 dialog.remove();
 | |
|                 const backdrop = document.querySelector('.modal-backdrop');
 | |
|                 if (backdrop) backdrop.remove();
 | |
|                 document.body.classList.remove('modal-open');
 | |
|                 resolve(value);
 | |
|                 if (callback) callback(value);
 | |
|             };
 | |
| 
 | |
|             confirmBtn.onclick = () => {
 | |
|                 cleanup(input.value.trim());
 | |
|             };
 | |
| 
 | |
|             cancelBtn.onclick = () => {
 | |
|                 cleanup(null);
 | |
|             };
 | |
| 
 | |
|             dialog.addEventListener('hidden.bs.modal', () => {
 | |
|                 cleanup(null);
 | |
|             });
 | |
| 
 | |
|             input.onkeypress = (e) => {
 | |
|                 if (e.key === 'Enter') confirmBtn.click();
 | |
|             };
 | |
| 
 | |
|             document.body.appendChild(dialog);
 | |
|             const modal = new bootstrap.Modal(dialog);
 | |
|             modal.show();
 | |
|             input.focus();
 | |
|             input.select();
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     async showConfirmDialog(title, message = '', callback) {
 | |
|         return new Promise((resolve) => {
 | |
|             const dialog = this.createConfirmDialog(title, message);
 | |
|             const confirmBtn = dialog.querySelector('.btn-danger');
 | |
|             const cancelBtn = dialog.querySelector('.btn-secondary');
 | |
| 
 | |
|             const cleanup = (value) => {
 | |
|                 const modalInstance = bootstrap.Modal.getInstance(dialog);
 | |
|                 if (modalInstance) {
 | |
|                     modalInstance.hide();
 | |
|                 }
 | |
|                 dialog.remove();
 | |
|                 const backdrop = document.querySelector('.modal-backdrop');
 | |
|                 if (backdrop) backdrop.remove();
 | |
|                 document.body.classList.remove('modal-open');
 | |
|                 resolve(value);
 | |
|                 if (callback) callback(value);
 | |
|             };
 | |
| 
 | |
|             confirmBtn.onclick = () => {
 | |
|                 cleanup(true);
 | |
|             };
 | |
| 
 | |
|             cancelBtn.onclick = () => {
 | |
|                 cleanup(false);
 | |
|             };
 | |
| 
 | |
|             dialog.addEventListener('hidden.bs.modal', () => {
 | |
|                 cleanup(false);
 | |
|             });
 | |
| 
 | |
|             document.body.appendChild(dialog);
 | |
|             const modal = new bootstrap.Modal(dialog);
 | |
|             modal.show();
 | |
|             confirmBtn.focus();
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     createInputDialog(title, placeholder) {
 | |
|         const backdrop = document.createElement('div');
 | |
|         backdrop.className = 'modal-backdrop fade show';
 | |
|         
 | |
|         const dialog = document.createElement('div');
 | |
|         dialog.className = 'modal fade show d-block';
 | |
|         dialog.setAttribute('tabindex', '-1');
 | |
|         dialog.style.display = 'block';
 | |
|         
 | |
|         dialog.innerHTML = `
 | |
|             <div class="modal-dialog modal-dialog-centered">
 | |
|                 <div class="modal-content">
 | |
|                     <div class="modal-header">
 | |
|                         <h5 class="modal-title">${title}</h5>
 | |
|                         <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
 | |
|                     </div>
 | |
|                     <div class="modal-body">
 | |
|                         <input type="text" class="form-control" value="${placeholder}" autofocus>
 | |
|                     </div>
 | |
|                     <div class="modal-footer">
 | |
|                         <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
 | |
|                         <button type="button" class="btn btn-primary">OK</button>
 | |
|                     </div>
 | |
|                 </div>
 | |
|             </div>
 | |
|         `;
 | |
|         
 | |
|         document.body.appendChild(backdrop);
 | |
|         return dialog;
 | |
|     }
 | |
| 
 | |
|     createConfirmDialog(title, message) {
 | |
|         const backdrop = document.createElement('div');
 | |
|         backdrop.className = 'modal-backdrop fade show';
 | |
|         
 | |
|         const dialog = document.createElement('div');
 | |
|         dialog.className = 'modal fade show d-block';
 | |
|         dialog.setAttribute('tabindex', '-1');
 | |
|         dialog.style.display = 'block';
 | |
|         
 | |
|         dialog.innerHTML = `
 | |
|             <div class="modal-dialog modal-dialog-centered">
 | |
|                 <div class="modal-content">
 | |
|                     <div class="modal-header border-danger">
 | |
|                         <h5 class="modal-title text-danger">${title}</h5>
 | |
|                         <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
 | |
|                     </div>
 | |
|                     <div class="modal-body">
 | |
|                         <p>${message}</p>
 | |
|                         <p class="text-danger small">This action cannot be undone.</p>
 | |
|                     </div>
 | |
|                     <div class="modal-footer">
 | |
|                         <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
 | |
|                         <button type="button" class="btn btn-danger">Delete</button>
 | |
|                     </div>
 | |
|                 </div>
 | |
|             </div>
 | |
|         `;
 | |
|         
 | |
|         document.body.appendChild(backdrop);
 | |
|         return dialog;
 | |
|     }
 | |
| 
 | |
|     updatePasteMenuItem() {
 | |
|         const pasteItem = document.getElementById('pasteMenuItem');
 | |
|         if (pasteItem) {
 | |
|             pasteItem.style.display = this.clipboard ? 'flex' : 'none';
 | |
|         }
 | |
|     }
 | |
| } |