diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ccde69..09710bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Ajouté +- **#89 Drag & Drop complet de fichiers/dossiers & intégration Assistant IA** — solution + complète de glisser-déposer de fichiers individuels, multiples et **dossiers récursifs** + depuis l'OS vers le vault (`POST /api/vault/{vault}/batch-upload`, FileSystem API), + overlay plein écran à double cible (dépôt vault vs analyse IA), surbrillance des cibles dans + l'arborescence, et dropzone BooksLM pour injection de contexte ou pièces jointes. + Fiche : [docs/features/drag-and-drop-ai.md](./docs/features/drag-and-drop-ai.md). - **#88 Assistant IA : contexte applicatif** — l'assistant en mode **Général** reçoit l'état vivant de l'interface (`app_context`) et connaît désormais les documents ouverts, le répertoire et le vault courants, la recherche en cours diff --git a/backend/main.py b/backend/main.py index bf6570a..89e9a32 100644 --- a/backend/main.py +++ b/backend/main.py @@ -105,6 +105,9 @@ from backend.services.backups import list_backup_files as service_list_backup_fi from backend.services.errors import ServiceError from backend.services.files import read_raw_file from backend.services.graph import get_graph as service_get_graph +from backend.services.mutations import ( + batch_upload_files as service_batch_upload_files, +) from backend.services.mutations import ( create_directory as service_create_directory, ) @@ -436,6 +439,31 @@ class FileCreateResponse(BaseModel): path: str = Field(description="Path of the created file") +class BatchUploadFileItem(BaseModel): + """A single file/dir entry in a batch upload request.""" + path: str = Field(description="Relative path of the item within the batch") + content: str | None = Field(default=None, description="Base64 encoded or text content for files") + is_dir: bool = Field(default=False, description="True if entry represents an empty directory") + + +class BatchUploadRequest(BaseModel): + """Request payload for batch file/directory upload.""" + target_dir: str = Field(default="", description="Base directory in vault to upload into (empty for root)") + files: list[BatchUploadFileItem] = Field(description="List of files and directories to upload") + overwrite: bool = Field(default=True, description="Whether to overwrite existing files (creates backups)") + + +class BatchUploadResponse(BaseModel): + """Response from batch file/directory upload.""" + success: bool = Field(description="True if all files uploaded without error") + vault: str = Field(description="Vault name") + target_dir: str = Field(description="Target directory") + uploaded: list[str] = Field(description="List of created/updated file paths") + created_dirs: list[str] = Field(description="List of created directory paths") + errors: list[dict[str, Any]] = Field(default_factory=list, description="List of items that failed") + total_files: int = Field(description="Total uploaded files count") + + class FileRenameRequest(BaseModel): """Request to rename a file.""" path: str = Field(description="Current path of the file") @@ -1905,6 +1933,78 @@ async def api_file_create( return {"success": True, "path": result["path"]} +@app.post("/api/vault/{vault_name}/batch-upload", response_model=BatchUploadResponse) +async def api_batch_upload( + vault_name: str, + body: BatchUploadRequest, + current_user=Depends(require_auth), +): + """Upload multiple files and directories (recursively) into a vault. + + Accepts base64 encoded or plain text files with relative directory paths. + Creates missing parent folders safely. + + Args: + vault_name: Target vault name. + body: BatchUploadRequest with target_dir and files list. + + Returns: + BatchUploadResponse with summary of uploaded files and errors. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + import base64 + + items: list[dict[str, Any]] = [] + for f in body.files: + if f.is_dir: + items.append({"path": f.path, "is_dir": True}) + continue + + raw_bytes = b"" + if f.content is not None: + # Check if content is base64 encoded data URI or raw base64 + content_str = f.content + if content_str.startswith("data:") and ";base64," in content_str: + content_str = content_str.split(";base64,", 1)[1] + try: + raw_bytes = base64.b64decode(content_str) + except Exception: + # Fallback to utf-8 text encoding + raw_bytes = f.content.encode("utf-8") + + items.append({"path": f.path, "content": raw_bytes, "is_dir": False}) + + result = service_batch_upload_files( + vault_name, + body.target_dir, + items, + overwrite=body.overwrite, + ) + + # Update index and SSE notifications for uploaded files + for path in result["uploaded"]: + try: + await update_single_file(vault_name, path) + await sse_manager.broadcast("file_created", { + "vault": vault_name, + "path": path, + }) + await dispatch_webhooks("file_created", {"vault": vault_name, "path": path}) + except Exception as e: + logger.warning(f"Failed to post-process upload of {path}: {e}") + + # SSE notification for tree refresh + if result["uploaded"] or result["created_dirs"]: + await sse_manager.broadcast("tree_updated", { + "vault": vault_name, + "target_dir": result["target_dir"], + }) + + return result + + @app.patch("/api/file/{vault_name}", response_model=FileRenameResponse) async def api_file_rename( vault_name: str, diff --git a/backend/services/mutations.py b/backend/services/mutations.py index e747dd2..accf6f0 100644 --- a/backend/services/mutations.py +++ b/backend/services/mutations.py @@ -44,12 +44,17 @@ def _ensure_writable(root: Path) -> None: raise ServiceError("Vault is read-only", code="read_only", status=403) -def _validate_extension(file_path: Path) -> None: +def _validate_extension(file_path: Path, *, allow_images: bool = False) -> None: """Reject unsupported file extensions (400).""" from backend.indexer import SUPPORTED_EXTENSIONS ext = file_path.suffix.lower() - if ext not in SUPPORTED_EXTENSIONS and file_path.name.lower() not in ("dockerfile", "makefile"): + allowed = SUPPORTED_EXTENSIONS + if allow_images: + from backend.attachment_indexer import IMAGE_EXTENSIONS + allowed = allowed | IMAGE_EXTENSIONS + + if ext not in allowed and file_path.name.lower() not in ("dockerfile", "makefile"): raise ServiceError( f"Unsupported file extension: {ext}", code="unsupported_extension", @@ -684,3 +689,123 @@ def restore_backup( "restored_from": version, "current_backed_up": current_backed_up, } + + +# ── D3. Batch upload & raw file save ─────────────────────────────────────── + + +def save_raw_file( + vault_name: str, + path: str, + content: bytes, + *, + overwrite: bool = True, +) -> dict[str, Any]: + """Save a binary or text file to a vault (e.g. from upload / drag-and-drop). + + Creates parent directories automatically and safely validates the path. + Supports supported text extensions, images and Excalidraw files. + + Args: + vault_name: Name of the vault. + path: Vault-relative path. + content: Raw bytes to write. + overwrite: When True, replace existing files (with backup). + + Returns: + Dict with ``success``, ``vault``, ``path``, and ``size``. + """ + root = get_vault_root(vault_name) + _ensure_writable(root) + file_path = resolve_safe_path(root, path) + _validate_extension(file_path, allow_images=True) + + rel_path = _rel(root, file_path) + + if file_path.exists(): + if not overwrite: + raise ServiceError( + f"File already exists: {rel_path}", + code="already_exists", + status=409, + details={"vault": vault_name, "path": rel_path}, + ) + create_backup(file_path, vault_name, rel_path) + + try: + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_bytes(content) + except PermissionError as e: + raise ServiceError("Permission denied: cannot save file", code="permission_denied", status=403) from e + + logger.info(f"Raw file saved: {vault_name}/{rel_path} ({len(content)} bytes)") + return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)} + + +def batch_upload_files( + vault_name: str, + target_dir: str, + files: list[dict[str, Any]], + *, + overwrite: bool = True, +) -> dict[str, Any]: + """Process a batch of uploaded files and directories into a vault. + + Args: + vault_name: Name of the target vault. + target_dir: Base directory inside the vault (empty string for root). + files: List of dicts, each with: + - ``path``: relative path within the batch (e.g. ``"sub/doc.md"`` or ``"note.md"``). + - ``content``: bytes content (or base64 decoded). + - ``is_dir``: optional boolean for empty directories. + overwrite: Whether to overwrite existing files. + + Returns: + Dict with ``uploaded`` (list of paths), ``created_dirs`` (list of paths), + and ``errors`` (list of error dicts). + """ + root = get_vault_root(vault_name) + _ensure_writable(root) + + clean_target = (target_dir or "").strip().strip("/\\") + uploaded: list[str] = [] + created_dirs: list[str] = [] + errors: list[dict[str, Any]] = [] + + for item in files: + rel_subpath = (item.get("path") or "").strip().replace("\\", "/").lstrip("/") + if not rel_subpath: + continue + + full_rel_path = f"{clean_target}/{rel_subpath}" if clean_target else rel_subpath + is_dir = item.get("is_dir", False) + + if is_dir: + try: + dir_path = resolve_safe_path(root, full_rel_path) + dir_path.mkdir(parents=True, exist_ok=True) + created_dirs.append(_rel(root, dir_path)) + except Exception as e: + errors.append({"path": full_rel_path, "error": str(e)}) + continue + + raw_bytes = item.get("content", b"") + if isinstance(raw_bytes, str): + raw_bytes = raw_bytes.encode("utf-8") + + try: + res = save_raw_file(vault_name, full_rel_path, raw_bytes, overwrite=overwrite) + uploaded.append(res["path"]) + except Exception as e: + errors.append({"path": full_rel_path, "error": str(e)}) + + return { + "success": len(errors) == 0, + "vault": vault_name, + "target_dir": clean_target, + "uploaded": uploaded, + "created_dirs": created_dirs, + "errors": errors, + "total_files": len(uploaded), + } + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 625cb12..010259a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -135,6 +135,7 @@ | # | Domaine / fonctionnalité | Version | Détails | |---|---|---|---| +| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) | | 1–8 | Fondations (FastAPI, TF-IDF + stemming, watchdog, SPA, sécurité, path traversal, gzip, PWA) | 1.0.0→1.4.0 | [archive](./archive/COMPLETED_v1-v2.md) | | 9–20 | UX & productivité (share, webhooks, dashboard, conflits, backlinks, redaction, backups, graphe, header, aperçu) | 1.5.0→1.6.0 | [archive](./archive/COMPLETED_v1-v2.md) | | 21–22 | Architecture (split `app.js` → 16 modules, validateur imports CI) | 1.5.1 | [archive](./archive/COMPLETED_v1-v2.md) | diff --git a/docs/features/drag-and-drop-ai.md b/docs/features/drag-and-drop-ai.md new file mode 100644 index 0000000..0907dc4 --- /dev/null +++ b/docs/features/drag-and-drop-ai.md @@ -0,0 +1,71 @@ +# #89 — Drag & drop complet de fichiers / dossiers & intégration Assistant IA + +> **Statut :** ✅ Livré +> **Priorité :** 🟡 P2 +> **Zone :** frontend + backend +> **Références :** [Roadmap](../ROADMAP.md) · [Assistant IA #80](./ai-assistant-ux.md) · [Assistant IA #88](./ai-app-context.md) + +--- + +## 🎯 Objectif + +Offrir une expérience complète de glisser-déposer (Drag & Drop) dans ObsiGate couvrant à la fois la gestion de fichiers dans le vault et les interactions avec l'Assistant IA : +1. Glisser-déposer de fichiers individuels, multiples et **dossiers récursifs** depuis le système d'exploitation vers l'arborescence du vault. +2. Espace de drop dédié (dropzone explicite + overlay visuel réactif à double cible). +3. Glisser-déposer de fichiers / dossiers (internes au vault ou externes depuis l'OS) vers le panneau de l'assistant IA pour enrichir le contexte ou lui soumettre des instructions directes. + +--- + +## 🏗️ Architecture & Composants + +### 1. Backend — Endpoint d'upload récursif / batch (`POST /api/vault/{vault}/batch-upload`) +- Réception de structures de fichiers avec leurs chemins relatifs (`relative_path`). +- Validation de sécurité anti path-traversal via `resolve_safe_path` et validation d'extension via `_validate_extension(allow_images=True)`. +- Création automatique et récursive des dossiers parents manquants. +- Gestion des collisions / écrasements (option d'écrasement avec sauvegarde automatique `create_backup`). +- Retour d'un bilan d'upload (`uploaded`, `created_dirs`, `errors`). + +### 2. Frontend — Core Drag & Drop & Traversée d'arborescence (`frontend/js/upload.js` & `dragdrop.js`) +- Utilisation de la `FileSystem API` (`DataTransferItem.webkitGetAsEntry()`) pour extraire de manière récursive toute l'arborescence de répertoires et fichiers déposés depuis l'OS. +- Surbrillance dynamique des dossiers cibles dans l'arborescence (`.tree-item`) lors du survol d'éléments externes de type `Files`. +- Support du drop sur la racine d'un vault ou dans un sous-dossier spécifique. +- Feedback utilisateur immédiat via les toasts d'information / progression / succès. + +### 3. Frontend — Dropzones dédiées & Overlay global +- Overlay visuel plein écran lors du drag d'éléments depuis l'OS sur l'application avec séparation claire des zones cibles : + - *Zone Vault* : Déposer pour importer dans le dossier actif / vault. + - *Zone Assistant IA* : Déposer pour analyser avec l'IA. +- Dropzone réactive dans le panneau BooksLM. + +### 4. Intégration Assistant IA (`frontend/js/bookslm.js`) +- **Drag interne (depuis l'arborescence ObsiGate vers le chat)** : + - Déposer un dossier : bascule l'assistant ou insère le dossier en contexte ad-hoc. + - Déposer un ou plusieurs fichiers : ajoute les fichiers en context chips (`_adhocFiles`). +- **Drag externe (depuis l'OS vers le chat)** : + - Images : ajout direct en pièce jointe pour les modèles multimodaux (vision). + - Documents texte / Markdown / Code : lecture immédiate du contenu et ajout en contexte ad-hoc. + +--- + +## 📋 Sous-tâches + +- [x] **Backend** : + - [x] Endpoint `POST /api/vault/{vault}/batch-upload` (gestion des sous-dossiers et upload en batch). + - [x] Services métier `save_raw_file` et `batch_upload_files` dans `backend/services/mutations.py`. + - [x] Tests unitaires backend (`tests/test_batch_upload.py`). +- [x] **Frontend Core Upload & Tree Drop** : + - [x] Helper de lecture récursive `readDataTransferEntries` (fichiers + dossiers) dans `frontend/js/upload.js`. + - [x] Écouteurs `dragover` / `drop` sur l'arborescence du vault avec retour visuel dans `frontend/js/dragdrop.js`. + - [x] Gestion des transferts et notifications toasts. +- [x] **Overlay & Dropzone UI** : + - [x] Détection globale du glisser externe sur la fenêtre avec overlay interactif (`initGlobalDropOverlay`). + - [x] Styles de l'overlay et des zones cibles dans `frontend/style.css`. +- [x] **Intégration Assistant IA** : + - [x] Dropzone sur le composeur BooksLM (`_setupDropZones`). + - [x] Gestion des drops internes (fichiers/dossiers du vault) et externes (OS `handleExternalDroppedItems`). + - [x] Intégration transparente avec les chips de contexte et images multimodales. +- [x] **Internationalisation (i18n)** : + - [x] Clés FR/EN pour toutes les indications de drop, progression et erreurs dans `frontend/locales/{fr,en}.json`. +- [x] **Tests & Validation** : + - [x] Tests unitaires frontend `tests/frontend/upload.test.mjs`. + - [x] Validation de tous les imports/exports ES modules `tests/frontend/validate-imports.mjs`. diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 1368cd2..8818e84 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -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(); diff --git a/frontend/js/dragdrop.js b/frontend/js/dragdrop.js index e5cbb1c..4ccca93 100644 --- a/frontend/js/dragdrop.js +++ b/frontend/js/dragdrop.js @@ -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 = ` +
+