/** * OxiCloud - File Operations Module * This file handles file and folder operations (create, move, delete, rename, upload) */ import { refreshUserData } from '../../app/authSession.js'; import { addItem as filesViewAddItem, loadFiles } from '../../app/filesView.js'; import { app } from '../../app/state.js'; import { showConfirmDialog, ui } from '../../app/ui.js'; import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; import { notifications } from '../../core/notifications.js'; import { invalidateFolderMeta } from '../../model/filesModel.js'; import { triggerBrowserDownload } from '../../utils/download.js'; import { formatSavedSummary, tryDeltaUpload } from './deltaUpload.js'; /** * @typedef {Object} BatchResult * @property {number} success number of files|folders sucessfully updated * @property {number} errors number of files|folders in error * / /** * Get authorization headers for API requests. * Tokens are now in HttpOnly cookies — no explicit Authorization header needed. * @returns {Record} Headers object */ function getAuthHeaders() { return { ...getCsrfHeaders() }; } // File Operations Module const fileOps = { // ======================================================================== // Upload progress — notification bell integration // ======================================================================== /** @type {string | null} */ _currentBatchId: null, /** @type {boolean} */ _isUploading: false, // Guard against concurrent upload calls /** * Start a new upload batch in the notification bell * @param {number} totalFiles * @param {string} [folderName] */ _initUploadToast(totalFiles, folderName) { this._currentBatchId = notifications.addUploadBatch(totalFiles, folderName); }, /** * Finalise the batch in the notification bell * @param {number} successCount * @param {number} totalFiles * */ _finishUploadToast(successCount, totalFiles) { if (this._currentBatchId) { notifications.finishBatch(this._currentBatchId, successCount, totalFiles); } }, /** * Some drag-and-drop sources can inject directory placeholders into * DataTransfer.files. Browsers fail those with net::ERR_ACCESS_DENIED * when trying to send them as normal files. * @param {File} file * @returns {Promise} */ _canReadFileBlob(file) { return new Promise((resolve) => { try { const reader = new FileReader(); reader.onload = () => resolve(true); reader.onerror = () => resolve(false); reader.readAsArrayBuffer(file.slice(0, 1)); } catch (_) { resolve(false); } }); }, // FIXME: prefer exceptions for errors /** * @typedef {Object} UploadAnswer * @property {boolean} ok * @property {any} [data] * @property {string} [errorMsg] * @property {boolean} [isQuotaError] * @property {boolean} [isTimeout] */ /** * Upload a single file via XMLHttpRequest with progress events. * Progress is reported to the notification bell via batchId + fileName. * Returns a promise that resolves with { ok, data?, errorMsg?, isQuotaError? }. * @param {FormData} formData * @param {string} batchId * @param {string} fileName * @param {number} [timeoutMs=120000] */ _uploadFileXHR(formData, batchId, fileName, timeoutMs = 120000) { return new Promise((resolve) => { const xhr = new XMLHttpRequest(); const notif = notifications; // Do NOT set xhr.timeout — it is a TOTAL deadline from send() to // response and would kill large uploads even while data is flowing. // Instead we rely on the stall timer (no progress for N seconds) // and a generous hard deadline that scales with file size. xhr.timeout = 0; const hardDeadlineMs = Math.max(timeoutMs * 4, 600000); // min 10 min let lastProgressPctSent = -1; let isSettled = false; /** @type {ReturnType} */ let stallTimer = null; /** @type {ReturnType} */ let hardTimer = null; /** * * @param {number} pct * @param {'uploading' | 'done' | 'error'} status */ const safeUpdateFile = (pct, status) => { if (!notif || !batchId) return; try { notif.updateFile(batchId, fileName, pct, status); } catch (e) { console.warn('Notification update failed for upload row:', fileName, e); } }; /** * * @param {UploadAnswer} result * @returns */ const finalize = (result) => { if (isSettled) return; isSettled = true; if (stallTimer) { clearTimeout(stallTimer); stallTimer = null; } if (hardTimer) { clearTimeout(hardTimer); hardTimer = null; } resolve(result); }; const resetStallTimer = () => { if (stallTimer) clearTimeout(stallTimer); stallTimer = setTimeout(() => { try { xhr.abort(); } catch (_) {} safeUpdateFile(0, 'error'); finalize({ ok: false, isTimeout: true, errorMsg: `Upload stalled for ${Math.round(timeoutMs / 1000)}s` }); }, timeoutMs); }; resetStallTimer(); hardTimer = setTimeout(() => { try { xhr.abort(); } catch (_) {} safeUpdateFile(0, 'error'); finalize({ ok: false, isTimeout: true, errorMsg: `Upload hard timeout after ${Math.round(hardDeadlineMs / 1000)}s` }); }, hardDeadlineMs); xhr.upload.addEventListener('progress', (e) => { resetStallTimer(); if (e.lengthComputable) { const pct = Math.round((e.loaded / e.total) * 100); // Throttle UI updates: every 2% for smooth progress on large files if (pct === 100 || pct - lastProgressPctSent >= 2) { lastProgressPctSent = pct; safeUpdateFile(pct, 'uploading'); } } }); xhr.addEventListener('readystatechange', () => { // Keep watchdog alive while request is actively moving through states if (xhr.readyState > 1 && xhr.readyState < 4) { resetStallTimer(); } }); xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { safeUpdateFile(100, 'done'); let data = null; try { data = JSON.parse(xhr.responseText); } catch (_) {} finalize({ ok: true, data }); } else { safeUpdateFile(0, 'error'); // Parse error body for quota-exceeded or other messages let errorMsg = null; let isQuotaError = false; try { const errBody = JSON.parse(xhr.responseText); errorMsg = errBody.error || null; isQuotaError = errBody.error_type === 'QuotaExceeded' || xhr.status === 507; } catch (_) {} finalize({ ok: false, errorMsg, isQuotaError }); } }); xhr.addEventListener('error', () => { safeUpdateFile(0, 'error'); finalize({ ok: false }); }); xhr.addEventListener('abort', () => { safeUpdateFile(0, 'error'); finalize({ ok: false, isTimeout: true, errorMsg: `Upload aborted/stalled: ${fileName}` }); }); xhr.addEventListener('timeout', () => { safeUpdateFile(0, 'error'); finalize({ ok: false, isTimeout: true, errorMsg: `Timeout after ${Math.round(timeoutMs / 1000)}s` }); }); xhr.open('POST', '/api/files/upload'); // Auth is handled by HttpOnly cookies — no explicit header needed xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); // CSRF double-submit: echo the CSRF cookie as a request header const _csrfTok = getCsrfToken(); if (_csrfTok) xhr.setRequestHeader('X-CSRF-Token', _csrfTok); try { xhr.send(formData); } catch (e) { safeUpdateFile(0, 'error'); finalize({ ok: false, errorMsg: `Client send() failed: ${/** @type {Error} */ (e)?.message || 'unknown error'}` }); } }); }, /** * Upload a single file via fetch + AbortController. * Used by folder uploads to avoid browser XHR edge-cases with dragged entries. * Returns { ok, data?, errorMsg?, isQuotaError?, isTimeout? }. */ /** * * @param {*} formData * @param {*} timeoutMs * @returns {Promise} */ async _uploadFileFetch(formData, timeoutMs = 60000) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch('/api/files/upload', { method: 'POST', headers: { ...getAuthHeaders(), 'Cache-Control': 'no-cache, no-store, must-revalidate' }, body: formData, signal: controller.signal, cache: 'no-store' }); // Read body as text first (always consume the response fully) let rawText = ''; try { rawText = await response.text(); } catch (_) {} let body = null; try { body = JSON.parse(rawText); } catch (_) {} if (response.ok) { return { ok: true, data: body }; } const errorMsg = body && typeof body === 'object' ? body.error || null : rawText || null; const isQuotaError = (body && typeof body === 'object' && body.error_type === 'QuotaExceeded') || response.status === 507; return { ok: false, errorMsg, isQuotaError }; } catch (e) { const isTimeout = /** @type {Error} */ (e)?.name === 'AbortError'; return { ok: false, isTimeout, errorMsg: isTimeout ? `Timeout after ${Math.round(timeoutMs / 1000)}s` : `Fetch upload failed: ${/** @type {Error} */ (e)?.message || 'network error'}` }; } finally { clearTimeout(timeoutId); } }, // ======================================================================== // Upload files (via button or drag-and-drop) // ======================================================================== /** * Upload files to the server with real-time progress indication * @param {FileList | File[]} files - Files to upload */ async uploadFiles(files) { const originalFiles = Array.from(files || []); if (originalFiles.length === 0) return; // Guard: prevent concurrent upload calls (e.g. double drop events) if (this._isUploading) { console.warn('Upload already in progress, ignoring duplicate call'); return; } this._isUploading = true; try { // Legacy progress bar (inside dropzone) — keep working for drag-drop const progressBar = /** @type {HTMLDivElement} */ (document.querySelector('.progress-fill')); const uploadProgressDiv = document.querySelector('.upload-progress'); if (uploadProgressDiv) { uploadProgressDiv.classList.remove('hidden'); } if (progressBar) { progressBar.style.width = '0%'; } // Filter out unreadable entries (typically dropped folders/placeholders) /** @type {File[]} */ const readableFiles = []; /** @type {string[]} */ const skippedEntries = []; for (const f of originalFiles) { // eslint-disable-next-line no-await-in-loop const readable = await this._canReadFileBlob(f); if (readable) readableFiles.push(f); else skippedEntries.push(f.name || 'Unnamed entry'); } const totalFiles = readableFiles.length; if (skippedEntries.length > 0 && notifications) { const locale = i18n?.getCurrentLocale?.() || 'en'; const title = locale.startsWith('es') ? 'Entradas omitidas' : 'Entries skipped'; const text = locale.startsWith('es') ? `Se omitieron ${skippedEntries.length} carpeta(s)/entrada(s) no legibles. Usa "Subir carpeta".` : `${skippedEntries.length} unreadable folder/entry items were skipped. Use "Upload folder".`; notifications.addNotification({ icon: 'fa-folder-open', iconClass: 'upload', title, text }); } if (totalFiles === 0) { if (uploadProgressDiv) uploadProgressDiv.classList.add('hidden'); this._isUploading = false; return; } // Show upload notification (only for actual readable files) this._initUploadToast(totalFiles); const batchId = this._currentBatchId; let uploadedCount = 0; let successCount = 0; let quotaStop = false; let savedBytesTotal = 0; const targetFolderId = app.currentPath || app.userHomeFolderId; /** * Upload a single readable file by index. Shared counters are * mutated here; safe because JS runs the workers cooperatively * (no true parallelism between awaits). * @param {number} idx */ const uploadOneFile = async (idx) => { if (quotaStop) return; const file = readableFiles[idx]; // ── Delta upload: chunk + hash locally (worker/WASM) and // transfer only what the server doesn't already have for // this user. Any miss/failure falls back to a byte upload. /** @type {UploadAnswer & { savedBytes?: number } | null} */ let result = await tryDeltaUpload(file, targetFolderId, (pct) => { if (batchId) { try { notifications.updateFile(batchId, file.name, pct, 'uploading'); } catch (_) {} } }); if (result) { savedBytesTotal += result.savedBytes || 0; if (batchId) { try { notifications.updateFile(batchId, file.name, 100, result.ok ? 'done' : 'error'); } catch (_) {} } } else { const formData = new FormData(); if (targetFolderId) formData.append('folder_id', targetFolderId); formData.append('file', file); // Scale stall timeout with file size: // base 120s + 60s per GB, so a 7 GB file gets ~540s stall limit const sizeGB = file.size / (1024 * 1024 * 1024); const dynamicTimeout = Math.max(120000, 120000 + Math.ceil(sizeGB) * 60000); result = await this._uploadFileXHR(formData, batchId, file.name, dynamicTimeout); } uploadedCount++; // Legacy dropzone bar if (progressBar) { progressBar.style.width = `${(uploadedCount / totalFiles) * 100}%`; } // Notify bell of per-file completion if (batchId) { try { notifications.fileCompleted(batchId, result.ok); } catch (e) { console.warn('Batch progress update failed:', e); } } if (result.ok) { successCount++; console.log(`Successfully uploaded ${file.name}`, result.data); } else { console.error(`Upload error for ${file.name}`); if (result.isTimeout && notifications) { notifications.addNotification({ icon: 'fa-clock', iconClass: 'error', title: file.name, text: result.errorMsg || 'Upload timeout' }); } if (result.isQuotaError) { // Stop pulling new files; in-flight uploads still finish. quotaStop = true; const msg = result.errorMsg || i18n.t('storage_quota_exceeded'); if (notifications) { notifications.addNotification({ icon: 'fa-exclamation-triangle', iconClass: 'error', title: file.name, text: msg }); } } } }; // Pool-based concurrency: keep up to CONCURRENCY uploads in flight // instead of one at a time (mirrors uploadFolderEntries). Files are // independent, so this is ~CONCURRENCY× faster for many small files. const CONCURRENCY = 10; let nextIdx = 0; const runNext = async () => { while (nextIdx < totalFiles && !quotaStop) { const idx = nextIdx++; await uploadOneFile(idx); } }; const workers = []; for (let w = 0; w < Math.min(CONCURRENCY, totalFiles); w++) { workers.push(runNext()); } await Promise.all(workers); // All done this._finishUploadToast(successCount, totalFiles); if (savedBytesTotal > 0 && notifications) { notifications.addNotification({ icon: 'fa-bolt', iconClass: 'upload', title: i18n?.getCurrentLocale?.()?.startsWith('es') ? 'Subida delta' : 'Delta upload', text: formatSavedSummary(savedBytesTotal, i18n?.getCurrentLocale?.() || 'en') }); } // Refresh storage usage display try { await refreshUserData(); } catch (_) {} try { await loadFiles({ forceRefresh: true }); } catch (reloadError) { console.error('Error reloading files:', reloadError); } const dropzone = document.getElementById('dropzone'); if (dropzone) dropzone.classList.add('hidden'); if (uploadProgressDiv) uploadProgressDiv.classList.add('hidden'); } finally { this._isUploading = false; } }, /** * Upload folder files maintaining directory structure * Creates subfolders as needed, then uploads files into them * @param {FileList} files - Files from folder input (with webkitRelativePath) */ async uploadFolderFiles(files) { const entries = Array.from(files || []).map((file) => ({ file, relativePath: file.webkitRelativePath || file.name })); await this.uploadFolderEntries(entries); }, /** * Upload folder-like entries preserving relative paths. * @param {Array<{file: File, relativePath: string}>} entries */ async uploadFolderEntries(entries) { const rawEntries = Array.isArray(entries) ? entries : []; if (rawEntries.length === 0) return; // Guard: prevent concurrent upload calls if (this._isUploading) { console.warn('Upload already in progress, ignoring duplicate call'); return; } this._isUploading = true; const progressBar = /** @type {HTMLDivElement} */ (document.querySelector('.progress-fill')); const uploadProgressDiv = document.querySelector('.upload-progress'); if (uploadProgressDiv) { uploadProgressDiv.classList.remove('hidden'); } if (progressBar) { progressBar.style.width = '0%'; } try { // Filter unreadable entries /** @type {Array<{file: File, relativePath: string}>} */ const validEntries = []; for (const e of rawEntries) { // eslint-disable-next-line no-await-in-loop const readable = await this._canReadFileBlob(e.file); if (readable) validEntries.push(e); else console.warn(`Skipping unreadable folder entry: ${e.relativePath || e.file?.name}`); } const totalFiles = validEntries.length; if (totalFiles === 0) { if (uploadProgressDiv) uploadProgressDiv.classList.add('hidden'); return; } const currentFolderId = app.currentPath || app.userHomeFolderId; // Build folder structure from relative paths const folderMap = new Map(); folderMap.set('', currentFolderId); const folderPaths = new Set(); for (const entry of validEntries) { const rel = entry.relativePath || entry.file.name; const parts = rel.split('/'); for (let i = 1; i < parts.length; i++) { const path = parts.slice(0, i).join('/'); folderPaths.add(path); } } const sortedPaths = [...folderPaths].sort((a, b) => a.split('/').length - b.split('/').length); // Create folders first for (const folderPath of sortedPaths) { const parts = folderPath.split('/'); const folderName = parts[parts.length - 1]; const parentPath = parts.slice(0, -1).join('/'); const parentId = folderMap.get(parentPath) || currentFolderId; try { const response = await fetch('/api/folders', { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store, must-revalidate' }, body: JSON.stringify({ name: folderName, parent_id: parentId }) }); if (response.ok) { const folder = await response.json(); folderMap.set(folderPath, folder.id); console.log(`Created folder: ${folderPath} -> ${folder.id}`); } else { console.error(`Error creating folder ${folderPath}:`, await response.text()); } } catch (error) { console.error(`Network error creating folder ${folderPath}:`, error); } } // Detect root folder(s) from entry paths const rootFolderNames = [ ...new Set( validEntries .map((entry) => { const rel = entry.relativePath || entry.file.name; return rel.split('/')[0] || ''; }) .filter(Boolean) ) ]; const locale = i18n?.getCurrentLocale?.() || 'en'; const rootFolderLabel = rootFolderNames.length <= 1 ? rootFolderNames[0] || '' : locale.startsWith('es') ? `${rootFolderNames.length} carpetas` : `${rootFolderNames.length} folders`; // Upload files — pass folder name for folder-level progress display this._initUploadToast(totalFiles, rootFolderLabel); const batchId = this._currentBatchId; let uploadedCount = 0; let successCount = 0; let quotaStop = false; let savedBytesTotal = 0; // ── Concurrent upload with limited parallelism ────────── // FIFOs are pre-caught by the 0-byte arrayBuffer guard, // so all files reaching fetch() are regular. Keep-alive // reuses TCP connections across workers for speed. const CONCURRENCY = 10; const TIMEOUT_BASE_MS = 30000; // 30s base for normal files const TIMEOUT_PER_MB_MS = 2000; // +2s per MB (supports ≥4 Mbps) const TIMEOUT_MIN_MS = 10000; // floor for tiny files const TIMEOUT_MS_ZERO = 3000; // 3s for 0-byte files /** * * @param {number} idx * @returns */ const uploadOneFile = async (idx) => { if (quotaStop) return; const entry = validEntries[idx]; const file = entry.file; const rel = entry.relativePath || file.name; /** @type {UploadAnswer} */ let result = { ok: false, errorMsg: 'Unknown client error' }; try { const parts = rel.split('/'); const parentPath = parts.slice(0, -1).join('/'); const targetFolderId = folderMap.get(parentPath) || currentFolderId; // ── Delta upload (only changed bytes on the wire) ── // Same fallback contract as uploadFiles: a null result // means "do the byte upload". The shared accounting // after this try block handles both outcomes. const delta = await tryDeltaUpload(file, targetFolderId); if (delta) { result = delta; savedBytesTotal += delta.savedBytes || 0; } else { // ── FIFO/pipe guard (0-byte files only) ── // Named pipes (runit supervise/control) report size=0 // but block on open(). Pre-read only 0-byte files into // memory; files with size>0 are always regular files and // go straight to FormData (zero extra memory copy). /** @type {Blob} */ let uploadFile = file; // default: use original File if (file.size === 0) { try { const buf = await Promise.race([ file.arrayBuffer(), new Promise((_, rej) => setTimeout(() => rej(new Error('read-timeout')), 2000)) ]); uploadFile = new Blob([buf], { type: file.type || 'application/octet-stream' }); } catch { console.warn(`[SKIP] #${idx} ${rel} — cannot read 0-byte file (FIFO/pipe?), skipping`); uploadedCount++; successCount++; if (batchId) { try { notifications.fileCompleted(batchId, true); } catch (_) {} } return; } } const formData = new FormData(); formData.append('folder_id', targetFolderId); formData.append('file', uploadFile, file.name); const thisTimeout = file.size === 0 ? TIMEOUT_MS_ZERO : Math.max(TIMEOUT_MIN_MS, TIMEOUT_BASE_MS + Math.ceil(file.size / (1024 * 1024)) * TIMEOUT_PER_MB_MS); result = await this._uploadFileFetch(formData, thisTimeout); } } catch (e) { result = { ok: false, errorMsg: `Client exception: ${/** @type {Error} */ (e)?.message || 'unknown'}` }; console.error(`[UPLOAD EXCEPTION] #${idx} ${rel}:`, e); } uploadedCount++; if (batchId) { try { notifications.fileCompleted(batchId, result.ok); } catch (_) {} } if (progressBar && uploadedCount % 10 === 0) { progressBar.style.width = `${(uploadedCount / totalFiles) * 100}%`; } if (uploadedCount % 50 === 0 || uploadedCount === totalFiles) { console.log(`Progress: ${uploadedCount}/${totalFiles} (${successCount} ok)`); } if (result.ok) { successCount++; } else if (result.isQuotaError) { quotaStop = true; if (notifications) { notifications.addNotification({ icon: 'fa-exclamation-triangle', iconClass: 'error', title: file.name, text: result.errorMsg || 'Storage quota exceeded' }); } } }; // Pool-based concurrency: always keep CONCURRENCY tasks in flight let nextIdx = 0; const runNext = async () => { while (nextIdx < totalFiles && !quotaStop) { const idx = nextIdx++; await uploadOneFile(idx); } }; const workers = []; for (let w = 0; w < Math.min(CONCURRENCY, totalFiles); w++) { workers.push(runNext()); } await Promise.all(workers); this._finishUploadToast(successCount, totalFiles); if (savedBytesTotal > 0 && notifications) { notifications.addNotification({ icon: 'fa-bolt', iconClass: 'upload', title: i18n?.getCurrentLocale?.()?.startsWith('es') ? 'Subida delta' : 'Delta upload', text: formatSavedSummary(savedBytesTotal, i18n?.getCurrentLocale?.() || 'en') }); } try { await refreshUserData(); } catch (_) {} try { await loadFiles({ forceRefresh: true }); } catch (reloadError) { console.error('Error reloading files:', reloadError); } const dropzone = document.getElementById('dropzone'); if (dropzone) dropzone.classList.add('hidden'); if (uploadProgressDiv) uploadProgressDiv.classList.add('hidden'); } finally { this._isUploading = false; } }, /** * Create a new folder * @param {string} name - Folder name */ /** * Create a new folder * @param {string} name - Folder name */ async createFolder(name) { try { console.log('Creating folder with name:', name); // Send the actual request to the backend to create the folder const response = await fetch('/api/folders', { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store, must-revalidate' }, body: JSON.stringify({ name: name, parent_id: app.currentPath || app.userHomeFolderId || null }) }); if (response.ok) { // Get the created folder from the backend const folder = await response.json(); console.log('Folder created successfully:', folder); // Optimistic UI: add folder card directly from server response // — no reload needed since the backend already confirmed creation. filesViewAddItem(folder); ui.showNotification(i18n.t('notif.folderCreated'), i18n.t('notif.folderCreatedBody', { name })); } else { const errorText = await response.text(); console.error('Create folder error:', errorText); let errorMessage = 'Unknown error'; try { const errorData = JSON.parse(errorText); errorMessage = errorData.error || response.statusText; } catch (_e) { errorMessage = errorText || response.statusText; } throw new Error(errorMessage); } } catch (error) { console.error('Error creating folder:', error); throw error; } }, /** * Move a file to another folder * @param {string} fileId - File ID * @param {string} targetFolderId - Target folder ID * @returns {Promise} - Success status */ async moveFile(fileId, targetFolderId) { try { const response = await fetch(`/api/files/${fileId}/move`, { method: 'PUT', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id: targetFolderId === '' ? null : targetFolderId }) }); if (response.ok) { // Reload files after moving await loadFiles(); ui.showNotification(i18n.t('notif.fileMoved'), i18n.t('notif.fileMovedBody')); return true; } else { let errorMessage = 'Unknown error'; try { const errorData = await response.json(); errorMessage = errorData.error || 'Unknown error'; } catch (_e) { errorMessage = 'Error processing server response'; } ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.fileMoveError', { error: errorMessage })); return false; } } catch (error) { console.error('Error moving file:', error); ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.fileMoveErrorGeneric')); return false; } }, /** * Move a folder to another folder * @param {string} folderId - Folder ID * @param {string} targetFolderId - Target folder ID * @returns {Promise} - Success status */ async moveFolder(folderId, targetFolderId) { try { const response = await fetch(`/api/folders/${folderId}/move`, { method: 'PUT', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ parent_id: targetFolderId === '' ? null : targetFolderId }) }); if (response.ok) { // Parent changed — drop the cached breadcrumb metadata invalidateFolderMeta(folderId); // Reload files after moving await loadFiles(); ui.showNotification(i18n.t('notif.folderMoved'), i18n.t('notif.folderMovedBody')); return true; } else { let errorMessage = 'Unknown error'; try { const errorData = await response.json(); errorMessage = errorData.error || 'Unknown error'; } catch (_e) { errorMessage = 'Error processing server response'; } ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.folderMoveError', { error: errorMessage })); return false; } } catch (error) { console.error('Error moving folder:', error); ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.folderMoveErrorGeneric')); return false; } }, /** * Move files & folders * @param {string[]} fileIds - File IDs * @param {string[]} folderIds - Folder IDs * @param {string} targetFolderId - Target folder ID * @returns {Promise} - Success status */ async batchMove(fileIds, folderIds, targetFolderId) { // TODO ensure not moving a folder into itself let success = 0, errors = 0; try { // Batch move files in a single request if (fileIds.length > 0) { const res = await fetch('/api/batch/files/move', { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ file_ids: fileIds, target_folder_id: targetFolderId }) }); const data = await res.json(); success += data.stats?.successful || 0; errors += data.stats?.failed || 0; } // Batch move folders in a single request if (folderIds.length > 0) { const res = await fetch('/api/batch/folders/move', { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_ids: folderIds, target_folder_id: targetFolderId }) }); const data = await res.json(); success += data.stats?.successful || 0; errors += data.stats?.failed || 0; // Parents changed — drop the cached breadcrumb metadata for (const id of folderIds) invalidateFolderMeta(id); } } catch (err) { console.error('Batch move error:', err); errors++; } return { success, errors }; }, /** * Copy a file to another folder * @param {string} fileId - File ID * @param {string} targetFolderId - Target folder ID * @returns {Promise} - Success status */ async copyFile(fileId, targetFolderId) { try { const response = await fetch('/api/batch/files/copy', { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ file_ids: [fileId], target_folder_id: targetFolderId === '' ? null : targetFolderId }) }); if (response.ok) { await response.json(); // Reload files after copying await loadFiles(); ui.showNotification(i18n.t('notif.fileCopied'), i18n.t('notif.fileCopiedBody')); return true; } else { let errorMessage = 'Unknown error'; try { const errorData = await response.json(); errorMessage = errorData.error || 'Unknown error'; } catch (_e) { errorMessage = 'Error processing server response'; } ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.fileCopyError', { error: errorMessage })); return false; } } catch (error) { console.error('Error copying file:', error); ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.fileCopyErrorGeneric')); return false; } }, /** * Copy a folder to another folder * @param {string} folderId - Folder ID * @param {string} targetFolderId - Target folder ID * @returns {Promise} - Success status */ async copyFolder(folderId, targetFolderId) { const res = await fetch('/api/batch/folders/copy', { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_ids: [folderId], target_folder_id: targetFolderId }) }); return res.ok; }, /** * Copy files & folders * @param {string[]} fileIds - File IDs * @param {string[]} folderIds - Folder IDs * @param {string} targetFolderId - Target folder ID * @returns {Promise} - Success status */ async batchCopy(fileIds, folderIds, targetFolderId) { // FIXME ensure not moving a folder into itself let success = 0, errors = 0; try { // Batch copy files if (fileIds.length > 0) { const res = await fetch('/api/batch/files/copy', { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ file_ids: fileIds, target_folder_id: targetFolderId }) }); const data = await res.json(); success += data.stats?.successful || 0; errors += data.stats?.failed || (!res.ok && !data.stats ? fileIds.length : 0); } // Batch copy folders if (folderIds.length > 0) { const res = await fetch('/api/batch/folders/copy', { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_ids: folderIds, target_folder_id: targetFolderId }) }); const data = await res.json(); success += data.stats?.successful || 0; errors += data.stats?.failed || (!res.ok && !data.stats ? folderIds.length : 0); } } catch (err) { console.error('Batch copy error:', err); errors++; } return { success, errors }; }, /** * Rename a file * @param {string} fileId - File ID * @param {string} newName - New file name * @returns {Promise} - Success status */ /** * Rename a file * @param {string} fileId - File ID * @param {string} newName - New file name */ async renameFile(fileId, newName) { try { console.log(`Renaming file ${fileId} to "${newName}"`); const response = await fetch(`/api/files/${fileId}/rename`, { method: 'PUT', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }) }); console.log('Response status:', response.status); if (response.ok) { ui.showNotification(i18n.t('notifications.file_renamed'), i18n.t('notifications.file_renamed_to', { name: newName })); } else { const errorText = await response.text(); console.error('Error response:', errorText); try { const errorData = JSON.parse(errorText); throw new Error(errorData.error || response.statusText); } catch (parseError) { if (parseError instanceof SyntaxError) { throw new Error(errorText || response.statusText); } throw parseError; } } } catch (error) { console.error('Error renaming file:', error); throw error; } }, /** * Rename a folder * @param {string} folderId - Folder ID * @param {string} newName - New folder name */ async renameFolder(folderId, newName) { try { console.log(`Renaming folder ${folderId} to "${newName}"`); const response = await fetch(`/api/folders/${folderId}/rename`, { method: 'PUT', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }) }); console.log('Response status:', response.status); if (response.ok) { // Name changed — drop the cached breadcrumb metadata invalidateFolderMeta(folderId); ui.showNotification(i18n.t('notif.folderRenamed'), i18n.t('notif.folderRenamedBody', { name: newName })); } else { const errorText = await response.text(); console.error('Error response:', errorText); try { // Try to parse as JSON const errorData = JSON.parse(errorText); throw new Error(errorData.error || response.statusText); } catch (parseError) { if (parseError instanceof SyntaxError) { throw new Error(errorText || response.statusText); } throw parseError; } } } catch (error) { console.error('Error renaming folder:', error); throw error; } }, /** * Move a file to trash * @param {string} fileId - File ID * @param {string} fileName - File name * @returns {Promise} - Success status */ async deleteFile(fileId, fileName) { const confirmed = await showConfirmDialog({ title: i18n.t('dialogs.confirm_delete'), message: i18n.t('dialogs.confirm_delete_file', { name: fileName }), confirmText: i18n.t('actions.delete') }); if (!confirmed) return false; try { // Use the trash API endpoint const response = await fetch(`/api/trash/files/${fileId}`, { method: 'DELETE', headers: getAuthHeaders() }); if (response.ok) { loadFiles(); ui.showNotification(i18n.t('notif.fileTrashed'), i18n.t('notif.fileTrashedBody', { name: fileName })); return true; } else { // Fallback to direct deletion if trash fails const fallbackResponse = await fetch(`/api/files/${fileId}`, { method: 'DELETE', headers: getAuthHeaders() }); if (fallbackResponse.ok) { loadFiles(); ui.showNotification(i18n.t('notif.fileDeleted'), i18n.t('notif.fileDeletedBody', { name: fileName })); return true; } else { ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.fileDeleteError')); return false; } } } catch (error) { console.error('Error deleting file:', error); ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.fileDeleteError')); return false; } }, /** * Move a folder to trash * @param {string} folderId - Folder ID * @param {string} folderName - Folder name * @returns {Promise} - Success status */ async deleteFolder(folderId, folderName) { const confirmed = await showConfirmDialog({ title: i18n.t('dialogs.confirm_delete'), message: i18n.t('dialogs.confirm_delete_folder', { name: folderName }), confirmText: i18n.t('actions.delete') }); if (!confirmed) return false; try { // Use the trash API endpoint const response = await fetch(`/api/trash/folders/${folderId}`, { method: 'DELETE', headers: getAuthHeaders() }); if (response.ok) { // If we're inside the folder we just deleted, go back up if (app.currentPath === folderId) { app.currentPath = ''; ui.updateBreadcrumb(); } loadFiles(); ui.showNotification(i18n.t('notif.folderTrashed'), i18n.t('notif.folderTrashedBody', { name: folderName })); return true; } else { // Fallback to direct deletion if trash fails const fallbackResponse = await fetch(`/api/folders/${folderId}`, { method: 'DELETE', headers: getAuthHeaders() }); if (fallbackResponse.ok) { // If we're inside the folder we just deleted, go back up if (app.currentPath === folderId) { app.currentPath = ''; ui.updateBreadcrumb(); } loadFiles(); ui.showNotification(i18n.t('notif.folderDeleted'), i18n.t('notif.folderDeletedBody', { name: folderName })); return true; } else { ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.folderDeleteError')); return false; } } } catch (error) { console.error('Error deleting folder:', error); ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.folderDeleteError')); return false; } }, /** * Restore an item from trash * @param {string} trashId - Trash item ID * @returns {Promise} - Operation success */ async restoreFromTrash(trashId) { try { const response = await fetch(`/api/trash/${trashId}/restore`, { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); if (response.ok) { ui.showNotification(i18n.t('notif.itemRestored'), i18n.t('notif.itemRestoredBody')); return true; } else { ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.itemRestoreError')); return false; } } catch (error) { console.error('Error restoring item from trash:', error); ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.itemRestoreError')); return false; } }, /** * Permanently delete a trash item * @param {string} trashId - Trash item ID * @returns {Promise} - Operation success */ async deletePermanently(trashId) { const confirmed = await showConfirmDialog({ title: i18n.t('dialogs.confirm_permanent_delete'), message: i18n.t('dialogs.confirm_permanent_delete_msg'), confirmText: i18n.t('actions.delete_permanently') }); if (!confirmed) return false; try { const response = await fetch(`/api/trash/${trashId}`, { method: 'DELETE', headers: getAuthHeaders() }); if (response.ok) { ui.showNotification(i18n.t('notif.itemDeleted'), i18n.t('notif.itemDeletedBody')); return true; } else { ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.itemDeleteError')); return false; } } catch (error) { console.error('Error deleting item permanently:', error); ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.itemDeleteError')); return false; } }, /** * Empty the trash * @returns {Promise} - Operation success */ async emptyTrash() { const confirmed = await showConfirmDialog({ title: i18n.t('dialogs.confirm_empty_trash'), message: i18n.t('trash.empty_confirm'), confirmText: i18n.t('actions.empty_trash') }); if (!confirmed) return false; try { const response = await fetch('/api/trash/empty', { method: 'DELETE', headers: getAuthHeaders() }); if (response.ok) { ui.showNotification(i18n.t('notif.trashEmptied'), i18n.t('notif.trashEmptiedBody')); return true; } else { ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.trashEmptyError')); return false; } } catch (error) { console.error('Error emptying trash:', error); ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.trashEmptyError')); return false; } }, /** * Download a file — handed to the browser so it streams to disk with * its native download UI instead of buffering the file in memory. * @param {string} fileId - File ID * @param {string} fileName - File name */ async downloadFile(fileId, fileName) { triggerBrowserDownload(`/api/files/${fileId}`, fileName); }, /** * Download a folder as ZIP * @param {string} folderId - Folder ID * @param {string} folderName - Folder name */ async downloadFolder(folderId, folderName) { // Show notification to user (the server still has to assemble the // ZIP before the browser's own download UI takes over). ui.showNotification(i18n.t('notif.preparingDownload'), i18n.t('notif.preparingDownloadBody')); triggerBrowserDownload(`/api/folders/${folderId}/download?format=zip`, `${folderName}.zip`); } }; export { fileOps, getAuthHeaders };