Files
Oxicloud/static/js/features/files/fileOperations.js
T
Claude 5d034b0d09 Delta-upload client: FastCDC in WASM + overlapped worker pipeline
Phase 2 — the client side of "upload only what changed", closing the
delta-sync plan.

WASM (wasm/oxicloud-hash): DeltaChunker adds incremental FastCDC with
the server's exact crate and parameters (64K/256K/1M) next to the BLAKE3
hasher. The incremental split is provably identical to a single pass:
every chunk except the last ends on a content/max-size condition whose
decision window was fully buffered, so only the tail is provisional and
re-examined as slices arrive. A mirror test — the client twin of the
server's stream≡slice test — chunks 4 MiB of xorshift noise with
adversarial slice sizes (7 B … 8 MiB) and requires boundary-for-boundary
equality with one FastCDC pass. Vendored artifacts rebuilt (55 KB wasm).

Worker (static/js/workers/deltaWorker.js): the full protocol off the
main thread with OVERLAPPED stages — 8 MiB file slices feed the chunker
while earlier batches (256 hashes) negotiate and their missing chunks
upload through a 2-deep PUT pool (≤8 MiB framed bodies, bytes re-sliced
from the File at send time, never hoarded). Commit handles 409
still_missing by uploading exactly the named hashes and retrying.

Orchestrator (features/files/deltaUpload.js): threshold (8 MiB),
worker lifecycle + size-scaled timeout, progress relay to the upload
bell, conclusive-outcome mapping (201/200, 507 quota, 409 name
conflict) and silent fallback to the byte upload for everything else.
Wired into uploadFiles and uploadFolderEntries, which now surface one
batch summary of the bytes dedup saved. This subsumes the whole-file
instant-upload module — a fully-known file negotiates to nothing
missing and the commit short-circuits on possession — so
instantUpload.js and hashWorker.js are removed (the /api/dedup/check
and /api/files/by-hash endpoints remain for API clients).

Verified end-to-end against PostgreSQL 16 — the cross-boundary proof
the whole design hangs on, in both directions: a 24 MB file byte-
uploaded (server-side CDC) then edited and delta-negotiated with
WASM-computed chunks reported missing 1/74 (boundaries bit-identical),
synced with 344 KB on the wire vs 24 MB (98.6% saved) and downloaded
byte-identical; inversely, a file created via delta then byte-uploaded
as identical content produced a server-side manifest DEDUP HIT with the
same content_hash. Insertion at the head of the file (the adversarial
CDC case) still negotiated missing 1/74. Chunk+hash throughput ≈275 MB/s
in V8 with SIMD128.

https://claude.ai/code/session_01WdNenpnujNR2sc32XVvwfS
2026-06-11 15:44:44 +00:00

1444 lines
55 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('Folder created', `"${name}" created successfully`);
} 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('File moved', 'File moved successfully');
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('Error', `Error moving the file: ${errorMessage}`);
return false;
}
} catch (error) {
console.error('Error moving file:', error);
ui.showNotification('Error', 'Error moving the file');
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('Folder moved', 'Folder moved successfully');
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('Error', `Error moving the folder: ${errorMessage}`);
return false;
}
} catch (error) {
console.error('Error moving folder:', error);
ui.showNotification('Error', 'Error moving the folder');
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('File copied', 'File copied successfully');
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('Error', `Error copying the file: ${errorMessage}`);
return false;
}
} catch (error) {
console.error('Error copying file:', error);
ui.showNotification('Error', 'Error copying the file');
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('Folder renamed', `Folder renamed to "${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('File moved to trash', `"${fileName}" moved to trash`);
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('File deleted', `"${fileName}" deleted successfully`);
return true;
} else {
ui.showNotification('Error', 'Error deleting the file');
return false;
}
}
} catch (error) {
console.error('Error deleting file:', error);
ui.showNotification('Error', 'Error deleting the file');
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('Folder moved to trash', `"${folderName}" moved to trash`);
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('Folder deleted', `"${folderName}" deleted successfully`);
return true;
} else {
ui.showNotification('Error', 'Error deleting the folder');
return false;
}
}
} catch (error) {
console.error('Error deleting folder:', error);
ui.showNotification('Error', 'Error deleting the folder');
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('Item restored', 'Item restored successfully');
return true;
} else {
ui.showNotification('Error', 'Error restoring the item');
return false;
}
} catch (error) {
console.error('Error restoring item from trash:', error);
ui.showNotification('Error', 'Error restoring the item');
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('Item deleted', 'Item permanently deleted');
return true;
} else {
ui.showNotification('Error', 'Error deleting the item');
return false;
}
} catch (error) {
console.error('Error deleting item permanently:', error);
ui.showNotification('Error', 'Error deleting the item');
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('Trash emptied', 'The trash has been emptied successfully');
return true;
} else {
ui.showNotification('Error', 'Error emptying the trash');
return false;
}
} catch (error) {
console.error('Error emptying trash:', error);
ui.showNotification('Error', 'Error emptying the trash');
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('Preparing download', 'Preparing the folder for download...');
triggerBrowserDownload(`/api/folders/${folderId}/download?format=zip`, `${folderName}.zip`);
}
};
export { fileOps, getAuthHeaders };