feat(dragdrop): drag & drop complet de fichiers/dossiers & intégration IA (#89)
CI / lint (push) Successful in 1m20s
CI / security (push) Successful in 50s
CI / test (push) Successful in 2m42s
CI / build (push) Successful in 51s
CI / e2e (push) Successful in 10m29s

This commit is contained in:
2026-09-14 15:31:12 -04:00
parent 150b57d538
commit 1db3e0ad2f
13 changed files with 990 additions and 10 deletions
+93
View File
@@ -965,6 +965,12 @@ class BooksLM {
this._sendMessage();
});
// Setup Drag & Drop for internal items and external files (#89)
this._setupDropZones(panel);
// Save singleton reference on window
window.BooksLMInstance = this;
return panel;
}
@@ -981,6 +987,93 @@ class BooksLM {
return Math.max(PANEL_MIN_WIDTH, Math.min(PANEL_MAX_WIDTH, width));
}
_setupDropZones(panel) {
const composer = panel.querySelector('.bookslm-composer') || panel;
const onDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
composer.classList.add('bookslm-drop-over');
};
const onDragLeave = (e) => {
if (composer.contains(e.relatedTarget)) return;
composer.classList.remove('bookslm-drop-over');
};
const onDrop = async (e) => {
e.preventDefault();
e.stopPropagation();
composer.classList.remove('bookslm-drop-over');
// Check if internal tree drag
const treeData = e.dataTransfer.getData('tree:path') || e.dataTransfer.getData('text/plain');
const treeVault = e.dataTransfer.getData('tree:vault');
const treeType = e.dataTransfer.getData('tree:type');
if (treeData && treeVault) {
if (treeType === 'directory') {
this._addAdhocDir(treeData, treeVault);
} else {
this._addAdhocFile(treeData, treeVault);
}
return;
}
// External files
const files = Array.from(e.dataTransfer.files || []);
if (files.length > 0) {
this.handleExternalDroppedItems(files.map((f) => ({ path: f.name, file: f, isDir: false })));
}
};
composer.addEventListener('dragover', onDragOver);
composer.addEventListener('dragleave', onDragLeave);
composer.addEventListener('drop', onDrop);
}
/**
* Handle files/folders dropped from outside into BooksLM (#89).
*/
async handleExternalDroppedItems(items) {
if (!this._isOpen) {
this.open();
}
for (const item of items) {
if (item.isDir) {
continue;
}
const file = item.file;
if (!file) continue;
if (file.type && file.type.startsWith('image/')) {
this._addImageFile(file);
} else {
// Read text/markdown file content
try {
const text = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = reject;
reader.readAsText(file);
});
// Insert into context files or append to composer
this._adhocFiles.push({
path: item.path,
vault: null,
inlineContent: text,
});
this._renderAttachments();
} catch (err) {
console.warn('Could not read dropped file as text:', err);
}
}
}
}
_startResize(e) {
if (!this._panel || this._isFullscreen) return;
e.preventDefault();
+144 -6
View File
@@ -9,8 +9,10 @@
*/
import { api } from './auth.js';
import { t } from './i18n.js';
import { showToast } from './ui.js';
import { refreshSidebarTreePreservingState } from './sidebar.js';
import { readDataTransferEntries, handleVaultDropUpload } from './upload.js';
// ---------------------------------------------------------------------------
// State
@@ -180,14 +182,21 @@ function onDragStart(e) {
}
function onDragOver(e) {
if (!dragState) return;
const isExternalFiles = e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files');
if (!dragState && !isExternalFiles) return;
e.preventDefault(); // Required to allow drop
e.dataTransfer.dropEffect = 'move';
if (isExternalFiles) {
e.dataTransfer.dropEffect = 'copy';
} else {
e.dataTransfer.dropEffect = 'move';
}
clearAllHighlights();
const target = getDropTarget(e.target);
if (!target || isInvalidDrop(dragState, target)) {
if (!target || (!isExternalFiles && isInvalidDrop(dragState, target))) {
e.dataTransfer.dropEffect = 'none';
return;
}
@@ -224,17 +233,33 @@ async function onDrop(e) {
e.preventDefault();
e.stopPropagation();
if (!dragState) return;
const isExternalFiles = e.dataTransfer && (
(e.dataTransfer.files && e.dataTransfer.files.length > 0) ||
(e.dataTransfer.items && e.dataTransfer.items.length > 0) ||
Array.from(e.dataTransfer.types || []).includes('Files')
);
if (!dragState && !isExternalFiles) return;
const target = getDropTarget(e.target);
if (!target || isInvalidDrop(dragState, target)) {
if (!target || (!isExternalFiles && isInvalidDrop(dragState, target))) {
onDragEnd();
return;
}
const { vault, path, type } = dragState;
const { vault: targetVault, targetDir } = target;
if (isExternalFiles) {
onDragEnd();
const items = await readDataTransferEntries(e.dataTransfer.items || e.dataTransfer.files);
if (items.length > 0) {
await handleVaultDropUpload(targetVault, targetDir, items);
}
return;
}
const { vault, path, type } = dragState;
// Don't move if source and target are the same vault
if (vault !== targetVault) {
showToast(t('dragdrop.cross_vault_unsupported'), 'error');
@@ -312,6 +337,119 @@ export function initDragDrop() {
makeAllDraggable();
});
observer.observe(container, { childList: true, subtree: true });
// Initialize global drop overlay
initGlobalDropOverlay();
}
/**
* Initialize a global dropzone overlay across the entire window
* when dragging files/folders from the OS.
*/
export function initGlobalDropOverlay() {
if (document.getElementById('obsigate-global-drop-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'obsigate-global-drop-overlay';
overlay.className = 'obsigate-drop-overlay';
overlay.innerHTML = `
<div class="obsigate-drop-backdrop"></div>
<div class="obsigate-drop-modal">
<h2 class="obsigate-drop-title">${t('dragdrop.overlay_title') || 'Déposer des fichiers ou dossiers'}</h2>
<div class="obsigate-drop-zones">
<div class="obsigate-drop-zone obsigate-drop-vault" data-zone="vault">
<div class="obsigate-drop-zone-icon">📁</div>
<div class="obsigate-drop-zone-title">${t('dragdrop.overlay_vault_zone') || 'Importer dans le Vault'}</div>
<div class="obsigate-drop-zone-desc" id="obsigate-drop-vault-target"></div>
</div>
<div class="obsigate-drop-zone obsigate-drop-ai" data-zone="ai">
<div class="obsigate-drop-zone-icon">🧠</div>
<div class="obsigate-drop-zone-title">${t('dragdrop.overlay_ai_zone') || "Analyser avec l'Assistant IA"}</div>
<div class="obsigate-drop-zone-desc">Envoyer au chat BooksLM</div>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
let dragCounter = 0;
window.addEventListener('dragenter', (e) => {
if (dragState) return; // Don't show global overlay for internal tree reordering
const types = Array.from(e.dataTransfer?.types || []);
if (!types.includes('Files')) return;
dragCounter++;
if (dragCounter === 1) {
// Update target vault description
const activeVault = window.state?.activeVault || (document.querySelector('.tree-item.vault-item')?.dataset?.vault) || '';
const vaultDesc = overlay.querySelector('#obsigate-drop-vault-target');
if (vaultDesc) {
vaultDesc.textContent = activeVault ? `Vault : ${activeVault}` : 'Vault par défaut';
}
overlay.classList.add('active');
}
});
window.addEventListener('dragleave', (e) => {
if (dragState) return;
const types = Array.from(e.dataTransfer?.types || []);
if (!types.includes('Files')) return;
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
overlay.classList.remove('active');
}
});
window.addEventListener('dragover', (e) => {
const types = Array.from(e.dataTransfer?.types || []);
if (!types.includes('Files')) return;
e.preventDefault();
});
const zones = overlay.querySelectorAll('.obsigate-drop-zone');
zones.forEach((zone) => {
zone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
zone.classList.add('hover');
});
zone.addEventListener('dragleave', () => {
zone.classList.remove('hover');
});
zone.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter = 0;
overlay.classList.remove('active');
zone.classList.remove('hover');
const zoneType = zone.dataset.zone;
const items = await readDataTransferEntries(e.dataTransfer.items || e.dataTransfer.files);
if (!items.length) return;
if (zoneType === 'vault') {
const activeVault = window.state?.activeVault || (document.querySelector('.tree-item.vault-item')?.dataset?.vault);
if (activeVault) {
await handleVaultDropUpload(activeVault, '', items);
} else {
showToast('Aucun vault sélectionné', 'error');
}
} else if (zoneType === 'ai') {
// Dispatch or pass to BooksLM assistant
if (window.BooksLMInstance) {
window.BooksLMInstance.handleExternalDroppedItems(items);
} else {
showToast("Assistant IA non initialisé", 'warning');
}
}
});
});
}
/**
+205
View File
@@ -0,0 +1,205 @@
/**
* 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;
const fileCount = items.filter((x) => !x.isDir).length;
const dirCount = items.filter((x) => x.isDir).length;
const destName = targetDir ? `${vault}/${targetDir}` : vault;
showToast(`${t('upload.uploading', { count: fileCount }) || `Uploading ${fileCount} file(s) to ${destName}...`}`, 'info');
try {
const res = await uploadBatch(vault, targetDir, 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');
}
}
+7
View File
@@ -634,6 +634,13 @@
"dashboard.zero_files": "0 files",
"dragdrop.cross_vault_unsupported": "Cross-vault moves are not supported",
"dragdrop.move_failed": "Move failed",
"dragdrop.overlay_title": "Drop files or folders",
"dragdrop.overlay_vault_zone": "Import into Vault",
"dragdrop.overlay_ai_zone": "Analyze with AI Assistant",
"upload.uploading": "Uploading {count} file(s)...",
"upload.success": "Successfully imported {count} file(s)",
"upload.partial": "Imported {count} file(s) ({errors} errors)",
"upload.error": "Upload failed",
"editor.cancel": "Cancel",
"editor.close": "Close",
"editor.delete_error": "Delete error",
+9 -2
View File
@@ -632,8 +632,15 @@
"dashboard.views": "{count} vue(s)",
"dashboard.welcome": "Bienvenue dans ObsiGate",
"dashboard.zero_files": "0 fichiers",
"dragdrop.cross_vault_unsupported": "Cross-vault moves are not supported",
"dragdrop.move_failed": "Move failed",
"dragdrop.cross_vault_unsupported": "Le déplacement entre vaults différents n'est pas supporté",
"dragdrop.move_failed": "Échec du déplacement",
"dragdrop.overlay_title": "Déposer des fichiers ou dossiers",
"dragdrop.overlay_vault_zone": "Importer dans le Vault",
"dragdrop.overlay_ai_zone": "Analyser avec l'Assistant IA",
"upload.uploading": "Téléversement de {count} fichier(s)...",
"upload.success": "{count} fichier(s) importé(s) avec succès",
"upload.partial": "{count} fichier(s) importé(s) ({errors} erreurs)",
"upload.error": "Échec de l'importation",
"editor.cancel": "Annuler",
"editor.close": "Fermer",
"editor.delete_error": "Erreur de suppression",
+87
View File
@@ -1124,6 +1124,93 @@ select {
outline-offset: -2px;
}
/* Global Drag & Drop Overlay (#89) */
.obsigate-drop-overlay {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.obsigate-drop-overlay.active {
opacity: 1;
pointer-events: auto;
}
.obsigate-drop-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
}
.obsigate-drop-modal {
position: relative;
z-index: 1;
background: var(--bg-primary, #1e1e2e);
border: 1px solid var(--border-color, #313244);
border-radius: 12px;
padding: 24px;
width: min(600px, 90vw);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.4);
text-align: center;
}
.obsigate-drop-title {
margin: 0 0 20px 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-normal, #cdd6f4);
}
.obsigate-drop-zones {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.obsigate-drop-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 28px 16px;
border: 2px dashed var(--border-color, #45475a);
border-radius: 8px;
background: var(--bg-secondary, #181825);
cursor: pointer;
transition: all 0.15s ease;
}
.obsigate-drop-zone.hover {
border-color: var(--accent, #89b4fa);
background: color-mix(in srgb, var(--accent, #89b4fa) 15%, var(--bg-secondary, #181825));
transform: scale(1.02);
}
.obsigate-drop-zone-icon {
font-size: 2.5rem;
margin-bottom: 12px;
}
.obsigate-drop-zone-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-normal, #cdd6f4);
margin-bottom: 6px;
}
.bookslm-composer.bookslm-drop-over {
outline: 2px dashed var(--accent, #89b4fa);
outline-offset: 2px;
background: color-mix(in srgb, var(--accent, #89b4fa) 10%, var(--bg-secondary, #181825));
}
.tree-children {
padding-left: 16px;
overflow: hidden;