feat(ai): catalogue d'outils mutations + confirmations two-step (#79 phase D)
CI / lint (push) Successful in 59s
CI / security (push) Successful in 45s
CI / test (push) Successful in 1m22s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m37s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 59s
CI / security (push) Successful in 45s
CI / test (push) Successful in 1m22s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m37s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
This commit is contained in:
@@ -12,6 +12,8 @@ from __future__ import annotations
|
||||
import difflib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -22,6 +24,19 @@ from backend.services.vaults import get_vault_root
|
||||
|
||||
logger = logging.getLogger("obsigate.services.backups")
|
||||
|
||||
# Default number of backups kept per file (matches ``max_backups_per_file``).
|
||||
DEFAULT_MAX_BACKUPS = 10
|
||||
|
||||
|
||||
def _default_max_backups() -> int:
|
||||
"""Read ``max_backups_per_file`` from app config (lazy, best-effort)."""
|
||||
try:
|
||||
from backend.main import _load_config
|
||||
|
||||
return int(_load_config().get("max_backups_per_file", DEFAULT_MAX_BACKUPS))
|
||||
except Exception: # pragma: no cover - config unavailable
|
||||
return DEFAULT_MAX_BACKUPS
|
||||
|
||||
|
||||
def get_backup_dir(vault_name: str, relative_path: str) -> Path:
|
||||
"""Return the directory where backups for a specific file are stored.
|
||||
@@ -39,6 +54,59 @@ def get_backup_dir(vault_name: str, relative_path: str) -> Path:
|
||||
return backup_root / vault_name / Path(relative_path).parent
|
||||
|
||||
|
||||
def create_backup(
|
||||
file_path: Path,
|
||||
vault_name: str,
|
||||
relative_path: str,
|
||||
*,
|
||||
max_backups: int | None = None,
|
||||
) -> Path | None:
|
||||
"""Create a timestamped backup of a file before it is modified.
|
||||
|
||||
Backups are stored as ``{backup_root}/{vault}/{relative_path}.{ts}.bak``.
|
||||
Missing or unreadable files are skipped silently (returns ``None``) so a
|
||||
backup failure never blocks the caller's mutation.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path of the file to back up.
|
||||
vault_name: Name of the vault the file belongs to.
|
||||
relative_path: Vault-relative path (used to mirror the tree).
|
||||
max_backups: Number of backups to keep per file. ``None`` reads
|
||||
``max_backups_per_file`` from the app config.
|
||||
|
||||
Returns:
|
||||
The created backup path, or ``None`` when skipped.
|
||||
"""
|
||||
try:
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
logger.debug(f"Backup skipped: file not found {file_path}")
|
||||
return None
|
||||
|
||||
backup_dir = get_backup_dir(vault_name, relative_path)
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
backup_path = backup_dir / f"{file_path.name}.{int(time.time())}.bak"
|
||||
shutil.copy2(file_path, backup_path)
|
||||
logger.info(f"Backup created: {relative_path} -> {backup_path}")
|
||||
|
||||
keep = max_backups if max_backups is not None else _default_max_backups()
|
||||
all_backups = sorted(
|
||||
[
|
||||
f
|
||||
for f in backup_dir.iterdir()
|
||||
if f.is_file() and f.name.startswith(file_path.name + ".") and f.name.endswith(".bak")
|
||||
],
|
||||
key=lambda f: f.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for old in all_backups[keep:]:
|
||||
old.unlink()
|
||||
logger.debug(f"Auto-cleanup: removed old backup {old.name}")
|
||||
return backup_path
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to backup {relative_path} (vault={vault_name}): {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def list_backup_files(vault_name: str, relative_path: str) -> list[dict[str, Any]]:
|
||||
"""List all backup files for a vault file, sorted newest first.
|
||||
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
"""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 shutil
|
||||
from collections.abc import Callable
|
||||
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")
|
||||
|
||||
# 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) -> None:
|
||||
"""Reject unsupported file extensions (400)."""
|
||||
from backend.indexer import SUPPORTED_EXTENSIONS
|
||||
|
||||
ext = file_path.suffix.lower()
|
||||
if ext not in SUPPORTED_EXTENSIONS 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) -> dict[str, Any]:
|
||||
"""Create a directory (and its parents) in a vault.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``read_only`` (403) or
|
||||
``already_exists`` (409).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
dir_path = resolve_safe_path(root, path)
|
||||
|
||||
if dir_path.exists():
|
||||
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)}
|
||||
|
||||
|
||||
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.search import advanced_search_vaults
|
||||
|
||||
if not find:
|
||||
raise ServiceError("Query is required", code="invalid_arguments", status=400)
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
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
|
||||
try:
|
||||
original = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
occurrences = list(pattern.finditer(original))
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user