Files
ObsiGate/backend/services/mutations.py
T
bruno c5c225a68e
CI / lint (push) Successful in 2m36s
CI / security (push) Successful in 1m49s
CI / test (push) Successful in 4m33s
CI / build (push) Successful in 2m39s
CI / e2e (push) Successful in 15m45s
feat: completude de l'editeur Excel - mise en forme, sortie, concurrence, cache, outils IA #156
L'editeur lisait les styles mais ne les ecrivait pas, exportait la feuille
entiere et laissait le dernier-ecrivain gagner entre processus.

- A8 : bouton Mise en forme (gras/italique/souligne, alignements, couleurs via
  selecteur natif, formats de nombre, fusion, volets figes, largeur/hauteur) et
  nouvelle route PUT .../xlsx/style (verrou, backup, swap atomique, garde de
  perte, If-Match) ; A9 : decision "pas de moteur de formule" annoncee dans
  l'UI ; A10 : undo/redo unifie, piles par fichier conservees au re-rendu
- A11 : export de la selection + Markdown/HTML/impression et recherche sur
  toutes les feuilles ; A12 : concurrence optimiste (If-Match -> 409 reparable,
  retry qui relit) ; A13 : cache des metadonnees par (chemin, mtime, taille)
- A14 : outils IA .xlsm/.csv + search_workbook, analyze_range,
  edit_xlsx_structure
- tests : test_xlsx_styles.py (17), test_spreadsheet_tools.py (34),
  TestOptimisticConcurrency/TestMetaCache, JSDOM xlsx-viewer 107/107
2026-09-30 07:04:47 -04:00

