"""File and directory mutation services shared by REST routes and the AI tool layer. Single source of truth for the write-side operations (create, edit, append, rename, move, delete, restore, find/replace). Every function performs the anti path-traversal check, the read-only guard and an automatic backup before any destructive change, then returns a JSON-friendly result dict. Index refresh, SSE broadcasts, webhooks and audit logging stay in the route / agent layers: these services are synchronous and side-effect free apart from the filesystem mutation (plus the backup). """ from __future__ import annotations import logging import os import re import shutil import threading from collections.abc import Callable, Iterator from contextlib import contextmanager from pathlib import Path from typing import Any from backend.services.backups import create_backup, get_backup_dir from backend.services.errors import ServiceError from backend.services.paths import resolve_safe_path from backend.services.vaults import get_vault_root logger = logging.getLogger("obsigate.services.mutations") # #86: per-file size cap for find/replace passes (CPU guard — complements the # BUG-025 regex caps). Files larger than this are skipped instead of being # read fully into memory and scanned with a user-supplied pattern. MAX_REPLACE_FILE_BYTES = 5_000_000 # Skeleton injected into empty ``.excalidraw`` files (mirrors the route logic). _EXCALIDRAW_SKELETON = ( '{"type":"excalidraw","version":2,"elements":[],' '"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}' ) def _rel(root: Path, path: Path) -> str: """Return *path* relative to *root* as a POSIX-style string.""" return str(path.relative_to(root)).replace("\\", "/") def _ensure_writable(root: Path) -> None: """Raise ``read_only`` (403) when the vault root is not writable.""" if not os.access(root, os.W_OK): raise ServiceError("Vault is read-only", code="read_only", status=403) def _validate_extension(file_path: Path, *, allow_images: bool = False, allow_docs: bool = False) -> None: """Reject unsupported file extensions (400).""" from backend.indexer import SUPPORTED_EXTENSIONS ext = file_path.suffix.lower() allowed = SUPPORTED_EXTENSIONS if allow_images: from backend.attachment_indexer import IMAGE_EXTENSIONS allowed = allowed | IMAGE_EXTENSIONS if allow_docs: # Office documents produced by the AI tool layer (#92). allowed = allowed | {".xlsx", ".docx"} if ext not in allowed and file_path.name.lower() not in ("dockerfile", "makefile"): raise ServiceError( f"Unsupported file extension: {ext}", code="unsupported_extension", status=400, details={"extension": ext}, ) def _validate_new_name(name: str) -> str: """Validate a rename target (a plain name, not a path).""" candidate = (name or "").strip() if not candidate or candidate in (".", "..") or "/" in candidate or "\\" in candidate: raise ServiceError( f"Invalid name: {name!r}", code="invalid_arguments", status=400, details={"new_name": name}, ) return candidate # ── D1. Creation ─────────────────────────────────────────────────────────── def create_file( vault_name: str, path: str, content: str = "", *, overwrite: bool = False, ) -> dict[str, Any]: """Create a text file in a vault. Args: vault_name: Name of the vault. path: Vault-relative path of the new file. content: Initial content (an Excalidraw skeleton is injected for empty ``.excalidraw`` files). overwrite: When True, replace an existing file (with a backup) instead of raising ``already_exists``. Returns: ``{"success", "vault", "path", "size"}``. Raises: ServiceError: ``not_found`` (404) unknown vault, ``read_only`` (403), ``unsupported_extension`` (400) or ``already_exists`` (409). """ root = get_vault_root(vault_name) _ensure_writable(root) file_path = resolve_safe_path(root, path) _validate_extension(file_path) if file_path.exists(): if not overwrite: raise ServiceError( f"File already exists: {path}", code="already_exists", status=409, details={"vault": vault_name, "path": path}, ) create_backup(file_path, vault_name, _rel(root, file_path)) try: file_path.parent.mkdir(parents=True, exist_ok=True) if file_path.suffix.lower() == ".excalidraw" and not content.strip(): content = _EXCALIDRAW_SKELETON file_path.write_text(content, encoding="utf-8") except PermissionError as e: raise ServiceError("Permission denied: cannot create file", code="permission_denied", status=403) from e rel_path = _rel(root, file_path) logger.info(f"File created: {vault_name}/{rel_path}") return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)} def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> dict[str, Any]: """Create a directory (and its parents) in a vault. Args: vault_name: Name of the vault. path: Vault-relative path of the new directory. exist_ok: When True, an existing directory is a success (idempotent) instead of raising ``already_exists``. Used by the AI tool layer so a "create folder then create file" plan does not fail when the folder is already there (``create_file`` creates parents anyway). Raises: ServiceError: ``not_found`` (404), ``read_only`` (403) or ``already_exists`` (409) when *exist_ok* is False. """ root = get_vault_root(vault_name) _ensure_writable(root) dir_path = resolve_safe_path(root, path) if dir_path.exists(): if exist_ok and dir_path.is_dir(): return { "success": True, "vault": vault_name, "path": _rel(root, dir_path), "existed": True, } raise ServiceError( f"Directory already exists: {path}", code="already_exists", status=409, details={"vault": vault_name, "path": path}, ) try: dir_path.mkdir(parents=True, exist_ok=False) except PermissionError as e: raise ServiceError("Permission denied: cannot create directory", code="permission_denied", status=403) from e rel_path = _rel(root, dir_path) logger.info(f"Directory created: {vault_name}/{rel_path}") return {"success": True, "vault": vault_name, "path": rel_path} # ── D2. Edition / rename / move ──────────────────────────────────────────── def edit_file( vault_name: str, path: str, content: str, *, backup: bool = True, ) -> dict[str, Any]: """Overwrite an existing file's content (with a backup by default). Raises: ServiceError: ``not_found`` (404) or ``read_only`` (403). """ root = get_vault_root(vault_name) _ensure_writable(root) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): raise ServiceError( f"File not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) rel_path = _rel(root, file_path) if backup: create_backup(file_path, vault_name, rel_path) try: file_path.write_text(content, encoding="utf-8") except PermissionError as e: raise ServiceError("Permission denied: cannot save file", code="permission_denied", status=403) from e logger.info(f"File saved: {vault_name}/{rel_path}") return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)} # Cell reference like "A1" / "AB42" (Excel A1 notation, up to 3 letters / 8 digits). _XLSX_CELL_RE = re.compile(r"^[A-Z]{1,3}[1-9][0-9]{0,7}$") # ponytail: bare int/float coercion mirrors what Excel does when you type a # number; dates/booleans stay text (upgrade path: parse locale dates too). _XLSX_INT_RE = re.compile(r"^[+-]?\d+$") _XLSX_FLOAT_RE = re.compile(r"^[+-]?(?:\d+\.\d*|\.\d+)$") # #153 A4 — openpyxl turns any string starting with "=" into a formula, which # Excel then evaluates on open (DDE / =cmd|… / =HYPERLINK exfiltration). "@" is # the legacy Lotus-style trigger. "+"/"-" are left alone: they are numbers here. _XLSX_FORMULA_RE = re.compile(r"^[=@]") # #153 A3 — per-file write lock. Two concurrent saves (two tabs, the AI agent # and the viewer, a watcher restore) would otherwise read-modify-write on the # same archive and the last writer silently wins. Kept deliberately small: the # lock only covers the load → edit → atomic-replace window. _XLSX_LOCK_TIMEOUT = 15.0 _xlsx_locks: dict[str, threading.Lock] = {} _xlsx_locks_guard = threading.Lock() @contextmanager def _xlsx_write_lock(key: str) -> Iterator[None]: """Serialize the read-modify-write of one workbook path. Raises: ServiceError: ``conflict`` (409) when the lock is still held after :data:`_XLSX_LOCK_TIMEOUT` seconds. """ with _xlsx_locks_guard: lock = _xlsx_locks.setdefault(key, threading.Lock()) if not lock.acquire(timeout=_XLSX_LOCK_TIMEOUT): raise ServiceError( "Workbook is being modified by another operation, retry shortly", code="conflict", status=409, details={"path": key, "timeout_seconds": _XLSX_LOCK_TIMEOUT}, ) try: yield finally: lock.release() def _coerce_xlsx_value(value: Any) -> Any: """Turn the string sent by the cell editor back into a scalar.""" if not isinstance(value, str): return value text = value.strip() if text == "": return None if _XLSX_INT_RE.match(text): return int(text) if _XLSX_FLOAT_RE.match(text): return float(text) return value def _write_cell(ws: Any, ref: str, value: Any, *, allow_formula: bool) -> None: """Assign one cell, forcing text when it looks like a formula. ``cell.data_type = "s"`` is what stops openpyxl from emitting ````: the text is then stored as an inline/shared string and Excel shows it verbatim. """ cell = ws[ref] coerced = _coerce_xlsx_value(value) cell.value = coerced if not allow_formula and isinstance(coerced, str) and _XLSX_FORMULA_RE.match(coerced): cell.data_type = "s" def edit_xlsx_cells( vault_name: str, path: str, sheet: str, cells: dict[str, Any], *, backup: bool = True, allow_formula: bool = False, force: bool = False, ) -> dict[str, Any]: """Apply a batch of cell edits to an ``.xlsx`` workbook. Args: vault_name: Name of the vault the workbook belongs to. path: Vault-relative path of the ``.xlsx`` file. sheet: Worksheet title to edit. cells: Mapping of A1 references to new scalar values. backup: Create a timestamped ``.bak`` before rewriting the archive. allow_formula: Keep values starting with ``=``/``@`` as real formulas. Off by default (#153 A4): a typed ``=cmd|…`` is a DDE payload when the file is later opened in Excel. force: Write even when the workbook carries features openpyxl drops (slicers, form controls, connections, custom XML, signature, cached formula results — see :data:`backend.xlsx_reader.LOSSY_PARTS`). Raises: ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict`` (409, concurrent write), ``xlsx_lossy_content`` (409, a lossy write was attempted without ``force``) or ``invalid`` (400) for a bad sheet, cell reference or value. """ root = get_vault_root(vault_name) _ensure_writable(root) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): raise ServiceError( f"File not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) if file_path.suffix.lower() != ".xlsx": raise ServiceError( f"Not an .xlsx file: {path}", code="invalid", status=400 ) if not cells: raise ServiceError("No cells to update", code="invalid", status=400) for ref in cells: if not isinstance(ref, str) or not _XLSX_CELL_RE.match(ref): raise ServiceError( f"Invalid cell reference: {ref!r}", code="invalid", status=400 ) if not force: from backend.xlsx_reader import inspect_workbook lossy = inspect_workbook(file_path) if lossy: raise ServiceError( "Saving this workbook would drop features ObsiGate cannot " "preserve; retry with force=true after confirmation", code="xlsx_lossy_content", status=409, details={"path": path, "features": lossy}, ) with _xlsx_write_lock(str(file_path)): from openpyxl import load_workbook try: wb = load_workbook(file_path) except Exception as exc: raise ServiceError( f"Cannot open workbook: {exc}", code="invalid", status=400 ) from exc if sheet not in wb.sheetnames: raise ServiceError( f"Unknown sheet: {sheet}", code="invalid", status=400, details={"sheets": wb.sheetnames}, ) rel_path = _rel(root, file_path) if backup: create_backup(file_path, vault_name, rel_path) ws = wb[sheet] for ref, value in cells.items(): _write_cell(ws, ref, value, allow_formula=allow_formula) # #153 A2 — write beside the target then swap: a crash mid-save leaves # the original workbook intact instead of a truncated archive. tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp") try: wb.save(tmp_path) os.replace(tmp_path, file_path) except Exception: tmp_path.unlink(missing_ok=True) raise logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}") return { "success": True, "vault": vault_name, "path": rel_path, "size": len(cells), } def append_to_file( vault_name: str, path: str, content: str, *, backup: bool = True, ) -> dict[str, Any]: """Append text to an existing file (a newline is inserted if needed). Raises: ServiceError: ``not_found`` (404) or ``read_only`` (403). """ root = get_vault_root(vault_name) _ensure_writable(root) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): raise ServiceError( f"File not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) rel_path = _rel(root, file_path) if backup: create_backup(file_path, vault_name, rel_path) try: existing = file_path.read_text(encoding="utf-8", errors="replace") separator = "" if (not existing or existing.endswith("\n")) else "\n" new_content = existing + separator + content file_path.write_text(new_content, encoding="utf-8") except PermissionError as e: raise ServiceError("Permission denied: cannot append to file", code="permission_denied", status=403) from e logger.info(f"File appended: {vault_name}/{rel_path} (+{len(content)} chars)") return { "success": True, "vault": vault_name, "path": rel_path, "appended": len(content), "size": len(new_content), } def rename_file(vault_name: str, path: str, new_name: str) -> dict[str, Any]: """Rename a file in place (same parent directory). Raises: ServiceError: ``not_found`` (404), ``read_only`` (403), ``unsupported_extension`` (400) or ``already_exists`` (409). """ root = get_vault_root(vault_name) _ensure_writable(root) old_path = resolve_safe_path(root, path) if not old_path.exists() or not old_path.is_file(): raise ServiceError( f"File not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) new_path = old_path.parent / _validate_new_name(new_name) new_path = resolve_safe_path(root, _rel(root, new_path)) _validate_extension(new_path) if new_path.exists(): raise ServiceError( f"Destination already exists: {new_name}", code="already_exists", status=409, details={"vault": vault_name, "new_name": new_name}, ) old_rel = _rel(root, old_path) try: old_path.rename(new_path) except PermissionError as e: raise ServiceError("Permission denied: cannot rename file", code="permission_denied", status=403) from e new_rel = _rel(root, new_path) logger.info(f"File renamed: {vault_name}/{old_rel} -> {new_rel}") return {"success": True, "vault": vault_name, "old_path": old_rel, "new_path": new_rel} def rename_directory(vault_name: str, path: str, new_name: str) -> dict[str, Any]: """Rename a directory in place (same parent directory). Raises: ServiceError: ``not_found`` (404), ``read_only`` (403) or ``already_exists`` (409). """ root = get_vault_root(vault_name) _ensure_writable(root) old_path = resolve_safe_path(root, path) if not old_path.exists() or not old_path.is_dir(): raise ServiceError( f"Directory not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) new_path = old_path.parent / _validate_new_name(new_name) new_path = resolve_safe_path(root, _rel(root, new_path)) if new_path.exists(): raise ServiceError( f"Destination already exists: {new_name}", code="already_exists", status=409, details={"vault": vault_name, "new_name": new_name}, ) old_rel = _rel(root, old_path) try: old_path.rename(new_path) except PermissionError as e: raise ServiceError("Permission denied: cannot rename directory", code="permission_denied", status=403) from e new_rel = _rel(root, new_path) logger.info(f"Directory renamed: {vault_name}/{old_rel} -> {new_rel}") return {"success": True, "vault": vault_name, "old_path": old_rel, "new_path": new_rel} def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> dict[str, Any]: """Move a file or directory to another directory within the same vault. The item keeps its name; only its parent directory changes. Raises: ServiceError: ``not_found`` (404), ``read_only`` (403), ``unsupported_extension`` (400) or ``already_exists`` (409). """ root = get_vault_root(vault_name) _ensure_writable(root) source = resolve_safe_path(root, source_path) if not source.exists(): raise ServiceError( f"Source not found: {source_path}", code="not_found", status=404, details={"vault": vault_name, "path": source_path}, ) is_directory = source.is_dir() item_name = source.name dest_clean = (destination_dir or "").strip("/") if dest_clean: dest_parent = resolve_safe_path(root, dest_clean) if not dest_parent.exists() or not dest_parent.is_dir(): raise ServiceError( f"Destination directory not found: {destination_dir}", code="not_found", status=404, details={"vault": vault_name, "path": destination_dir}, ) else: dest_parent = root destination = resolve_safe_path(root, _rel(root, dest_parent / item_name)) if source.resolve() == destination.resolve(): rel = _rel(root, source) return { "success": True, "vault": vault_name, "old_path": rel, "new_path": rel, "item_type": "directory" if is_directory else "file", } if destination.exists(): raise ServiceError( f"A file or directory already exists at the destination: {destination.name}", code="already_exists", status=409, details={"vault": vault_name, "new_path": _rel(root, destination)}, ) if not is_directory: _validate_extension(destination) old_rel = _rel(root, source) try: source.rename(destination) except PermissionError as e: raise ServiceError("Permission denied: cannot move item", code="permission_denied", status=403) from e new_rel = _rel(root, destination) item_type = "directory" if is_directory else "file" logger.info(f"Item moved: {vault_name}/{old_rel} -> {new_rel}") return { "success": True, "vault": vault_name, "old_path": old_rel, "new_path": new_rel, "item_type": item_type, } # ── D3. Find & replace ───────────────────────────────────────────────────── def replace_in_files( find: str, replacement: str, *, vault: str = "all", case_sensitive: bool = False, whole_word: bool = False, regex: bool = False, include_paths: str | None = None, exclude_paths: str | None = None, replace_all: bool = False, dry_run: bool = True, is_vault_allowed: Callable[[str], bool] | None = None, ) -> dict[str, Any]: """Find and replace text across vault files (dry-run by default). A backup is created before every file is rewritten. When ``is_vault_allowed`` is provided, files from vaults it rejects are skipped (used by the tool layer to enforce per-vault permissions and the destructive-tools toggle). Returns: ``{"matches", "total_matches"}`` in dry-run mode (with ``"dry_run": True``) or ``{"replaced", "total_replacements"}`` when applied. """ import re as re_mod from backend.services.regex_safety import MAX_REGEX_MATCHES, validate_regex from backend.services.search import advanced_search_vaults if not find: raise ServiceError("Query is required", code="invalid_arguments", status=400) # BUG-025: validate the pattern before it is compiled / applied in bulk. if regex: try: validate_regex(find) except ValueError as e: raise ServiceError(str(e), code="invalid_arguments", status=400) from e try: search_results = advanced_search_vaults( find, vault=vault, case_sensitive=case_sensitive, whole_word=whole_word, regex=regex, include_paths=include_paths, exclude_paths=exclude_paths, limit=500, sort="relevance", ) except ValueError as e: raise ServiceError(str(e), code="invalid_arguments", status=400) from e if not search_results["results"]: return {"matches": [], "total_matches": 0, "dry_run": dry_run} flags = 0 if case_sensitive else re_mod.IGNORECASE if regex: pattern = re_mod.compile(find, flags) elif whole_word: pattern = re_mod.compile(rf"\b{re_mod.escape(find)}\b", flags) else: pattern = re_mod.compile(re_mod.escape(find), flags) matches: list[dict[str, Any]] = [] total = 0 for result in search_results["results"]: result_vault = result["vault"] if is_vault_allowed is not None and not is_vault_allowed(result_vault): continue try: root = get_vault_root(result_vault) file_path = resolve_safe_path(root, result["path"]) except ServiceError: continue if not file_path.exists() or not file_path.is_file(): continue # #86 CPU guard: skip files too large to scan safely in one pass. try: if file_path.stat().st_size > MAX_REPLACE_FILE_BYTES: logger.warning( "replace_in_files: skipping oversized file %s/%s (%d bytes)", result_vault, result["path"], file_path.stat().st_size, ) continue except OSError: continue try: original = file_path.read_text(encoding="utf-8", errors="replace") except OSError: continue occurrences = list(pattern.finditer(original))[:MAX_REGEX_MATCHES] if not occurrences: continue if dry_run: previews = [] for m in occurrences[:3]: start = max(0, m.start() - 40) end = min(len(original), m.end() + 40) previews.append(f"...{original[start:end]}...") matches.append({ "vault": result_vault, "path": result["path"], "title": result.get("title", result["path"]), "match_count": len(occurrences), "preview": previews, }) total += len(occurrences) continue new_content, count = pattern.subn(replacement, original) if count == 0: continue create_backup(file_path, result_vault, result["path"]) try: file_path.write_text(new_content, encoding="utf-8") except PermissionError as e: raise ServiceError( f"Permission denied writing {result['path']}", code="permission_denied", status=403, ) from e matches.append({ "vault": result_vault, "path": result["path"], "title": result.get("title", result["path"]), "replacements": count, "size": len(new_content), }) total += count if dry_run: return {"matches": matches, "total_matches": total, "dry_run": True} return {"replaced": matches, "total_replacements": total, "dry_run": False} # ── D4. Deletion / restore ───────────────────────────────────────────────── def delete_file(vault_name: str, path: str, *, backup: bool = True) -> dict[str, Any]: """Delete a file (with a backup by default). Raises: ServiceError: ``not_found`` (404) or ``read_only`` (403). """ root = get_vault_root(vault_name) _ensure_writable(root) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): raise ServiceError( f"File not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) rel_path = _rel(root, file_path) if backup: create_backup(file_path, vault_name, rel_path) try: file_path.unlink() except PermissionError as e: raise ServiceError("Permission denied: cannot delete file", code="permission_denied", status=403) from e logger.info(f"File deleted: {vault_name}/{rel_path}") return {"success": True, "vault": vault_name, "path": rel_path} def delete_directory(vault_name: str, path: str, *, recursive: bool = True) -> dict[str, Any]: """Delete a directory (recursively by default). Raises: ServiceError: ``not_found`` (404), ``read_only`` (403), ``not_empty`` (409) when non-recursive and not empty, or ``invalid_arguments`` (400) when targeting the vault root. """ root = get_vault_root(vault_name) _ensure_writable(root) dir_path = resolve_safe_path(root, path) if not dir_path.exists() or not dir_path.is_dir(): raise ServiceError( f"Directory not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) if dir_path.resolve() == root.resolve(): raise ServiceError( "Refusing to delete the vault root", code="invalid_arguments", status=400, details={"vault": vault_name}, ) file_count = sum(1 for p in dir_path.rglob("*") if p.is_file()) try: if recursive: shutil.rmtree(dir_path) else: if any(dir_path.iterdir()): raise ServiceError( f"Directory not empty: {path}", code="not_empty", status=409, details={"vault": vault_name, "path": path}, ) dir_path.rmdir() except PermissionError as e: raise ServiceError("Permission denied: cannot delete directory", code="permission_denied", status=403) from e rel_path = _rel(root, dir_path) logger.info(f"Directory deleted: {vault_name}/{rel_path} ({file_count} files)") return {"success": True, "vault": vault_name, "path": rel_path, "deleted_count": file_count} def restore_backup( vault_name: str, path: str, version: int, *, backup: bool = True, ) -> dict[str, Any]: """Restore a file from a backup version. The current file is backed up first (when ``backup`` is True) so the operation is reversible. Raises: ServiceError: ``not_found`` (404) when the file or backup is missing, or ``read_only`` (403). """ root = get_vault_root(vault_name) _ensure_writable(root) file_path = resolve_safe_path(root, path) backup_dir = get_backup_dir(vault_name, path) backup_path = backup_dir / f"{Path(path).name}.{version}.bak" if not backup_path.exists(): raise ServiceError( f"Backup version {version} not found for {path}", code="not_found", status=404, details={"vault": vault_name, "path": path, "version": version}, ) # Read the target version *before* backing up the current file: both use a # second-resolution timestamp, so a same-second backup could otherwise # overwrite the version we are about to restore. try: content = backup_path.read_text(encoding="utf-8") except OSError as e: raise ServiceError( f"Failed to read backup {version}: {e}", code="read_error", status=500, details={"vault": vault_name, "path": path, "version": version}, ) from e current_backed_up: int | None = None if backup and file_path.exists() and file_path.is_file(): import time as _time create_backup(file_path, vault_name, path) current_backed_up = int(_time.time()) try: file_path.write_text(content, encoding="utf-8") except PermissionError as e: raise ServiceError("Permission denied: cannot restore file", code="permission_denied", status=403) from e logger.info(f"File restored from backup: {vault_name}/{path} <- version {version}") return { "success": True, "vault": vault_name, "path": path, "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, allow_docs: bool = False, ) -> 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, Excalidraw files and — with ``allow_docs`` — Office documents (.xlsx/.docx) produced by the AI tools. Args: vault_name: Name of the vault. path: Vault-relative path. content: Raw bytes to write. overwrite: When True, replace existing files (with backup). allow_docs: Also accept .xlsx/.docx extensions (AI document tools). 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, allow_docs=allow_docs) 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), }