Files
ObsiGate/frontend/js/upload.js
T
bruno ba7b49c35b
CI / lint (push) Successful in 1m17s
CI / security (push) Successful in 51s
CI / test (push) Successful in 3m11s
CI / build (push) Successful in 48s
CI / e2e (push) Successful in 11m2s
fix(dragdrop): amélioration sélection répertoire cible, déplacement arborescence et ouverture auto IA (#89)
2026-09-14 15:55:41 -04:00

303 lines
11 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.
/**
* ObsiGate — File & Folder Upload & Drag-and-Drop Core Module (#89).
*
* Provides utilities for:
* - Traversing recursive directories and files from DataTransfer / FileSystem API
* - Reading files into base64 / text payloads
* - Batch uploading to `/api/vault/{vault}/batch-upload`
* - Managing drop progress modals and confirmation dialogs
*/
import { api } from './auth.js';
import { t } from './i18n.js';
import { showToast } from './ui.js';
import { refreshSidebarTreePreservingState } from './sidebar.js';
/**
* Recursively read all FileSystemEntry items (files and directories) from DataTransferItemList.
* Falls back to plain File objects if FileSystem API is unavailable.
*
* @param {DataTransferItemList|FileList|File[]} items - DataTransfer items or files
* @returns {Promise<Array<{ path: string, file?: File, isDir?: boolean }>>}
*/
export async function readDataTransferEntries(items) {
const results = [];
if (!items) return results;
// Check if items support webkitGetAsEntry
const entries = [];
if (items.length && typeof items[0].webkitGetAsEntry === 'function') {
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry();
if (entry) entries.push(entry);
}
}
if (entries.length > 0) {
// Traverse entries recursively
for (const entry of entries) {
await traverseFileSystemEntry(entry, '', results);
}
} else {
// Plain FileList or files
for (let i = 0; i < items.length; i++) {
const file = items[i] instanceof File ? items[i] : items[i].getAsFile ? items[i].getAsFile() : null;
if (file) {
const relPath = file.webkitRelativePath || file.name;
results.push({
path: relPath.replace(/\\/g, '/'),
file,
isDir: false,
});
}
}
}
return results;
}
/**
* Traverse a single FileSystemEntry recursively.
*/
async function traverseFileSystemEntry(entry, parentPath, results) {
const currentPath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
if (entry.isFile) {
const file = await new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
results.push({
path: currentPath.replace(/\\/g, '/'),
file,
isDir: false,
});
} else if (entry.isDirectory) {
results.push({
path: currentPath.replace(/\\/g, '/'),
isDir: true,
});
const dirReader = entry.createReader();
const readAllEntries = async () => {
const childEntries = await new Promise((resolve, reject) => {
dirReader.readEntries(resolve, reject);
});
if (childEntries.length > 0) {
for (const child of childEntries) {
await traverseFileSystemEntry(child, currentPath, results);
}
await readAllEntries(); // Read next batch (readEntries returns max 100 at a time)
}
};
await readAllEntries();
}
}
/**
* Convert a File object to base64 data string.
* @param {File} file
* @returns {Promise<string>}
*/
export function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const res = reader.result;
if (typeof res === 'string') {
resolve(res);
} else {
reject(new Error('Failed to read file as base64 string'));
}
};
reader.onerror = (err) => reject(err);
reader.readAsDataURL(file);
});
}
/**
* Upload a batch of extracted items into a vault and target folder.
*
* @param {string} vault - Target vault name
* @param {string} targetDir - Target relative directory (empty for root)
* @param {Array<{ path: string, file?: File, isDir?: boolean }>} items - Extracted items
* @param {Object} [options]
* @param {boolean} [options.overwrite=true]
* @param {Function} [options.onProgress] - Callback (current, total)
* @returns {Promise<{ success: boolean, uploaded: string[], created_dirs: string[], errors: any[] }>}
*/
export async function uploadBatch(vault, targetDir, items, options = {}) {
if (!vault || !items || !items.length) {
return { success: true, uploaded: [], created_dirs: [], errors: [], total_files: 0 };
}
const { overwrite = true, onProgress = null } = options;
const payloadFiles = [];
const total = items.length;
for (let i = 0; i < total; i++) {
const item = items[i];
if (onProgress) onProgress(i, total, item.path);
if (item.isDir) {
payloadFiles.push({
path: item.path,
is_dir: true,
});
} else if (item.file) {
try {
const b64 = await fileToBase64(item.file);
payloadFiles.push({
path: item.path,
content: b64,
is_dir: false,
});
} catch (err) {
console.warn(`Failed to encode file ${item.path}:`, err);
}
}
}
if (onProgress) onProgress(total, total, 'Sending payload...');
const response = await api(`/api/vault/${encodeURIComponent(vault)}/batch-upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
target_dir: targetDir || '',
files: payloadFiles,
overwrite,
}),
});
return response;
}
/**
* Show a simple upload modal / toast and execute the upload.
*
* @param {string} vault
* @param {string} targetDir
* @param {Array<{ path: string, file?: File, isDir?: boolean }>} items
*/
export async function handleVaultDropUpload(vault, targetDir, items) {
if (!items || !items.length) return;
// If target directory is not explicitly passed or if dropping on vault root,
// allow user to choose or confirm the target folder
let chosenTargetDir = targetDir || '';
if (targetDir === undefined || targetDir === null) {
const picked = await promptTargetFolder(vault, chosenTargetDir, items);
if (picked === null) return; // User cancelled
chosenTargetDir = picked;
}
const fileCount = items.filter((x) => !x.isDir).length;
const destName = chosenTargetDir ? `${vault}/${chosenTargetDir}` : vault;
showToast(`${t('upload.uploading', { count: fileCount }) || `Uploading ${fileCount} file(s) to ${destName}...`}`, 'info');
try {
const res = await uploadBatch(vault, chosenTargetDir, items);
if (res && res.success) {
showToast(t('upload.success', { count: res.uploaded.length }) || `Imported ${res.uploaded.length} file(s) successfully`, 'success');
} else if (res && res.uploaded && res.uploaded.length > 0) {
showToast(t('upload.partial', { count: res.uploaded.length, errors: res.errors.length }) || `Imported ${res.uploaded.length} file(s) (${res.errors.length} errors)`, 'warning');
} else {
const errDetail = res?.errors?.[0]?.error || 'Unknown error';
showToast(`${t('upload.error') || 'Upload failed'}: ${errDetail}`, 'error');
}
await refreshSidebarTreePreservingState();
} catch (err) {
console.error('Batch upload error:', err);
showToast(`${t('upload.error') || 'Upload failed'}: ${err.message || err}`, 'error');
}
}
/**
* Display a folder picker modal for choosing the upload destination folder.
*
* @param {string} vault - Vault name
* @param {string} currentDir - Initial folder path
* @param {Array} items - Items to upload
* @returns {Promise<string|null>} Selected folder path (empty for root) or null if cancelled.
*/
export async function promptTargetFolder(vault, currentDir = '', items = []) {
let folders = [];
try {
const res = await api(`/api/vault/${encodeURIComponent(vault)}/paths?limit=5000`);
if (res && Array.isArray(res.paths)) {
folders = res.paths
.filter((p) => p.type === 'directory')
.map((p) => p.path)
.sort();
}
} catch (e) {
console.warn('Could not fetch vault directory paths:', e);
}
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'obsigate-modal-overlay active';
overlay.style.zIndex = '100000';
const fileCount = items.filter((x) => !x.isDir).length;
const dirCount = items.filter((x) => x.isDir).length;
let optionsHtml = `<option value="">/ (${t('upload.vault_root') || 'Racine du vault'})</option>`;
for (const folder of folders) {
const selected = folder === currentDir ? 'selected' : '';
optionsHtml += `<option value="${escapeHtml(folder)}" ${selected}>📁 ${escapeHtml(folder)}</option>`;
}
overlay.innerHTML = `
<div class="obsigate-modal" style="max-width: 480px;">
<div class="modal-header">
<h3>📁 ${t('upload.choose_target_folder') || 'Choisir le dossier de destination'}</h3>
<button class="modal-close" type="button" aria-label="Fermer">×</button>
</div>
<div class="modal-body" style="padding: 16px;">
<p style="margin-bottom: 12px; color: var(--text-muted);">
${t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`}
</p>
<label style="display: block; font-weight: 500; margin-bottom: 6px;">
${t('upload.target_directory') || 'Dossier de destination dans'} <strong>${escapeHtml(vault)}</strong> :
</label>
<select id="obsigate-upload-folder-select" class="form-select" style="width: 100%; padding: 8px 12px; margin-bottom: 16px; border-radius: 6px; background: var(--bg-secondary); color: var(--text-normal); border: 1px solid var(--border-color);">
${optionsHtml}
</select>
<div style="display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px;">
<button type="button" class="btn btn-secondary modal-cancel-btn">${t('button.cancel') || 'Annuler'}</button>
<button type="button" class="btn btn-primary modal-confirm-btn">${t('upload.import_button') || 'Importer'}</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
const close = (val) => {
overlay.remove();
resolve(val);
};
overlay.querySelector('.modal-close').addEventListener('click', () => close(null));
overlay.querySelector('.modal-cancel-btn').addEventListener('click', () => close(null));
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close(null);
});
overlay.querySelector('.modal-confirm-btn').addEventListener('click', () => {
const select = overlay.querySelector('#obsigate-upload-folder-select');
close(select ? select.value : '');
});
});
}
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}