Files
ObsiGate/backend/services/mutations.py
T
bruno 31d4616baf feat: garde-fous d'écriture des classeurs Excel #153 (P0)
L'édition d'un .xlsx pouvait détruire une partie du classeur, le
concurrencer en silence, ou diffuser une injection de formule.

- BUG-085 : inspect_workbook() détecte ce qu'un round-trip openpyxl perd
  (valeurs calculées en cache, slicers, contrôles, connexions, custom
  XML, signature, commentaires enrichis, macros) → xlsx_lossy_features
  exposé en lecture, bandeau FR/EN, et 409 xlsx_lossy_content sans
  `force` (confirmation explicite puis reprise). Périmètre réel
  revalidé : graphiques, images et TCD survivent au round-trip.
- BUG-086 : écriture atomique (fichier .tmp + os.replace) : un plantage
  ne peut plus tronquer le classeur, le backup reste intact.
- BUG-087 : verrou par fichier autour du read-modify-write (timeout 15 s,
  409 conflict) ; endpoint xlsx/save devenu synchrone pour que
  l'attente s'exécute dans le threadpool.
- BUG-088 : une saisie en '=' ou '@' est stockée en texte, sauf opt-in
  `allow_formula` ou le bouton f(x) de la visionneuse. Le handler
  ServiceError expose désormais code + details, que api() propage.
- BUG-084 : la suppression d'une vault purge enfin l'index inversé
  (documents fantômes qui continuaient de matcher) et is_stale() devient
  is_ready(), le nom étant trompeur (la staleness n'existe plus).

Tests : 1390 pytest, 10 JSDOM (xlsx-viewer.test.mjs, branché au CI),
3 E2E Playwright, suite E2E complète verte, ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-27 20:39:12 -04:00

1031 lines
36 KiB
Python

"""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 ``<f>``: 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),
}