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