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]>
1031 lines
36 KiB
Python
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),
|
|
}
|
|
|