Restaure et termine le lot tableur resté non committé. Il n'existait que dans un `git stash` (24 fichiers suivis) et en fichiers non suivis (menus.js, formula.js, color-picker.js, tests, fiches) : la 2.53.3 livrée ne le contenait donc pas. Le stash, créé avant le commit BUG-108, n'a jamais été restauré. #180 Redimensionnement au curseur (en-tête de bord miroité + classe de repaint), grille thémée sur les 15 thèmes et les modes contraste élevé / sépia, menu Fichier au niveau des onglets, barre épinglée pleine largeur. #181 Modèle de saisie Google Sheets : sélection ≠ édition (caret masqué sans quitter contenteditable, double-clic / F2 / première frappe qui remplace, Entrée contextuelle, Échap qui restaure) + inventaire priorisé des écarts. #182 Désélection fiable (clic simple sans dépendre du focus), contours de plage non empilés (sélecteur de classes sans point), couleurs texte/fond sur une plage, clic droit qui préserve la sélection multiple. #183 Panneau de couleurs façon Google Sheets : palette 8 × 10, STANDARD, PERSONNALISÉ, coche selon la luminance, sortie `#rrggbb` (une valeur HSL était rejetée par normHex). #184 Peinture de format complète (toutes propriétés, source sans format = réinitialisation), sélection multi-lignes/colonnes depuis les marges, grille étendue : colonnes A → Z d'emblée, lignes ajoutées PAR BLOCS DE 100 au défilement jusqu'à 1000 — matérialiser 1000 lignes d'un coup = ~26 000 cellules câblées par feuille, ce qui épuisait le tas de la suite JSDOM ; index de cellules `ref → td`, court-circuits formule/styles, marqueur data-wired. #185 Barre de menus : les 10 menus Google Sheets (161 entrées, 125 câblées, 36 annoncées indisponibles), ruban façon Sheets, grille unie 1 px dérivée du thème. #186 « Créer un fichier » propose .xlsx et construit un vrai classeur OPC (openpyxl) au lieu d'une charge utile texte illisible. Corrections trouvées en restaurant et en exerçant le lot : - fuite mémoire : écouteur `click` anonyme posé sur #content-area à chaque rendu, jamais retiré — sa fermeture retenait la grille précédente en entier ; - sorties Markdown / HTML / Imprimer de la barre de menus inertes (`data-xlsx-export` jamais réparti, seul `data-xlsx-action` l'était) ; - collision de classe `.xlsx-structure-menu` entre la barre de menus et le menu Structure de la barre d'outils (toute requête tombait sur un nœud masqué) ; - curseur col/row-resize absent quand le pointeur est sur la table elle-même ; - l'export emportait les lignes et colonnes vides du quadrillage (CSV, Markdown, HTML, impression) ; - clic extérieur avalé par la grâce de 250 ms du menu contextuel (destinée au seul appui long tactile) ; - une entrée indisponible laissait la barre de menus ouverte. Tests : pytest 1595 passés / 2 ignorés ; ruff et mypy 0 erreur ; bandit 0 ; 33 suites frontend vertes (xlsx-viewer 163/163, xlsx-menus 19/19, xlsx-formula 14/14) ; E2E complet 132 passés / 12 ignorés ; E2E xlsx-viewer 19/19.
1953 lines
74 KiB
Python
1953 lines
74 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":{}}'
|
|
)
|
|
|
|
# Title of the single sheet of a workbook created from scratch (#186).
|
|
_XLSX_DEFAULT_SHEET = "Feuille1"
|
|
|
|
|
|
def _empty_xlsx_bytes() -> bytes:
|
|
"""Return the bytes of a valid, empty single-sheet workbook.
|
|
|
|
An ``.xlsx`` is a ZIP archive (OPC), not a text file: writing ``""`` to it
|
|
produces a file no reader (and no spreadsheet application) can open. The
|
|
"Create a file" modal posts an empty text payload, so the archive itself is
|
|
built here with openpyxl.
|
|
"""
|
|
import io
|
|
|
|
from openpyxl import Workbook
|
|
|
|
wb = Workbook()
|
|
wb.active.title = _XLSX_DEFAULT_SHEET
|
|
buffer = io.BytesIO()
|
|
wb.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
|
|
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 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). ``.xlsx`` is binary: the workbook is always
|
|
built from scratch and *content* is ignored for that extension.
|
|
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))
|
|
|
|
suffix = file_path.suffix.lower()
|
|
try:
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if suffix == ".xlsx":
|
|
# Binary OPC archive: a text write would leave an unopenable file
|
|
# (#186). Written as bytes, so the size below is the byte count.
|
|
payload = _empty_xlsx_bytes()
|
|
file_path.write_bytes(payload)
|
|
else:
|
|
if suffix == ".excalidraw" and not content.strip():
|
|
content = _EXCALIDRAW_SKELETON
|
|
payload = content.encode("utf-8")
|
|
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(payload)}
|
|
|
|
|
|
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":
|
|
# Le modèle sparse d'openpyxl ignore les inserts
|
|
# au-delà de la dernière ligne utilisée (rien à
|
|
# décaler), et l'écrivain abandonne les cellules
|
|
# vides (None) au cycle suivant : on matérialise
|
|
# avec " " (un blanc qui persiste, relu tel quel,
|
|
# affiché vide, ignoré par la recherche) pour
|
|
# qu'une insertion « sous la dernière ligne »
|
|
# crée bien des lignes blanches, comme Excel.
|
|
if at > ws.max_row:
|
|
if at - ws.max_row > _INSERT_GAP_LIMIT:
|
|
raise ServiceError(
|
|
"Position trop éloignée de la fin de la feuille",
|
|
code="invalid", status=400,
|
|
)
|
|
for r in range(at, at + count):
|
|
cell = ws.cell(row=r, column=1)
|
|
if cell.value is None:
|
|
cell.value = " "
|
|
else:
|
|
ws.insert_rows(at, count)
|
|
elif op == "row_delete":
|
|
ws.delete_rows(at, count)
|
|
elif op == "col_insert":
|
|
# Idem côté colonnes : « à droite de la dernière ».
|
|
if at > ws.max_column:
|
|
if at - ws.max_column > _INSERT_GAP_LIMIT:
|
|
raise ServiceError(
|
|
"Position trop éloignée de la fin de la feuille",
|
|
code="invalid", status=400,
|
|
)
|
|
for c in range(at, at + count):
|
|
cell = ws.cell(row=1, column=c)
|
|
if cell.value is None:
|
|
cell.value = " "
|
|
else:
|
|
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.
|
|
# Cap anti-DoS : une insertion au-delà de la fin matérialise des cellules,
|
|
# jamais un gouffre de lignes/colonnes.
|
|
_INSERT_GAP_LIMIT = 1000
|
|
_STYLE_KEYS = {
|
|
"bold",
|
|
"italic",
|
|
"underline",
|
|
"strike",
|
|
"font_size",
|
|
"font_color",
|
|
"fill_color",
|
|
"align",
|
|
"valign",
|
|
"wrap",
|
|
"rotation",
|
|
"border",
|
|
"border_style",
|
|
"border_color",
|
|
"number_format",
|
|
}
|
|
_STYLE_ALIGNS = {"left", "center", "right", "justify"}
|
|
_STYLE_VALIGNS = {"top": "top", "middle": "center", "bottom": "bottom"}
|
|
_STYLE_BORDERS = {"all", "outer", "none"}
|
|
_STYLE_BORDER_STYLES = {
|
|
"thin", "medium", "thick", "dashed", "dotted", "double", "hair",
|
|
"mediumDashed", "dashDot", "mediumDashDot", "dashDotDot",
|
|
"mediumDashDotDot", "slantDashDot",
|
|
}
|
|
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, Border, Color, PatternFill, Side
|
|
|
|
unknown = set(style) - _STYLE_KEYS
|
|
if unknown:
|
|
raise ServiceError(
|
|
f"Style inconnu: {sorted(unknown)}", code="invalid", status=400
|
|
)
|
|
if {"bold", "italic", "underline", "strike", "font_size", "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 "strike" in style:
|
|
font.strike = bool(style["strike"])
|
|
if "font_size" in style:
|
|
try:
|
|
size = float(style["font_size"])
|
|
except (TypeError, ValueError):
|
|
raise ServiceError(
|
|
f"Taille de police invalide: {style['font_size']!r}",
|
|
code="invalid", status=400,
|
|
) from None
|
|
if not 6 <= size <= 72:
|
|
raise ServiceError(
|
|
f"Taille de police hors bornes (6-72): {style['font_size']!r}",
|
|
code="invalid", status=400,
|
|
)
|
|
font.size = size
|
|
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),
|
|
text_rotation=getattr(current, "text_rotation", None),
|
|
)
|
|
if "valign" in style or "wrap" in style or "rotation" in style:
|
|
current = cell.alignment
|
|
vertical = getattr(current, "vertical", None)
|
|
wrap_text = getattr(current, "wrap_text", None)
|
|
text_rotation = getattr(current, "text_rotation", None) or 0
|
|
if "valign" in style:
|
|
valign = style["valign"]
|
|
if valign not in _STYLE_VALIGNS:
|
|
raise ServiceError(
|
|
f"Alignement vertical invalide: {valign!r}", code="invalid", status=400
|
|
)
|
|
vertical = _STYLE_VALIGNS[valign]
|
|
if "wrap" in style:
|
|
wrap_text = bool(style["wrap"])
|
|
if "rotation" in style:
|
|
try:
|
|
rotation = int(style["rotation"])
|
|
except (TypeError, ValueError):
|
|
raise ServiceError(
|
|
f"Rotation invalide: {style['rotation']!r}", code="invalid", status=400
|
|
) from None
|
|
if not (-90 <= rotation <= 90 or rotation == 180):
|
|
raise ServiceError(
|
|
f"Rotation hors bornes (-90..90, 180): {rotation!r}", code="invalid", status=400
|
|
)
|
|
# OOXML stocke les angles horaires sur 91..180 (-45° → 135).
|
|
# 180 = texte vertical retourné (le lecteur le relit tel quel).
|
|
text_rotation = rotation if rotation >= 0 else 180 + rotation
|
|
cell.alignment = Alignment(
|
|
horizontal=getattr(current, "horizontal", None),
|
|
vertical=vertical,
|
|
wrap_text=wrap_text,
|
|
text_rotation=text_rotation,
|
|
)
|
|
if {"border", "border_style", "border_color"} & set(style):
|
|
which = style.get("border", "all")
|
|
if which not in _STYLE_BORDERS:
|
|
raise ServiceError(
|
|
f"Bordure invalide: {which!r}", code="invalid", status=400
|
|
)
|
|
bstyle = style.get("border_style", "thin")
|
|
if bstyle not in _STYLE_BORDER_STYLES:
|
|
raise ServiceError(
|
|
f"Style de bordure invalide: {bstyle!r}", code="invalid", status=400
|
|
)
|
|
raw_color = style.get("border_color", "#000000")
|
|
if raw_color in (None, ""):
|
|
raw_color = "FF000000"
|
|
rgb = _style_color(raw_color)
|
|
if rgb is None:
|
|
raise ServiceError(
|
|
f"Couleur de bordure invalide: {style.get('border_color')!r}",
|
|
code="invalid", status=400,
|
|
)
|
|
if which == "none":
|
|
cell.border = Border()
|
|
else:
|
|
# `all` et `outer` se rendent pareil cellule par cellule : les
|
|
# bords partagés coïncident, le contour de la plage reste net.
|
|
side = Side(style=bstyle, color=rgb)
|
|
cell.border = Border(left=side, right=side, top=side, bottom=side)
|
|
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``, ``strike``, ``font_size`` (6-72),
|
|
``font_color`` / ``fill_color`` (``#rrggbb``, ``""`` clears),
|
|
``align`` (left/center/right/justify), ``valign`` (top/middle/bottom),
|
|
``wrap`` (bool), ``rotation`` (-90..90), ``border``
|
|
(all/outer/none) + ``border_style`` / ``border_color`` 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": "comment_set", "sheet": "X", "ref": "B2", "text": "…"}``
|
|
(``author`` optional) / ``{"op": "comment_clear", ...}`` ;
|
|
* ``{"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
|
|
)
|
|
width = float(width)
|
|
if width == 0:
|
|
# 0 = largeur par défaut (équivalent « Default
|
|
# Width » d'Excel / « Reset » de Sheets) : on
|
|
# retire la mesure explicite au lieu d'écrire une
|
|
# largeur de 0, qu'Excel lirait comme une colonne
|
|
# quasi masquée pendant que l'affichage reste en
|
|
# largeur automatique.
|
|
ws.column_dimensions.pop(col.strip().upper(), None)
|
|
else:
|
|
ws.column_dimensions[col.strip().upper()].width = 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
|
|
)
|
|
height = float(height)
|
|
if height == 0:
|
|
# 0 = hauteur par défaut (même logique que la
|
|
# largeur ci-dessus) : la mesure explicite est
|
|
# retirée, la ligne redevient automatique.
|
|
ws.row_dimensions.pop(row, None)
|
|
else:
|
|
ws.row_dimensions[row].height = 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 '-'}")
|
|
elif op in ("comment_set", "comment_clear"):
|
|
from openpyxl.comments import Comment
|
|
|
|
ws = _sheet(wb, action)
|
|
ref = str(action.get("ref") or action.get("cell") or "")
|
|
min_col, min_row, max_col, max_row = _range(ref)
|
|
if (min_col, min_row) != (max_col, max_row):
|
|
raise ServiceError(
|
|
"Un commentaire vise une cellule (A1), pas une plage",
|
|
code="invalid", status=400,
|
|
)
|
|
if op == "comment_clear":
|
|
ws.cell(row=min_row, column=min_col).comment = None
|
|
else:
|
|
text = str(action.get("text") or "")
|
|
if not text.strip() or len(text) > 2000:
|
|
raise ServiceError(
|
|
"Texte de commentaire invalide (1 à 2000 caractères)",
|
|
code="invalid", status=400,
|
|
)
|
|
author = str(action.get("author") or "ObsiGate")[:64]
|
|
ws.cell(row=min_row, column=min_col).comment = Comment(
|
|
text, author
|
|
)
|
|
applied.append(f"{op}:{ws.title}!{ref}")
|
|
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),
|
|
}
|
|
|