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 = ` +
+
+

${t('dragdrop.overlay_title') || 'Déposer des fichiers ou dossiers'}

+
+
+
📁
+
${t('dragdrop.overlay_vault_zone') || 'Importer dans le Vault'}
+
+
+
+
🧠
+
${t('dragdrop.overlay_ai_zone') || "Analyser avec l'Assistant IA"}
+
Envoyer au chat BooksLM
+
+
+
+ `; + 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'); + } + } + }); + }); } /** diff --git a/frontend/js/upload.js b/frontend/js/upload.js new file mode 100644 index 0000000..737ab94 --- /dev/null +++ b/frontend/js/upload.js @@ -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>} + */ +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; + + 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'); + } +} diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 6ed6075..fd5177e 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index b25d1b3..32584d4 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -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", diff --git a/frontend/style.css b/frontend/style.css index 0589aef..394d350 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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; diff --git a/tests/frontend/upload.test.mjs b/tests/frontend/upload.test.mjs new file mode 100644 index 0000000..17657f9 --- /dev/null +++ b/tests/frontend/upload.test.mjs @@ -0,0 +1,55 @@ +/** + * Frontend tests for Upload & Drag-and-Drop modules (#89). + */ + +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; + +// JSDOM bootstrap +const dom = new JSDOM(`
`, { + url: "http://localhost:2020/", +}); +const w = dom.window; +globalThis.window = w; +globalThis.document = w.document; +globalThis.File = w.File; +globalThis.FileReader = w.FileReader; +Object.defineProperty(globalThis, "navigator", { + value: w.navigator, + configurable: true, + writable: true, +}); + +const { readDataTransferEntries } = await import("../../frontend/js/upload.js"); + +async function runTests() { + console.log("Testing readDataTransferEntries empty input..."); + const resNull = await readDataTransferEntries(null); + assert.deepEqual(resNull, []); + + const resEmpty = await readDataTransferEntries([]); + assert.deepEqual(resEmpty, []); + + console.log("Testing readDataTransferEntries plain files..."); + const fakeFile1 = { name: "note1.md", webkitRelativePath: "folder/note1.md" }; + const fakeFile2 = { name: "logo.png", webkitRelativePath: "" }; + + const fakeItems = [ + { getAsFile: () => fakeFile1 }, + { getAsFile: () => fakeFile2 }, + ]; + + const results = await readDataTransferEntries(fakeItems); + assert.equal(results.length, 2); + assert.equal(results[0].path, "folder/note1.md"); + assert.equal(results[0].isDir, false); + assert.equal(results[1].path, "logo.png"); + assert.equal(results[1].isDir, false); + + console.log("✅ All upload tests passed successfully!"); +} + +runTests().catch((err) => { + console.error("❌ Test failed:", err); + process.exit(1); +}); diff --git a/tests/test_batch_upload.py b/tests/test_batch_upload.py new file mode 100644 index 0000000..3dce22b --- /dev/null +++ b/tests/test_batch_upload.py @@ -0,0 +1,85 @@ +"""Unit tests for batch upload and raw file saving (#89).""" + +import base64 +from pathlib import Path +import pytest +from fastapi.testclient import TestClient + +from backend.indexer import get_vault_data +from backend.main import app +from backend.services.errors import ServiceError +from backend.services.mutations import ( + batch_upload_files, + save_raw_file, +) + + +def test_save_raw_file_text_and_binary(client): + vault_root = Path(get_vault_data("TestVault")["path"]) + + # Save a markdown file + res = save_raw_file("TestVault", "folder/note_test.md", b"# Hello World\n") + assert res["success"] is True + assert res["path"] == "folder/note_test.md" + assert (vault_root / "folder" / "note_test.md").read_text(encoding="utf-8") == "# Hello World\n" + + # Save a png image + img_bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + res_img = save_raw_file("TestVault", "assets/logo.png", img_bytes) + assert res_img["success"] is True + assert res_img["path"] == "assets/logo.png" + assert (vault_root / "assets" / "logo.png").read_bytes() == img_bytes + + +def test_save_raw_file_unsupported_ext(client): + with pytest.raises(ServiceError) as exc_info: + save_raw_file("TestVault", "danger.exe", b"binary") + assert exc_info.value.code == "unsupported_extension" + + +def test_batch_upload_files_recursive_structure(client): + vault_root = Path(get_vault_data("TestVault")["path"]) + + files = [ + {"path": "empty_dir", "is_dir": True}, + {"path": "doc1.md", "content": b"# Doc 1"}, + {"path": "sub/doc2.md", "content": b"# Doc 2"}, + {"path": "sub/deep/doc3.md", "content": b"# Doc 3"}, + {"path": "assets/img.png", "content": b"\x89PNG\r\n\x1a\n"}, + {"path": "invalid.exe", "content": b"bad"}, + ] + + res = batch_upload_files("TestVault", "target_folder", files, overwrite=True) + assert res["vault"] == "TestVault" + assert res["target_dir"] == "target_folder" + assert len(res["uploaded"]) == 4 + assert len(res["created_dirs"]) == 1 + assert len(res["errors"]) == 1 + assert res["errors"][0]["path"] == "target_folder/invalid.exe" + + # Verify disk files + assert (vault_root / "target_folder" / "empty_dir").is_dir() + assert (vault_root / "target_folder" / "doc1.md").read_text(encoding="utf-8") == "# Doc 1" + assert (vault_root / "target_folder" / "sub" / "deep" / "doc3.md").read_text(encoding="utf-8") == "# Doc 3" + + +def test_api_batch_upload(client): + vault_root = Path(get_vault_data("TestVault")["path"]) + + content_b64 = base64.b64encode(b"# Uploaded via API").decode("utf-8") + payload = { + "target_dir": "api_import", + "files": [ + {"path": "api_doc.md", "content": content_b64}, + {"path": "nested/folder", "is_dir": True}, + ], + "overwrite": True, + } + + resp = client.post("/api/vault/TestVault/batch-upload", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert "api_import/api_doc.md" in data["uploaded"] + assert "api_import/nested/folder" in data["created_dirs"] + assert (vault_root / "api_import" / "api_doc.md").read_text(encoding="utf-8") == "# Uploaded via API"