feat(dragdrop): drag & drop complet de fichiers/dossiers & intégration IA (#89)
This commit is contained in:
@@ -14,6 +14,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### Ajouté
|
### 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**
|
- **#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
|
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
|
documents ouverts, le répertoire et le vault courants, la recherche en cours
|
||||||
|
|||||||
+100
@@ -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.errors import ServiceError
|
||||||
from backend.services.files import read_raw_file
|
from backend.services.files import read_raw_file
|
||||||
from backend.services.graph import get_graph as service_get_graph
|
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 (
|
from backend.services.mutations import (
|
||||||
create_directory as service_create_directory,
|
create_directory as service_create_directory,
|
||||||
)
|
)
|
||||||
@@ -436,6 +439,31 @@ class FileCreateResponse(BaseModel):
|
|||||||
path: str = Field(description="Path of the created file")
|
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):
|
class FileRenameRequest(BaseModel):
|
||||||
"""Request to rename a file."""
|
"""Request to rename a file."""
|
||||||
path: str = Field(description="Current path of the 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"]}
|
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)
|
@app.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
|
||||||
async def api_file_rename(
|
async def api_file_rename(
|
||||||
vault_name: str,
|
vault_name: str,
|
||||||
|
|||||||
@@ -44,12 +44,17 @@ def _ensure_writable(root: Path) -> None:
|
|||||||
raise ServiceError("Vault is read-only", code="read_only", status=403)
|
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)."""
|
"""Reject unsupported file extensions (400)."""
|
||||||
from backend.indexer import SUPPORTED_EXTENSIONS
|
from backend.indexer import SUPPORTED_EXTENSIONS
|
||||||
|
|
||||||
ext = file_path.suffix.lower()
|
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(
|
raise ServiceError(
|
||||||
f"Unsupported file extension: {ext}",
|
f"Unsupported file extension: {ext}",
|
||||||
code="unsupported_extension",
|
code="unsupported_extension",
|
||||||
@@ -684,3 +689,123 @@ def restore_backup(
|
|||||||
"restored_from": version,
|
"restored_from": version,
|
||||||
"current_backed_up": current_backed_up,
|
"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),
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,7 @@
|
|||||||
|
|
||||||
| # | Domaine / fonctionnalité | Version | Détails |
|
| # | 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) |
|
| 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) |
|
| 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) |
|
| 21–22 | Architecture (split `app.js` → 16 modules, validateur imports CI) | 1.5.1 | [archive](./archive/COMPLETED_v1-v2.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`.
|
||||||
@@ -965,6 +965,12 @@ class BooksLM {
|
|||||||
this._sendMessage();
|
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;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -981,6 +987,93 @@ class BooksLM {
|
|||||||
return Math.max(PANEL_MIN_WIDTH, Math.min(PANEL_MAX_WIDTH, width));
|
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) {
|
_startResize(e) {
|
||||||
if (!this._panel || this._isFullscreen) return;
|
if (!this._panel || this._isFullscreen) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
Vendored
+144
-6
@@ -9,8 +9,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from './auth.js';
|
import { api } from './auth.js';
|
||||||
|
import { t } from './i18n.js';
|
||||||
import { showToast } from './ui.js';
|
import { showToast } from './ui.js';
|
||||||
import { refreshSidebarTreePreservingState } from './sidebar.js';
|
import { refreshSidebarTreePreservingState } from './sidebar.js';
|
||||||
|
import { readDataTransferEntries, handleVaultDropUpload } from './upload.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// State
|
// State
|
||||||
@@ -180,14 +182,21 @@ function onDragStart(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onDragOver(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.preventDefault(); // Required to allow drop
|
||||||
e.dataTransfer.dropEffect = 'move';
|
|
||||||
|
if (isExternalFiles) {
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
} else {
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
clearAllHighlights();
|
clearAllHighlights();
|
||||||
|
|
||||||
const target = getDropTarget(e.target);
|
const target = getDropTarget(e.target);
|
||||||
if (!target || isInvalidDrop(dragState, target)) {
|
if (!target || (!isExternalFiles && isInvalidDrop(dragState, target))) {
|
||||||
e.dataTransfer.dropEffect = 'none';
|
e.dataTransfer.dropEffect = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -224,17 +233,33 @@ async function onDrop(e) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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);
|
const target = getDropTarget(e.target);
|
||||||
if (!target || isInvalidDrop(dragState, target)) {
|
if (!target || (!isExternalFiles && isInvalidDrop(dragState, target))) {
|
||||||
onDragEnd();
|
onDragEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { vault, path, type } = dragState;
|
|
||||||
const { vault: targetVault, targetDir } = target;
|
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
|
// Don't move if source and target are the same vault
|
||||||
if (vault !== targetVault) {
|
if (vault !== targetVault) {
|
||||||
showToast(t('dragdrop.cross_vault_unsupported'), 'error');
|
showToast(t('dragdrop.cross_vault_unsupported'), 'error');
|
||||||
@@ -312,6 +337,119 @@ export function initDragDrop() {
|
|||||||
makeAllDraggable();
|
makeAllDraggable();
|
||||||
});
|
});
|
||||||
observer.observe(container, { childList: true, subtree: true });
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -634,6 +634,13 @@
|
|||||||
"dashboard.zero_files": "0 files",
|
"dashboard.zero_files": "0 files",
|
||||||
"dragdrop.cross_vault_unsupported": "Cross-vault moves are not supported",
|
"dragdrop.cross_vault_unsupported": "Cross-vault moves are not supported",
|
||||||
"dragdrop.move_failed": "Move failed",
|
"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.cancel": "Cancel",
|
||||||
"editor.close": "Close",
|
"editor.close": "Close",
|
||||||
"editor.delete_error": "Delete error",
|
"editor.delete_error": "Delete error",
|
||||||
|
|||||||
@@ -632,8 +632,15 @@
|
|||||||
"dashboard.views": "{count} vue(s)",
|
"dashboard.views": "{count} vue(s)",
|
||||||
"dashboard.welcome": "Bienvenue dans ObsiGate",
|
"dashboard.welcome": "Bienvenue dans ObsiGate",
|
||||||
"dashboard.zero_files": "0 fichiers",
|
"dashboard.zero_files": "0 fichiers",
|
||||||
"dragdrop.cross_vault_unsupported": "Cross-vault moves are not supported",
|
"dragdrop.cross_vault_unsupported": "Le déplacement entre vaults différents n'est pas supporté",
|
||||||
"dragdrop.move_failed": "Move failed",
|
"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.cancel": "Annuler",
|
||||||
"editor.close": "Fermer",
|
"editor.close": "Fermer",
|
||||||
"editor.delete_error": "Erreur de suppression",
|
"editor.delete_error": "Erreur de suppression",
|
||||||
|
|||||||
@@ -1124,6 +1124,93 @@ select {
|
|||||||
outline-offset: -2px;
|
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 {
|
.tree-children {
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user