1748 lines
64 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 _invalidate_meta(file_path: Path) -> None:
"""Drop the cached workbook metadata after a write (#156-A13).
Imported lazily so the module stays importable when the spreadsheet reader
is not needed (it pulls openpyxl in).
"""
try:
from backend.xlsx_reader import invalidate_workbook_meta
invalidate_workbook_meta(file_path)
except Exception: # pragma: no cover - cache invalidation is best-effort
logger.debug("workbook meta invalidation skipped", exc_info=True)
def file_revision(file_path: Path) -> str:
"""Opaque revision token of a file — ``mtime_ns:size`` in hex (#156-A12).
Cheap by design (one ``stat``) and enough for optimistic concurrency: any
writer that replaces the file changes at least one of the two fields. The
viewer reads it with the file and sends it back as the ``if_match`` of a
write, so an external editor (Excel, the watcher, another worker) can no
longer be silently overwritten.
Returns:
The token, or ``""`` when the file cannot be stat'ed.
"""
try:
st = file_path.stat()
except OSError:
return ""
return f"{st.st_mtime_ns:x}-{st.st_size:x}"
def _check_revision(file_path: Path, expected: str | None) -> None:
"""Refuse a write on a file that changed since it was read (#156-A12).
``expected`` comes from the read payload (``if_match``): when it is absent
the write keeps its pre-A12 behaviour (last writer wins), so curl, the AI
tools and the batch uploader are unaffected.
Raises:
ServiceError: ``conflict`` (409, ``reason=stale_revision``) when the
file on disk is not the revision the caller read.
"""
if not expected:
return
current = file_revision(file_path)
if current and current != expected:
raise ServiceError(
"The file changed on disk since it was read; reload before saving",
code="conflict",
status=409,
details={
"reason": "stale_revision",
"path": str(file_path),
"expected_revision": expected,
"current_revision": current,
},
)
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,
expected_revision: str | None = None,
) -> 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() not in (".xlsx", ".xlsm"):
raise ServiceError(
f"Not an .xlsx/.xlsm 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
)
# #153 A16 — the lossy gate is skipped for .xlsm: the save re-serializes
# with keep_vba=True, so the macro project (the only extra part a .xlsm
# carries) survives and nothing is dropped.
if not force and file_path.suffix.lower() != ".xlsm":
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)):
# #156-A12 — inside the lock: no writer can slip in between the check
# and the load.
_check_revision(file_path, expected_revision)
from openpyxl import load_workbook
# #153 A16 — .xlsm round-trips with keep_vba=True so the macro
# project survives the save (the endpoint's lossy probe is empty
# for .xlsm on purpose).
try:
wb = load_workbook(file_path, keep_vba=file_path.suffix.lower() == ".xlsm")
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
# #156-A13 — the metadata cache is keyed on (mtime, size); drop it too
# so a rewrite that lands on the same tick can never serve stale maps.
_invalidate_meta(file_path)
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),
"revision": file_revision(file_path),
}
def mutate_xlsx_structure(
vault_name: str,
path: str,
actions: list[dict[str, Any]],
*,
backup: bool = True,
force: bool = False,
expected_revision: str | None = None,
) -> 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)):
# #156-A12 — stale-write guard (see edit_xlsx_cells).
_check_revision(file_path, expected_revision)
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()
_invalidate_meta(file_path)
logger.info(
f"XLSX structure: {vault_name}/{rel_path} {applied}"
)
return {
"success": True,
"vault": vault_name,
"path": rel_path,
"applied": applied,
"revision": file_revision(file_path),
}
# #156-A8 — write-side formatting. Every value is data-driven (the colours
# come from the caller, never from a hardcoded palette) and the whole batch
# rides the same lock, backup and atomic swap as the cell edits.
_STYLE_KEYS = {
"bold",
"italic",
"underline",
"font_color",
"fill_color",
"align",
"number_format",
}
_STYLE_ALIGNS = {"left", "center", "right"}
MAX_STYLE_CELLS = 10_000
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-fA-F]{6}$")
def _style_color(value: Any) -> str | None:
"""Normalise ``#rrggbb`` to openpyxl's ARGB, or ``None`` to clear."""
if value is None or value == "":
return None
if not isinstance(value, str) or not _HEX_COLOR_RE.match(value.strip()):
raise ServiceError(
f"Couleur invalide: {value!r}", code="invalid", status=400
)
return "FF" + value.strip().lstrip("#").upper()
def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
"""Apply the data-driven style fragment of one ``cell`` operation.
Only the keys listed in :data:`_STYLE_KEYS` are accepted — an unknown one
is a client bug, not something to ignore silently. Font attributes are
copied before mutation so the shared style of the other cells is left
untouched.
"""
import copy as copy_mod
from openpyxl.styles import Alignment, Color, PatternFill
unknown = set(style) - _STYLE_KEYS
if unknown:
raise ServiceError(
f"Style inconnu: {sorted(unknown)}", code="invalid", status=400
)
if {"bold", "italic", "underline", "font_color"} & set(style):
font = copy_mod.copy(cell.font)
if "bold" in style:
font.bold = bool(style["bold"])
if "italic" in style:
font.italic = bool(style["italic"])
if "underline" in style:
font.underline = "single" if style["underline"] else None
if "font_color" in style:
rgb = _style_color(style["font_color"])
font.color = Color(rgb=rgb) if rgb else None
cell.font = font
if "fill_color" in style:
rgb = _style_color(style["fill_color"])
cell.fill = (
PatternFill(start_color=rgb, end_color=rgb, fill_type="solid")
if rgb
else PatternFill(fill_type=None)
)
if "align" in style:
align = style["align"]
if align not in _STYLE_ALIGNS:
raise ServiceError(
f"Alignement invalide: {align!r}", code="invalid", status=400
)
current = cell.alignment
cell.alignment = Alignment(
horizontal=align,
vertical=getattr(current, "vertical", None),
wrap_text=getattr(current, "wrap_text", None),
)
if "number_format" in style:
fmt = style["number_format"] or "General"
if not isinstance(fmt, str) or len(fmt) > 120:
raise ServiceError(
"Format de nombre invalide", code="invalid", status=400
)
cell.number_format = fmt
def mutate_xlsx_style(
vault_name: str,
path: str,
ops: list[dict[str, Any]],
*,
backup: bool = True,
force: bool = False,
expected_revision: str | None = None,
) -> dict[str, Any]:
"""Write formatting on an ``.xlsx``/``.xlsm`` workbook (#156-A8).
``ops`` is an ordered list (1 to 50) applied in one locked, atomic rewrite:
* ``{"op": "cell", "sheet": "X", "range": "A1:B2", "style": {…}}`` with
``bold``, ``italic``, ``underline``, ``font_color`` / ``fill_color``
(``#rrggbb``, ``""`` clears), ``align`` (left/center/right) and
``number_format`` ;
* ``{"op": "merge"|"unmerge", "sheet": "X", "range": "A1:B2"}`` ;
* ``{"op": "col_width", "sheet": "X", "col": "A", "width": 24}`` ;
* ``{"op": "row_height", "sheet": "X", "row": 3, "height": 30}`` ;
* ``{"op": "freeze", "sheet": "X", "cell": "B2"}`` (``""`` releases).
Same guards as the cell edits: per-file lock, ``.bak`` backup,
``.tmp`` + ``os.replace`` atomic swap, lossy-write 409 and the optional
``expected_revision`` concurrency check (#156-A12).
"""
from openpyxl.utils import get_column_letter
from openpyxl.utils.cell import range_boundaries
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() not in (".xlsx", ".xlsm"):
raise ServiceError(
f"Not an .xlsx/.xlsm file: {path}", code="invalid", status=400
)
if not ops or len(ops) > 50:
raise ServiceError(
"Invalid ops (1 to 50 per request)", code="invalid", status=400
)
if not force and file_path.suffix.lower() != ".xlsm":
from backend.xlsx_reader import inspect_workbook
lossy = inspect_workbook(file_path)
if lossy:
raise ServiceError(
"Restyling 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},
)
def _range(ref: str) -> tuple[int, int, int, int]:
try:
min_col, min_row, max_col, max_row = range_boundaries(str(ref).upper())
except Exception as exc:
raise ServiceError(
f"Plage invalide: {ref!r}", code="invalid", status=400
) from exc
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_STYLE_CELLS:
raise ServiceError(
f"Plage trop grande (max {MAX_STYLE_CELLS} cellules)",
code="invalid",
status=400,
)
return min_col, min_row, max_col, max_row
def _sheet(wb: Any, action: dict[str, Any]) -> Any:
name = str(action.get("sheet", ""))
if name not in wb.sheetnames:
raise ServiceError(
f"Feuille introuvable: {name}",
code="invalid",
status=400,
details={"sheets": wb.sheetnames},
)
return wb[name]
applied: list[str] = []
with _xlsx_write_lock(str(file_path)):
_check_revision(file_path, expected_revision)
from openpyxl import load_workbook
try:
wb = load_workbook(
file_path, keep_vba=file_path.suffix.lower() == ".xlsm"
)
except Exception as exc:
raise ServiceError(
f"Cannot open workbook: {exc}", code="invalid", status=400
) from exc
rel_path = _rel(root, file_path)
try:
for i, action in enumerate(ops):
if not isinstance(action, dict):
raise ServiceError(
f"Action {i + 1} invalide", code="invalid", status=400
)
op = action.get("op")
try:
if op == "cell":
ws = _sheet(wb, action)
ref = str(action.get("range") or action.get("cell") or "")
min_col, min_row, max_col, max_row = _range(ref)
style = action.get("style") or {}
if not isinstance(style, dict) or not style:
raise ServiceError(
"Style vide", code="invalid", status=400
)
for row in ws.iter_rows(
min_row=min_row,
max_row=max_row,
min_col=min_col,
max_col=max_col,
):
for cell in row:
_apply_cell_style(cell, style)
applied.append(
f"cell:{ws.title}!{ref}:{','.join(sorted(style))}"
)
elif op in ("merge", "unmerge"):
ws = _sheet(wb, action)
ref = str(action.get("range", ""))
_range(ref)
if op == "merge":
ws.merge_cells(ref)
else:
ws.unmerge_cells(ref)
applied.append(f"{op}:{ws.title}!{ref}")
elif op == "col_width":
ws = _sheet(wb, action)
col = action.get("col")
width = action.get("width")
if isinstance(col, int):
col = get_column_letter(col)
if not isinstance(col, str) or not col.strip():
raise ServiceError(
"Colonne invalide", code="invalid", status=400
)
if not isinstance(width, (int, float)) or not 0 <= float(width) <= 255:
raise ServiceError(
"Largeur invalide (0 à 255)", code="invalid", status=400
)
ws.column_dimensions[col.strip().upper()].width = float(width)
applied.append(f"col_width:{ws.title}!{col}={width}")
elif op == "row_height":
ws = _sheet(wb, action)
row = action.get("row")
height = action.get("height")
if not isinstance(row, int) or row < 1:
raise ServiceError("Ligne invalide", code="invalid", status=400)
if not isinstance(height, (int, float)) or not 0 <= float(height) <= 409:
raise ServiceError(
"Hauteur invalide (0 à 409)", code="invalid", status=400
)
ws.row_dimensions[row].height = float(height)
applied.append(f"row_height:{ws.title}!{row}={height}")
elif op == "freeze":
ws = _sheet(wb, action)
cell_ref = str(action.get("cell", ""))
if cell_ref:
_range(cell_ref)
ws.freeze_panes = cell_ref.upper() or None
applied.append(f"freeze:{ws.title}!{cell_ref or '-'}")
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()
_invalidate_meta(file_path)
logger.info(f"XLSX style: {vault_name}/{rel_path} {applied}")
return {
"success": True,
"vault": vault_name,
"path": rel_path,
"applied": applied,
"revision": file_revision(file_path),
}
def save_csv_cells(
vault_name: str,
path: str,
cells: dict[str, Any],
*,
backup: bool = True,
expected_revision: str | None = None,
) -> dict[str, Any]:
"""Apply A1-addressed cell edits to a ``.csv`` file (#153 A16).
The file is re-parsed, patched and re-serialized with :mod:`csv` so
quoting follows RFC 4180. BUG-098 — the delimiter is **sniffed** and the
same one is reused on write-back: a `;`-separated French CSV used to be
parsed as a single column and rewritten with `,`. References beyond the
current extent grow the grid (missing rows/cells are filled with empty
strings). Values are stored as text: a CSV has no formula engine, so any
string — including ones starting with ``=`` — is written verbatim (the
render escapes it).
Raises:
ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict``
(409, concurrent write) or ``invalid`` (400) for a bad reference.
"""
import csv as csv_mod
import io as io_mod
from backend.xlsx_reader import sniff_csv_delimiter
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() != ".csv":
raise ServiceError(f"Not a .csv 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
)
# #156-A12 — same stale-write guard as the workbook path.
_check_revision(file_path, expected_revision)
raw = file_path.read_text(encoding="utf-8", errors="replace")
# BUG-098 — parse AND rewrite with the file's own delimiter.
delimiter = sniff_csv_delimiter(raw)
try:
rows = list(csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter))
except csv_mod.Error:
rows = [[line] for line in raw.splitlines()]
def _col_num(ref: str) -> int:
letters = ref.rstrip("0123456789").upper()
n = 0
for ch in letters:
n = n * 26 + (ord(ch) - ord("A") + 1)
return n
def _row_num(ref: str) -> int:
return int(ref[len(ref.rstrip("0123456789")):])
for ref, value in cells.items():
r, c = _row_num(ref), _col_num(ref)
while len(rows) < r:
rows.append([])
row = rows[r - 1]
while len(row) < c:
row.append("")
row[c - 1] = "" if value is None else str(value)
rel_path = _rel(root, file_path)
if backup:
create_backup(file_path, vault_name, rel_path)
buf = io_mod.StringIO()
csv_mod.writer(buf, delimiter=delimiter, lineterminator="\n").writerows(rows)
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
try:
tmp_path.write_text(buf.getvalue(), encoding="utf-8")
os.replace(tmp_path, file_path)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
logger.info(f"CSV cells saved: {vault_name}/{rel_path} +{len(cells)}")
return {
"success": True,
"vault": vault_name,
"path": rel_path,
"size": len(cells),
"revision": file_revision(file_path),
}
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),
}