Files
ObsiGate/backend/services/mutations.py
T
bruno d5c528fead feat: structure des classeurs Excel editable depuis la visionneuse #153
A14 : service mutate_xlsx_structure (batch ordonné d'actions
sheet_add/sheet_rename/sheet_delete/sheet_duplicate et
row/col_insert/delete, une seule réécriture verrouillée et atomique,
409 lossy sans force, backup, dernier sheet protégé) ; endpoint
PUT /api/file/{vault}/xlsx/structure (1-50 actions/requête) ;
menu « structure » dans la visionneuse : ajout/renommage/duplication/
suppression de feuilles et insertion/suppression de ligne/colonne à
partir de la cellule active, confirmations explicites pour les
destructions, re-rendu serveur après succès, reprise force après
confirmation 409.

Vérifié : test_xlsx_structure.py 11 (ops, gardes, atomicité du batch,
409 lossy + force) + contre-preuve (garde dernier sheet neutralisée ->
1 échec), xlsx-viewer.test.mjs 33/33 (3 nouveaux), ruff 0, mypy 0,
i18n parity, validate-imports.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 12:53:15 -04:00

1267 lines
46 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 datetime import date, datetime
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 A10 — types recognised when a user types into a cell. Excel infers them
# too; storing everything as text would make a spreadsheet unusable (a boolean
# column stays a string, a date column sorts lexicographically).
_XLSX_TRUE_LITERALS = {"true", "vrai", "oui", "yes"}
_XLSX_FALSE_LITERALS = {"false", "faux", "non", "no"}
# Shape check before strptime: keeps the hot path free of format attempts.
_XLSX_DATE_RE = re.compile(r"^\d{1,2}[-/]\d{1,2}[-/]\d{4}(?:[ T]\d{1,2}:\d{2})?$")
# #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 (#153 A10).
The coercion is symmetric with :func:`backend.xlsx_reader._fmt`: a value
typed by the user comes back as a string, and Excel would have inferred a
type when typing the same thing. Recognised here:
* an empty cell -> ``None`` (clears it)
* ``1234`` / ``-1`` -> ``int``
* ``1.5`` / ``.5`` -> ``float``
* ``TRUE``/``FAUX`` (case-insensitive) -> ``bool``
* ``31/12/2026`` / ``31/12/2026 14:30`` -> ``date``/``datetime`` (FR)
Anything else stays text. A date-looking string typed with a leading
``=`` is a formula and never reaches here as a date.
"""
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)
lowered = text.lower()
if lowered in _XLSX_TRUE_LITERALS:
return True
if lowered in _XLSX_FALSE_LITERALS:
return False
if not _XLSX_FORMULA_RE.match(text):
parsed = _parse_fr_datetime(text)
if parsed is not None:
return parsed
return value
def _parse_fr_datetime(text: str) -> date | datetime | None:
"""Parse a FR-localised date/datetime, or return ``None``.
Accepts ``JJ/MM/AAAA`` and ``JJ/MM/AAAA HH:MM`` (also ``JJ-MM-AAAA``).
``dayfirst`` is what makes ``01/02/2026`` the 1st of February rather than
the 2nd of January — the French convention.
"""
if not _XLSX_DATE_RE.match(text):
return None
for fmt in ("%d/%m/%Y %H:%M", "%d/%m/%Y", "%d-%m-%Y %H:%M", "%d-%m-%Y"):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
return None
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 mutate_xlsx_structure(
vault_name: str,
path: str,
actions: list[dict[str, Any]],
*,
backup: bool = True,
force: bool = False,
) -> dict[str, Any]:
"""Apply structural changes to an ``.xlsx`` workbook (#153 A14).
``actions`` is an ordered list — the workbook is loaded once and every
action is applied in sequence inside the same per-file lock and the same
atomic replace, so a half-applied batch can never reach the disk:
* ``{"op": "sheet_add", "name": "X", "at": 1}`` — new sheet (at =
optional 0-based position);
* ``{"op": "sheet_rename", "from": "X", "to": "Y"}``;
* ``{"op": "sheet_delete", "name": "X"}`` — refused when it is the
last sheet (an openpyxl workbook must keep one);
* ``{"op": "sheet_duplicate", "name": "X", "as": "Y"}`` — values,
styles and merged ranges are copied (not the data-dependent objects);
* ``{"op": "row_insert"|"row_delete"|"col_insert"|"col_delete",
"sheet": "X", "at": N, "count": k}`` — 1-based position, default 1.
All of it rides the same guards as the cell edits (P0): per-file lock,
``.tmp`` + ``os.replace`` atomic write and the ``force`` gate on lossy
round-trips. The UI proposes these actions with an explicit confirmation
— deletions are NOT recoverable from the viewer (only via the ``.bak``).
"""
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 not actions or len(actions) > 50:
raise ServiceError(
"Invalid actions (1 to 50 per request)", code="invalid", status=400
)
if not force:
from backend.xlsx_reader import inspect_workbook
lossy = inspect_workbook(file_path)
if lossy:
raise ServiceError(
"Restructuring 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
from openpyxl.worksheet.copier import WorksheetCopy
try:
wb = load_workbook(file_path)
except Exception as exc:
raise ServiceError(
f"Cannot open workbook: {exc}", code="invalid", status=400
) from exc
rel_path = _rel(root, file_path)
applied: list[str] = []
try:
for i, action in enumerate(actions):
op = action.get("op")
try:
if op == "sheet_add":
name = str(action.get("name", "")).strip()
if not name or name in wb.sheetnames:
raise ServiceError(
f"Nom de feuille invalide ou déjà pris: {name!r}",
code="invalid", status=400,
)
ws = wb.create_sheet(name[:31])
at = action.get("at")
# create_sheet appends at the end: shift left by the
# distance between the last index and the target.
if isinstance(at, int) and 0 <= at < len(wb.sheetnames):
wb.move_sheet(ws, offset=at - (len(wb.sheetnames) - 1))
applied.append(f"sheet_add:{ws.title}")
elif op == "sheet_rename":
src, dst = str(action.get("from", "")), str(action.get("to", "")).strip()
if src not in wb.sheetnames or not dst or dst in wb.sheetnames:
raise ServiceError(
f"Renommage invalide: {src!r} -> {dst!r}",
code="invalid", status=400,
)
wb[src].title = dst[:31]
applied.append(f"sheet_rename:{src}->{dst}")
elif op == "sheet_delete":
name = str(action.get("name", ""))
if name not in wb.sheetnames:
raise ServiceError(
f"Feuille introuvable: {name}", code="invalid", status=400
)
if len(wb.sheetnames) <= 1:
raise ServiceError(
"Impossible de supprimer la dernière feuille",
code="invalid", status=400,
)
del wb[name]
applied.append(f"sheet_delete:{name}")
elif op == "sheet_duplicate":
name = str(action.get("name", ""))
new_name = str(action.get("as", "")).strip()
if name not in wb.sheetnames or not new_name or new_name in wb.sheetnames:
raise ServiceError(
f"Duplication invalide: {name!r} -> {new_name!r}",
code="invalid", status=400,
)
# WorksheetCopy is the documented dup path (openpyxl
# 3.1); it copies values, styles and merges — not
# charts/images, which openpyxl itself cannot clone.
copy = wb.create_sheet(new_name[:31])
WorksheetCopy(wb[name], copy).copy_worksheet()
applied.append(f"sheet_duplicate:{name}->{copy.title}")
elif op in ("row_insert", "row_delete", "col_insert", "col_delete"):
sheet = str(action.get("sheet", ""))
if sheet not in wb.sheetnames:
raise ServiceError(
f"Feuille introuvable: {sheet}", code="invalid", status=400
)
ws = wb[sheet]
at = action.get("at", 1)
count = action.get("count", 1)
if not isinstance(at, int) or at < 1 or not isinstance(count, int) or count < 1:
raise ServiceError(
"Position 'at' / 'count' invalides", code="invalid", status=400
)
if op == "row_insert":
ws.insert_rows(at, count)
elif op == "row_delete":
ws.delete_rows(at, count)
elif op == "col_insert":
ws.insert_cols(at, count)
else:
ws.delete_cols(at, count)
applied.append(f"{op}:{sheet}@{at}x{count}")
else:
raise ServiceError(
f"Action inconnue: {op!r}", code="invalid", status=400
)
except ServiceError:
raise
except Exception as exc:
raise ServiceError(
f"Action {i + 1} ({op}) a échoué: {exc}",
code="invalid", status=400,
) from exc
except ServiceError:
wb.close()
raise
if backup:
create_backup(file_path, vault_name, rel_path)
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)
wb.close()
raise
wb.close()
logger.info(
f"XLSX structure: {vault_name}/{rel_path} {applied}"
)
return {
"success": True,
"vault": vault_name,
"path": rel_path,
"applied": applied,
}
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),
}