feat(dragdrop): drag & drop complet de fichiers/dossiers & intégration IA (#89)
CI / lint (push) Successful in 1m20s
CI / security (push) Successful in 50s
CI / test (push) Successful in 2m42s
CI / build (push) Successful in 51s
CI / e2e (push) Successful in 10m29s

This commit is contained in:
2026-09-14 15:31:12 -04:00
parent 150b57d538
commit 1db3e0ad2f
13 changed files with 990 additions and 10 deletions
+100
View File
@@ -105,6 +105,9 @@ from backend.services.backups import list_backup_files as service_list_backup_fi
from backend.services.errors import ServiceError
from backend.services.files import read_raw_file
from backend.services.graph import get_graph as service_get_graph
from backend.services.mutations import (
batch_upload_files as service_batch_upload_files,
)
from backend.services.mutations import (
create_directory as service_create_directory,
)
@@ -436,6 +439,31 @@ class FileCreateResponse(BaseModel):
path: str = Field(description="Path of the created file")
class BatchUploadFileItem(BaseModel):
"""A single file/dir entry in a batch upload request."""
path: str = Field(description="Relative path of the item within the batch")
content: str | None = Field(default=None, description="Base64 encoded or text content for files")
is_dir: bool = Field(default=False, description="True if entry represents an empty directory")
class BatchUploadRequest(BaseModel):
"""Request payload for batch file/directory upload."""
target_dir: str = Field(default="", description="Base directory in vault to upload into (empty for root)")
files: list[BatchUploadFileItem] = Field(description="List of files and directories to upload")
overwrite: bool = Field(default=True, description="Whether to overwrite existing files (creates backups)")
class BatchUploadResponse(BaseModel):
"""Response from batch file/directory upload."""
success: bool = Field(description="True if all files uploaded without error")
vault: str = Field(description="Vault name")
target_dir: str = Field(description="Target directory")
uploaded: list[str] = Field(description="List of created/updated file paths")
created_dirs: list[str] = Field(description="List of created directory paths")
errors: list[dict[str, Any]] = Field(default_factory=list, description="List of items that failed")
total_files: int = Field(description="Total uploaded files count")
class FileRenameRequest(BaseModel):
"""Request to rename a file."""
path: str = Field(description="Current path of the file")
@@ -1905,6 +1933,78 @@ async def api_file_create(
return {"success": True, "path": result["path"]}
@app.post("/api/vault/{vault_name}/batch-upload", response_model=BatchUploadResponse)
async def api_batch_upload(
vault_name: str,
body: BatchUploadRequest,
current_user=Depends(require_auth),
):
"""Upload multiple files and directories (recursively) into a vault.
Accepts base64 encoded or plain text files with relative directory paths.
Creates missing parent folders safely.
Args:
vault_name: Target vault name.
body: BatchUploadRequest with target_dir and files list.
Returns:
BatchUploadResponse with summary of uploaded files and errors.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
import base64
items: list[dict[str, Any]] = []
for f in body.files:
if f.is_dir:
items.append({"path": f.path, "is_dir": True})
continue
raw_bytes = b""
if f.content is not None:
# Check if content is base64 encoded data URI or raw base64
content_str = f.content
if content_str.startswith("data:") and ";base64," in content_str:
content_str = content_str.split(";base64,", 1)[1]
try:
raw_bytes = base64.b64decode(content_str)
except Exception:
# Fallback to utf-8 text encoding
raw_bytes = f.content.encode("utf-8")
items.append({"path": f.path, "content": raw_bytes, "is_dir": False})
result = service_batch_upload_files(
vault_name,
body.target_dir,
items,
overwrite=body.overwrite,
)
# Update index and SSE notifications for uploaded files
for path in result["uploaded"]:
try:
await update_single_file(vault_name, path)
await sse_manager.broadcast("file_created", {
"vault": vault_name,
"path": path,
})
await dispatch_webhooks("file_created", {"vault": vault_name, "path": path})
except Exception as e:
logger.warning(f"Failed to post-process upload of {path}: {e}")
# SSE notification for tree refresh
if result["uploaded"] or result["created_dirs"]:
await sse_manager.broadcast("tree_updated", {
"vault": vault_name,
"target_dir": result["target_dir"],
})
return result
@app.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
async def api_file_rename(
vault_name: str,
+127 -2
View File
@@ -44,12 +44,17 @@ def _ensure_writable(root: Path) -> None:
raise ServiceError("Vault is read-only", code="read_only", status=403)
def _validate_extension(file_path: Path) -> None:
def _validate_extension(file_path: Path, *, allow_images: bool = False) -> None:
"""Reject unsupported file extensions (400)."""
from backend.indexer import SUPPORTED_EXTENSIONS
ext = file_path.suffix.lower()
if ext not in SUPPORTED_EXTENSIONS and file_path.name.lower() not in ("dockerfile", "makefile"):
allowed = SUPPORTED_EXTENSIONS
if allow_images:
from backend.attachment_indexer import IMAGE_EXTENSIONS
allowed = allowed | IMAGE_EXTENSIONS
if ext not in allowed and file_path.name.lower() not in ("dockerfile", "makefile"):
raise ServiceError(
f"Unsupported file extension: {ext}",
code="unsupported_extension",
@@ -684,3 +689,123 @@ def restore_backup(
"restored_from": version,
"current_backed_up": current_backed_up,
}
# ── D3. Batch upload & raw file save ───────────────────────────────────────
def save_raw_file(
vault_name: str,
path: str,
content: bytes,
*,
overwrite: bool = True,
) -> dict[str, Any]:
"""Save a binary or text file to a vault (e.g. from upload / drag-and-drop).
Creates parent directories automatically and safely validates the path.
Supports supported text extensions, images and Excalidraw files.
Args:
vault_name: Name of the vault.
path: Vault-relative path.
content: Raw bytes to write.
overwrite: When True, replace existing files (with backup).
Returns:
Dict with ``success``, ``vault``, ``path``, and ``size``.
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
file_path = resolve_safe_path(root, path)
_validate_extension(file_path, allow_images=True)
rel_path = _rel(root, file_path)
if file_path.exists():
if not overwrite:
raise ServiceError(
f"File already exists: {rel_path}",
code="already_exists",
status=409,
details={"vault": vault_name, "path": rel_path},
)
create_backup(file_path, vault_name, rel_path)
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_bytes(content)
except PermissionError as e:
raise ServiceError("Permission denied: cannot save file", code="permission_denied", status=403) from e
logger.info(f"Raw file saved: {vault_name}/{rel_path} ({len(content)} bytes)")
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)}
def batch_upload_files(
vault_name: str,
target_dir: str,
files: list[dict[str, Any]],
*,
overwrite: bool = True,
) -> dict[str, Any]:
"""Process a batch of uploaded files and directories into a vault.
Args:
vault_name: Name of the target vault.
target_dir: Base directory inside the vault (empty string for root).
files: List of dicts, each with:
- ``path``: relative path within the batch (e.g. ``"sub/doc.md"`` or ``"note.md"``).
- ``content``: bytes content (or base64 decoded).
- ``is_dir``: optional boolean for empty directories.
overwrite: Whether to overwrite existing files.
Returns:
Dict with ``uploaded`` (list of paths), ``created_dirs`` (list of paths),
and ``errors`` (list of error dicts).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
clean_target = (target_dir or "").strip().strip("/\\")
uploaded: list[str] = []
created_dirs: list[str] = []
errors: list[dict[str, Any]] = []
for item in files:
rel_subpath = (item.get("path") or "").strip().replace("\\", "/").lstrip("/")
if not rel_subpath:
continue
full_rel_path = f"{clean_target}/{rel_subpath}" if clean_target else rel_subpath
is_dir = item.get("is_dir", False)
if is_dir:
try:
dir_path = resolve_safe_path(root, full_rel_path)
dir_path.mkdir(parents=True, exist_ok=True)
created_dirs.append(_rel(root, dir_path))
except Exception as e:
errors.append({"path": full_rel_path, "error": str(e)})
continue
raw_bytes = item.get("content", b"")
if isinstance(raw_bytes, str):
raw_bytes = raw_bytes.encode("utf-8")
try:
res = save_raw_file(vault_name, full_rel_path, raw_bytes, overwrite=overwrite)
uploaded.append(res["path"])
except Exception as e:
errors.append({"path": full_rel_path, "error": str(e)})
return {
"success": len(errors) == 0,
"vault": vault_name,
"target_dir": clean_target,
"uploaded": uploaded,
"created_dirs": created_dirs,
"errors": errors,
"total_files": len(uploaded),
}