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é
|
||||
|
||||
- **#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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
// 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();
|
||||
|
||||
Vendored
+144
-6
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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