feat(dragdrop): drag & drop complet de fichiers/dossiers & intégration IA (#89)
This commit is contained in:
+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),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user