/** * @fileoverview Manages file operations and state for the room interface. * This file handles: * - File fetching and state management * - File operations (delete, rename, move, download) * - File selection and batch operations * - Star/unstar functionality * - Navigation and path management */ /** * @class FileManager * @classdesc Manages all file-related operations and state in the room interface. * Handles file operations, selection, and navigation. */ export class FileManager { /** * Creates a new FileManager instance. * @param {RoomManager} roomManager - The parent RoomManager instance */ constructor(roomManager) { console.log('[FileManager] Initializing...'); this.roomManager = roomManager; this.currentFiles = []; this.selectedItems = new Set(); this.lastSelectedIndex = -1; this.batchDeleteItems = null; this.fileToDelete = null; this.fileToDeletePath = ''; this.fileToMove = null; this.fileToMovePath = ''; this.fileToRename = null; this.fileToRenamePath = ''; console.log('[FileManager] Initialized with roomManager:', roomManager); } /** * Fetches files from the server for the current path. * @async * @returns {Promise} A promise that resolves with the array of files * @throws {Error} If the fetch operation fails */ async fetchFiles() { console.log('[FileManager] Fetching files...'); try { const url = `/api/rooms/${this.roomManager.roomId}/files${this.roomManager.currentPath ? `?path=${encodeURIComponent(this.roomManager.currentPath)}` : ''}`; console.log('[FileManager] Fetching from URL:', url); const response = await fetch(url); console.log('[FileManager] Response status:', response.status); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); console.log('[FileManager] Received data:', data); this.currentFiles = data.sort((a, b) => a.name.localeCompare(b.name)); console.log('[FileManager] Sorted files:', this.currentFiles); // Update the view await this.roomManager.viewManager.renderFiles(this.currentFiles); console.log('[FileManager] Files rendered in view'); return this.currentFiles; } catch (error) { console.error('[FileManager] Error fetching files:', error); document.getElementById('fileError').textContent = 'Failed to load files. Please try again.'; throw error; } } /** * Deletes a file from the server. * @async * @param {string} filename - The name of the file to delete * @param {string} [path=''] - The path of the file to delete * @throws {Error} If the delete operation fails */ async deleteFile(filename, path = '') { console.log('[FileManager] Deleting file:', { filename, path }); const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); try { let url = `/api/rooms/${this.roomManager.roomId}/files/${encodeURIComponent(filename)}`; if (path) { url += `?path=${encodeURIComponent(path)}`; } const response = await fetch(url, { method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken } }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); if (result.success) { console.log('[FileManager] File deleted successfully'); await this.fetchFiles(); // Clear any existing error message const errorEl = document.getElementById('fileError'); if (errorEl) { errorEl.textContent = ''; } } else { console.error('[FileManager] Failed to delete file:', result.error); const errorEl = document.getElementById('fileError'); if (errorEl) { errorEl.textContent = result.error || 'Failed to delete file.'; } } } catch (error) { console.error('[FileManager] Error deleting file:', error); const errorEl = document.getElementById('fileError'); if (errorEl) { errorEl.textContent = 'Failed to delete file. Please try again.'; } } } /** * Renames a file on the server. * @async * @param {string} filename - The name of the file to rename * @param {string} newName - The new name for the file * @returns {Promise} A promise that resolves with the result of the rename operation * @throws {Error} If the rename operation fails */ async renameFile(filename, newName) { console.log('[FileManager] Renaming file:', { filename, newName }); // Check if the file has an extension const hasExtension = filename.includes('.'); if (hasExtension) { const oldExt = filename.substring(filename.lastIndexOf('.')); const newExt = newName.includes('.') ? newName.substring(newName.lastIndexOf('.')) : ''; // If the new name doesn't have the same extension, append the old extension if (newExt !== oldExt) { newName = newName + oldExt; } } try { const response = await fetch(`/api/rooms/${this.roomManager.roomId}/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content') }, body: JSON.stringify({ old_name: filename, new_name: newName, path: this.fileToRenamePath }) }); console.log('[FileManager] Rename response status:', response.status); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); console.log('[FileManager] Rename result:', result); if (result.success) { // Update the file's name in currentFiles const fileIndex = this.currentFiles.findIndex(file => file.name === filename && file.path === this.fileToRenamePath); if (fileIndex !== -1) { this.currentFiles[fileIndex].name = newName; await this.roomManager.viewManager.renderFiles(this.currentFiles); console.log('[FileManager] File renamed and view updated'); } // Clear the rename state this.fileToRename = null; this.fileToRenamePath = ''; return { success: true, message: 'File renamed successfully' }; } else { throw new Error(result.message || 'Failed to rename file'); } } catch (error) { console.error('[FileManager] Error renaming file:', error); return { success: false, message: error.message }; } } /** * Moves a file to a new location. * @async * @param {string} fileId - The ID of the file to move * @param {string} targetPath - The target path to move the file to * @returns {Promise} A promise that resolves with the result of the move operation * @throws {Error} If the move operation fails */ async moveFile(fileId, targetPath) { console.log('[FileManager] Starting moveFile...'); console.log('[FileManager] Parameters:', { fileId, targetPath }); try { const file = this.currentFiles.find(f => f.id === fileId); console.log('[FileManager] Found file to move:', file); if (!file) { console.error('[FileManager] File not found with ID:', fileId); throw new Error('File not found'); } console.log('[FileManager] Sending move request...'); const response = await fetch(`/api/rooms/${this.roomManager.roomId}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content') }, body: JSON.stringify({ filename: file.name, source_path: file.path || '', target_path: targetPath }) }); console.log('[FileManager] Move response status:', response.status); const result = await response.json(); console.log('[FileManager] Move response data:', result); if (result.success) { console.log('[FileManager] Move successful, updating view...'); this.currentFiles = this.currentFiles.filter(f => f.id !== fileId); await this.roomManager.viewManager.renderFiles(this.currentFiles); console.log('[FileManager] View updated after move'); return { success: true, message: 'File moved successfully' }; } else { console.error('[FileManager] Move failed:', result.message); throw new Error(result.message || 'Failed to move file'); } } catch (error) { console.error('[FileManager] Error in moveFile:', error); console.error('[FileManager] Error details:', { message: error.message, stack: error.stack }); return { success: false, message: error.message }; } } /** * Handles the confirmed move operation after user confirmation. * @async * @throws {Error} If the move operation fails */ async moveFileConfirmed() { console.log('[FileManager] Starting moveFileConfirmed...'); console.log('[FileManager] Current state:', { fileToMove: this.fileToMove, fileToMovePath: this.fileToMovePath, currentFiles: this.currentFiles }); if (!this.fileToMove) { console.error('[FileManager] No file selected for move operation'); document.getElementById('moveError').textContent = 'No file selected for move.'; return; } const targetPath = document.getElementById('moveTargetFolder').value; console.log('[FileManager] Selected target path:', targetPath); const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); console.log('[FileManager] CSRF Token:', csrfToken ? 'Present' : 'Missing'); // Get the file from currentFiles using the filename console.log('[FileManager] Attempting to find file with name:', this.fileToMove); const file = this.currentFiles.find(f => f.name === this.fileToMove); console.log('[FileManager] Found file:', file); if (!file) { console.error('[FileManager] File not found in currentFiles. Available files:', this.currentFiles); document.getElementById('moveError').textContent = 'File not found.'; return; } console.log('[FileManager] Preparing move request with data:', { filename: file.name, source_path: this.fileToMovePath, target_path: targetPath }); try { const url = `/api/rooms/${this.roomManager.roomId}/move`; console.log('[FileManager] Sending request to:', url); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ filename: file.name, source_path: this.fileToMovePath, target_path: targetPath }) }); console.log('[FileManager] Response status:', response.status); const result = await response.json(); console.log('[FileManager] Response data:', result); if (result.success) { console.log('[FileManager] Move successful, refreshing files...'); await this.fetchFiles(); this.fileToMove = null; this.fileToMovePath = ''; this.roomManager.modalManager.moveModal.hide(); document.getElementById('moveError').textContent = ''; console.log('[FileManager] Move operation completed successfully'); } else { console.error('[FileManager] Move failed:', result.error); document.getElementById('moveError').textContent = result.error || 'Move failed.'; } } catch (error) { console.error('[FileManager] Error during move operation:', error); console.error('[FileManager] Error details:', { message: error.message, stack: error.stack }); document.getElementById('moveError').textContent = 'Move failed.'; } } /** * Toggles the star status of a file. * @async * @param {string} filename - The name of the file to toggle star for * @param {string} path - The path of the file * @throws {Error} If the star toggle operation fails */ async toggleStar(filename, path) { console.log('[FileManager] Toggling star for:', filename, 'path:', path); const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); // Find and update the button immediately for better UX const starButton = event.target.closest('.file-action-btn'); if (starButton) { // Get the current state from the button's title const isStarred = starButton.title === 'Unstar'; // Update button appearance using CSS variables starButton.style.backgroundColor = isStarred ? 'var(--primary-opacity-8)' : 'var(--warning-opacity-15)'; starButton.style.color = isStarred ? 'var(--primary-color)' : 'var(--warning-color)'; starButton.title = isStarred ? 'Star' : 'Unstar'; } try { const response = await fetch(`/api/rooms/${this.roomManager.roomId}/star`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ filename: filename, path: path || '' }) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); console.log('[FileManager] Star toggle response:', data); if (data.success) { // Update the file's starred status in currentFiles const fileIndex = this.currentFiles.findIndex(f => f.name === filename && f.path === path); if (fileIndex !== -1) { this.currentFiles[fileIndex].starred = data.starred; } } else { // Revert the button if the server request failed if (starButton) { const isStarred = starButton.title === 'Unstar'; starButton.style.backgroundColor = isStarred ? 'var(--primary-opacity-8)' : 'var(--warning-opacity-15)'; starButton.style.color = isStarred ? 'var(--primary-color)' : 'var(--warning-color)'; starButton.title = isStarred ? 'Star' : 'Unstar'; } throw new Error(data.error || 'Failed to toggle star'); } } catch (error) { // Revert the button if there was an error if (starButton) { const isStarred = starButton.title === 'Unstar'; starButton.style.backgroundColor = isStarred ? 'var(--primary-opacity-8)' : 'var(--warning-opacity-15)'; starButton.style.color = isStarred ? 'var(--primary-color)' : 'var(--warning-color)'; starButton.title = isStarred ? 'Star' : 'Unstar'; } console.error('[FileManager] Error toggling star:', error); throw error; } } /** * Downloads a single file. * @async * @param {string} filename - The name of the file to download * @param {string} [path=''] - The path of the file * @throws {Error} If the download operation fails */ async downloadFile(filename, path = '') { console.log('[FileManager] Downloading file:', { filename, path }); let url = `/api/rooms/${this.roomManager.roomId}/files/${encodeURIComponent(filename)}`; if (path) { url += `?path=${encodeURIComponent(path)}`; } console.log('[FileManager] Download URL:', url); try { const response = await fetch(url, { method: 'GET', headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content') } }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const blob = await response.blob(); const downloadUrl = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = downloadUrl; a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(downloadUrl); document.body.removeChild(a); console.log('[FileManager] Download initiated'); } catch (error) { console.error('[FileManager] Error downloading file:', error); document.getElementById('fileError').textContent = 'Failed to download file. Please try again.'; throw error; } } /** * Downloads multiple selected files as a zip archive. * @async * @throws {Error} If the download operation fails */ async downloadSelected() { console.log('[FileManager] Downloading selected files...'); const selectedItems = this.getSelectedItems(); console.log('[FileManager] Selected items:', selectedItems); if (selectedItems.length === 0) { console.log('[FileManager] No files selected for download'); return; } // Filter out folders and get only file IDs const fileIds = selectedItems .filter(item => item.type !== 'folder') .map(item => item.id); if (fileIds.length === 0) { console.log('[FileManager] No files to download (only folders selected)'); return; } try { const response = await fetch(`/api/rooms/${this.roomManager.roomId}/download-zip`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content') }, body: JSON.stringify({ items: selectedItems }) }); console.log('[FileManager] Download response status:', response.status); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const blob = await response.blob(); console.log('[FileManager] Received blob:', blob); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'files.zip'; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); console.log('[FileManager] Download initiated'); } catch (error) { console.error('[FileManager] Error downloading files:', error); document.getElementById('fileError').textContent = 'Failed to download files. Please try again.'; throw error; } } /** * Handles the confirmed delete operation after user confirmation. * Supports both single file and batch deletion. * @async * @throws {Error} If the delete operation fails */ async deleteFileConfirmed() { const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); if (this.batchDeleteItems && this.batchDeleteItems.length) { // Batch delete let completed = 0; const deleteNext = async () => { if (completed >= this.batchDeleteItems.length) { await this.fetchFiles(); this.batchDeleteItems = null; this.fileToDelete = null; this.fileToDeletePath = ''; this.roomManager.modalManager.deleteModal.hide(); document.getElementById('fileError').textContent = ''; return; } const item = this.batchDeleteItems[completed]; let url = `/api/rooms/${this.roomManager.roomId}/files/${encodeURIComponent(item.name)}`; if (item.path) url += `?path=${encodeURIComponent(item.path)}`; try { const response = await fetch(url, { method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken } }); const result = await response.json(); if (!result.success) { throw new Error(result.error || 'Delete failed'); } } catch (error) { console.error('[FileManager] Error deleting file:', error); } completed++; await deleteNext(); }; await deleteNext(); return; } if (!this.fileToDelete) return; let url = `/api/rooms/${this.roomManager.roomId}/files/${encodeURIComponent(this.fileToDelete)}`; if (this.fileToDeletePath) url += `?path=${encodeURIComponent(this.fileToDeletePath)}`; try { const response = await fetch(url, { method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken } }); const result = await response.json(); if (result.success) { await this.fetchFiles(); this.fileToDelete = null; this.fileToDeletePath = ''; this.roomManager.modalManager.deleteModal.hide(); document.getElementById('fileError').textContent = ''; } else { document.getElementById('fileError').textContent = result.error || 'Delete failed.'; } } catch (error) { console.error('[FileManager] Error deleting file:', error); document.getElementById('fileError').textContent = 'Delete failed.'; } } /** * Updates the file selection based on user interaction. * Supports single selection, CTRL+click for multiple selection, and SHIFT+click for range selection. * @param {number} index - The index of the file being selected * @param {Event} event - The click event that triggered the selection */ updateSelection(index, event) { console.log('[FileManager] Updating selection:', { index, event }); // Prevent selection if clicking on a checkbox or action button if (event.target.classList.contains('select-item-checkbox') || event.target.closest('.file-action-btn')) { return; } const checkboxes = document.querySelectorAll('.select-item-checkbox'); const checkbox = checkboxes[index]; if (!checkbox) return; if (event.ctrlKey) { // CTRL + Click: Toggle individual selection checkbox.checked = !checkbox.checked; if (checkbox.checked) { this.selectedItems.add(index); } else { this.selectedItems.delete(index); } } else if (event.shiftKey && this.lastSelectedIndex !== -1) { // SHIFT + Click: Select range const start = Math.min(this.lastSelectedIndex, index); const end = Math.max(this.lastSelectedIndex, index); for (let i = start; i <= end; i++) { checkboxes[i].checked = true; this.selectedItems.add(i); } } else { // Normal click: Select single item const wasChecked = checkbox.checked; checkboxes.forEach(cb => { cb.checked = false; this.selectedItems.delete(parseInt(cb.dataset.index)); }); checkbox.checked = !wasChecked; if (!wasChecked) { this.selectedItems.add(index); } } this.lastSelectedIndex = index; this.roomManager.viewManager.updateMultiSelectUI(); } /** * Gets an array of currently selected file objects. * @returns {Array} Array of selected file objects */ getSelectedItems() { console.log('[FileManager] Getting selected items'); return Array.from(this.selectedItems).map(index => this.currentFiles[index]); } /** * Clears all file selections and updates the UI. */ clearSelection() { console.log('[FileManager] Clearing selection'); this.selectedItems.clear(); this.lastSelectedIndex = -1; const checkboxes = document.querySelectorAll('.select-item-checkbox'); checkboxes.forEach(cb => cb.checked = false); this.roomManager.viewManager.updateMultiSelectUI(); } /** * Navigates to the parent folder of the current path. */ navigateToParent() { if (!this.roomManager.currentPath) return; const parts = this.roomManager.currentPath.split('/'); parts.pop(); // Remove the last part const parentPath = parts.join('/'); this.roomManager.navigateTo(parentPath); } }