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
+6
View File
@@ -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
+100
View File
@@ -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,
+127 -2
View File
@@ -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),
}
+1
View File
@@ -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) |
+71
View File
@@ -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`.
+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;
+55
View File
@@ -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(`<!DOCTYPE html><html><body><div id="vault-tree"></div></body></html>`, {
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);
});
+85
View File
@@ -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"