Files
Oxicloud/static/js/features/files/fileOperations.js
T
DioCrafts 81a93a489b feat: photo/video capture-date pipeline + premium UI/UX overhaul
Backend — Photos timeline now groups by real capture date instead of upload time. New MediaMetadataService (FileLifecycleHook) extracts EXIF DateTimeOriginal from images and container creation_time from videos (mov/mp4/mkv) via nom-exif, timezone-correct (OffsetTimeOriginal), persisting captured_at so the existing media_sort_date trigger takes over. Adds POST /admin/photos/metadata/reextract to backfill existing media. Falls back to upload date when no embedded date exists.

Frontend — premium grid cards: combined metadata line (relative date · size, owner avatar when shared), custom selection checkbox with a clear checked state, uniform full-width 4:3 thumbnail tiles independent of filename length, centered file-type icons, and a hit-test fix so checkbox/star/kebab clicks reach the controls (the decorative thumbnail no longer captures pointer events). Notification messages internationalised across all 16 locales. Broader polish: design tokens, a11y/focus-visible states, brand + PWA assets.

Chore — bump semver-compatible dependencies (cargo upgrade); add nom-exif 3.6.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:24:27 +02:00

1444 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<String, String>} 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<boolean>}
*/
_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<typeof setTimeout>} */
let stallTimer = null;
/** @type {ReturnType<typeof setTimeout>} */
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<UploadAnswer>}
*/
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<boolean>} - 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<boolean>} - 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<BatchResult>} - 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<boolean>} - 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<boolean>} - 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<BatchResult>} - 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<boolean>} - 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<boolean>} - 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<boolean>} - 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<boolean>} - 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<boolean>} - 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<boolean>} - 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 };