feat(dragdrop): drag & drop complet de fichiers/dossiers & intégration IA (#89)
This commit is contained in:
@@ -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