/** * 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>} */ 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} */ 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 = [], opts = {}) { 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 ``; }).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 = ``; 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 = '  '.repeat(depth); html += ``; } return html; } overlay.innerHTML = `
📁 ${escapeHtml(opts.title || t('upload.choose_target_folder') || "Destination de l'importation")}

${opts.summaryText ? escapeHtml(opts.summaryText) : escapeHtml(t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`)}

`; 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }