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
+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),
}