Files

264 lines
9.4 KiB
Python

"""Backup inspection services shared by REST routes and the AI tool layer.
Single source of truth for locating, listing and diffing the timestamped
backups created before each file mutation. The write-side (creating a backup)
stays in the route layer; only the read/inspection logic lives here so both
``/api/file/{vault}/backups``, ``/api/file/{vault}/diff`` and the tools
``list_backups`` / ``diff_backup`` behave identically.
"""
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
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.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.routers.config import _load_config # ROADMAP #85 T7 — déménagé depuis backend.main
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.
Resolves relative backup paths against the SPECIFIC vault's directory,
matching the backup-creation logic exactly.
"""
from backend.indexer import get_vault_data
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
if not backup_root.is_absolute():
vault_data = get_vault_data(vault_name)
if vault_data:
backup_root = Path(vault_data["path"]) / backup_root
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.
Backup filename format: ``{original_filename}.{timestamp}.bak``.
Returns a list of dicts with ``timestamp``, ``datetime``, ``size`` and
``filename``. Missing or unreadable backup directories yield an empty list.
"""
backup_dir = get_backup_dir(vault_name, relative_path)
if not backup_dir.exists():
return []
original_name = Path(relative_path).name
prefix = original_name + "."
backups: list[dict[str, Any]] = []
try:
dir_entries = list(backup_dir.iterdir())
except PermissionError:
logger.warning(f"Permission denied reading backup dir: {backup_dir}")
return []
except OSError as e:
logger.error(f"Error reading backup dir {backup_dir}: {e}")
return []
for f in dir_entries:
try:
if not f.is_file():
continue
name = f.name
if not name.startswith(prefix) or not name.endswith(".bak"):
continue
ts_part = name[len(prefix):-len(".bak")]
try:
ts = int(ts_part)
except (ValueError, TypeError):
continue
try:
dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
except (OSError, OverflowError, ValueError) as ts_err:
logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}")
continue
st = f.stat()
backups.append({
"timestamp": ts,
"datetime": dt,
"size": st.st_size,
"filename": name,
})
except Exception as entry_err:
logger.warning(f"Skipping unreadable backup entry {f}: {entry_err}")
continue
backups.sort(key=lambda b: b["timestamp"], reverse=True)
return backups
def diff_backup(
vault_name: str,
path: str,
version: int,
compare_with: int | None = None,
) -> dict[str, Any]:
"""Generate a unified diff between a backup version and another version or the current file.
Args:
vault_name: Name of the vault.
path: Relative path of the file within the vault.
version: Timestamp of the backup used as the old/left side.
compare_with: Optional timestamp of another backup as the new/right
side. If omitted, the current file on disk is used.
Returns:
Dict with ``vault``, ``path``, ``version``, ``compare_with``, ``diff``,
``left_content`` and ``right_content``.
Raises:
ServiceError: ``not_found`` (404) when the file or a backup is missing,
``read_error`` (500) when a file cannot be read.
"""
root = get_vault_root(vault_name)
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},
)
original_name = Path(path).name
def _read_backup(ts: int) -> tuple[str, str]:
backup_dir = get_backup_dir(vault_name, path)
backup_path = backup_dir / f"{original_name}.{ts}.bak"
if not backup_path.exists():
raise ServiceError(
f"Backup version {ts} not found for {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path, "version": ts},
)
try:
content = backup_path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
raise ServiceError(
f"Failed to read backup {ts}: {e}",
code="read_error",
status=500,
details={"vault": vault_name, "path": path, "version": ts},
) from e
dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
return content, f"{path}@{dt}"
try:
left_content, left_label = _read_backup(version)
if compare_with is not None:
right_content, right_label = _read_backup(compare_with)
else:
try:
right_content = file_path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
raise ServiceError(
f"Failed to read current file: {e}",
code="read_error",
status=500,
details={"vault": vault_name, "path": path},
) from e
right_label = f"{path} (current)"
left_lines = left_content.splitlines(keepends=True)
right_lines = right_content.splitlines(keepends=True)
diff_lines = list(difflib.unified_diff(
left_lines, right_lines,
fromfile=left_label, tofile=right_label,
))
return {
"vault": vault_name,
"path": path,
"version": version,
"compare_with": compare_with,
"diff": "".join(diff_lines),
"left_content": left_content,
"right_content": right_content,
}
except ServiceError:
raise
except Exception as e:
logger.error(f"Error generating diff for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True)
raise ServiceError(f"Erreur lors de la génération du diff: {e!s}", code="read_error", status=500) from e