Files
ObsiGate/frontend/js/upload.js
T
bruno 96bb1cbcae
CI / lint (push) Successful in 1m18s
CI / security (push) Successful in 51s
CI / test (push) Successful in 2m44s
CI / build (push) Successful in 48s
CI / e2e (push) Successful in 10m32s
fix(viewer): rendu unifié type markdown pour fichiers texte/JSON/CSV + autofocus arborescence apres import (#89)
2026-09-14 18:15:21 -04:00

422 lines
15 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, focusPathInSidebar } 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;
let chosenVault = vault;
let chosenTargetDir = targetDir || '';
// If target directory is not explicitly passed or if dropping from global overlay,
// allow user to choose or confirm the target vault and folder
if (targetDir === undefined || targetDir === null) {
const picked = await promptTargetFolder(vault, chosenTargetDir, items);
if (picked === null) return; // User cancelled
chosenVault = picked.vault || chosenVault;
chosenTargetDir = picked.targetDir || '';
}
const fileCount = items.filter((x) => !x.isDir).length;
const destName = chosenTargetDir ? `${chosenVault}/${chosenTargetDir}` : chosenVault;
showToast(`${t('upload.uploading', { count: fileCount }) || `Uploading ${fileCount} file(s) to ${destName}...`}`, 'info');
try {
const res = await uploadBatch(chosenVault, 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();
if (chosenTargetDir) {
try {
await focusPathInSidebar(chosenVault, chosenTargetDir, { alignToTop: true, expandTarget: true });
} catch (_) { /* ignore */ }
} else if (res?.uploaded?.[0]) {
try {
await focusPathInSidebar(chosenVault, res.uploaded[0], { alignToTop: true, expandTarget: true });
} catch (_) { /* ignore */ }
}
} 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 vault and folder.
*
* @param {string} initialVault - Default vault name
* @param {string} initialDir - Initial folder path
* @param {Array} items - Items to upload
* @returns {Promise<{ vault: string, targetDir: string }|null>} Selected target or null if cancelled.
*/
export async function promptTargetFolder(initialVault, initialDir = '', items = []) {
let vaultsList = [];
try {
const vRes = await api('/api/vaults');
if (vRes && Array.isArray(vRes.vaults)) {
vaultsList = vRes.vaults;
} else if (Array.isArray(vRes)) {
vaultsList = vRes;
}
} catch (e) {
console.warn('Could not fetch vaults list:', e);
}
// Fallback if vaults list API returns empty or fails
if (!vaultsList.length && initialVault) {
vaultsList = [{ name: initialVault }];
}
let selectedVault = initialVault || (vaultsList[0]?.name) || '';
// Cache folder list per vault
const vaultFoldersMap = {};
async function fetchFoldersForVault(v) {
if (vaultFoldersMap[v]) return vaultFoldersMap[v];
try {
const res = await api(`/api/vault/${encodeURIComponent(v)}/paths?limit=5000`);
const list = res?.results || res?.paths || [];
if (Array.isArray(list)) {
vaultFoldersMap[v] = list
.filter((p) => p.type === 'directory')
.map((p) => p.path)
.sort();
} else {
vaultFoldersMap[v] = [];
}
} catch (e) {
console.warn(`Could not fetch directories for vault ${v}:`, e);
vaultFoldersMap[v] = [];
}
return vaultFoldersMap[v];
}
await fetchFoldersForVault(selectedVault);
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 filterText = '';
function renderVaultOptions() {
return vaultsList.map((v) => {
const vName = typeof v === 'string' ? v : v.name;
const sel = vName === selectedVault ? 'selected' : '';
return `<option value="${escapeHtml(vName)}" ${sel}>🗄️ ${escapeHtml(vName)}</option>`;
}).join('');
}
function renderFolderOptions(folders, curDir, filter = '') {
let filtered = folders;
if (filter.trim()) {
const q = filter.trim().toLowerCase();
filtered = folders.filter((f) => f.toLowerCase().includes(q));
}
let html = `<option value="">${escapeHtml(t('upload.vault_root') || '/ (Racine de la voute)')}</option>`;
for (const folder of filtered) {
const sel = folder === curDir ? 'selected' : '';
// Indent subfolder depth for a clean hierarchical visual tree
const depth = (folder.match(/\//g) || []).length;
const indent = '&nbsp;&nbsp;'.repeat(depth);
html += `<option value="${escapeHtml(folder)}" ${sel}>${indent}📁 ${escapeHtml(folder)}</option>`;
}
return html;
}
overlay.innerHTML = `
<div class="obsigate-modal" style="min-width: 460px; max-width: 560px;">
<div class="obsigate-modal-header">
<span class="obsigate-modal-title">📁 ${escapeHtml(t('upload.choose_target_folder') || "Destination de l'importation")}</span>
<button class="modal-close modal-btn" type="button" aria-label="Fermer" style="padding: 4px 8px; line-height: 1;">×</button>
</div>
<div class="obsigate-modal-body">
<p style="margin: 0 0 16px 0; color: var(--text-muted); font-size: 0.9rem;">
${escapeHtml(t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`)}
</p>
<div class="modal-form-group">
<label style="display: block; font-weight: 600; font-size: 0.85rem; margin-bottom: 6px; color: var(--text-primary);">
${escapeHtml(t('upload.target_vault') || 'Voute cible')}
</label>
<select id="obsigate-upload-vault-select" class="modal-select modal-input">
${renderVaultOptions()}
</select>
</div>
<div class="modal-form-group" style="margin-top: 14px;">
<label style="display: block; font-weight: 600; font-size: 0.85rem; margin-bottom: 6px; color: var(--text-primary);">
${escapeHtml(t('upload.target_directory') || 'Sous-répertoire cible')}
</label>
<input type="text" id="obsigate-upload-folder-filter" class="modal-input" placeholder="${escapeHtml(t('upload.filter_folders') || 'Filtrer ou rechercher un dossier...')}" style="margin-bottom: 6px; font-size: 0.85rem;" />
<select id="obsigate-upload-folder-select" class="modal-select modal-input" size="5" style="height: 120px; font-family: monospace; font-size: 0.85rem;">
${renderFolderOptions(vaultFoldersMap[selectedVault] || [], initialDir)}
</select>
</div>
<div class="modal-form-group" style="margin-top: 14px;">
<label style="display: block; font-weight: 500; font-size: 0.8rem; margin-bottom: 4px; color: var(--text-muted);">
${escapeHtml(t('upload.custom_folder') || 'Ou saisir un nouveau sous-dossier...')}
</label>
<input type="text" id="obsigate-upload-custom-folder" class="modal-input" placeholder="ex: dossier/sous-dossier" style="font-size: 0.85rem;" />
</div>
</div>
<div class="obsigate-modal-footer">
<button type="button" class="modal-btn modal-cancel-btn">${escapeHtml(t('button.cancel') || 'Annuler')}</button>
<button type="button" class="modal-btn primary modal-confirm-btn">${escapeHtml(t('upload.import_button') || 'Importer')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const vaultSelect = overlay.querySelector('#obsigate-upload-vault-select');
const folderFilter = overlay.querySelector('#obsigate-upload-folder-filter');
const folderSelect = overlay.querySelector('#obsigate-upload-folder-select');
const customFolderInput = overlay.querySelector('#obsigate-upload-custom-folder');
const updateFoldersView = async () => {
const fList = await fetchFoldersForVault(selectedVault);
folderSelect.innerHTML = renderFolderOptions(fList, folderSelect.value, filterText);
};
vaultSelect.addEventListener('change', async () => {
selectedVault = vaultSelect.value;
await updateFoldersView();
});
folderFilter.addEventListener('input', () => {
filterText = folderFilter.value;
updateFoldersView();
});
folderSelect.addEventListener('change', () => {
if (folderSelect.value) {
customFolderInput.value = '';
}
});
customFolderInput.addEventListener('input', () => {
if (customFolderInput.value.trim()) {
folderSelect.selectedIndex = -1;
}
});
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 v = vaultSelect.value;
const customPath = customFolderInput.value.trim().replace(/^[\/\\]+|[\/\\]+$/g, '');
const d = customPath || folderSelect.value || '';
close({ vault: v, targetDir: d });
});
});
}
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}