369 lines
13 KiB
JavaScript
369 lines
13 KiB
JavaScript
/**
|
||
* 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;
|
||
|
||
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();
|
||
} 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`);
|
||
if (res && Array.isArray(res.paths)) {
|
||
vaultFoldersMap[v] = res.paths
|
||
.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;
|
||
|
||
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) {
|
||
let html = `<option value="">${escapeHtml(t('upload.vault_root') || '/ (Racine de la voute)')}</option>`;
|
||
for (const folder of folders) {
|
||
const sel = folder === curDir ? 'selected' : '';
|
||
html += `<option value="${escapeHtml(folder)}" ${sel}>📁 ${escapeHtml(folder)}</option>`;
|
||
}
|
||
return html;
|
||
}
|
||
|
||
overlay.innerHTML = `
|
||
<div class="obsigate-modal" style="min-width: 440px; max-width: 520px;">
|
||
<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>
|
||
<select id="obsigate-upload-folder-select" class="modal-select modal-input">
|
||
${renderFolderOptions(vaultFoldersMap[selectedVault] || [], initialDir)}
|
||
</select>
|
||
</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 folderSelect = overlay.querySelector('#obsigate-upload-folder-select');
|
||
|
||
vaultSelect.addEventListener('change', async () => {
|
||
selectedVault = vaultSelect.value;
|
||
const fList = await fetchFoldersForVault(selectedVault);
|
||
folderSelect.innerHTML = renderFolderOptions(fList, '');
|
||
});
|
||
|
||
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 d = folderSelect.value;
|
||
close({ vault: v, targetDir: d });
|
||
});
|
||
});
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return String(str || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|