833 lines
28 KiB
Python
833 lines
28 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 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, *, allow_images: bool = False, allow_docs: bool = False) -> None:
|
|
"""Reject unsupported file extensions (400)."""
|
|
from backend.indexer import SUPPORTED_EXTENSIONS
|
|
|
|
ext = file_path.suffix.lower()
|
|
allowed = SUPPORTED_EXTENSIONS
|
|
if allow_images:
|
|
from backend.attachment_indexer import IMAGE_EXTENSIONS
|
|
allowed = allowed | IMAGE_EXTENSIONS
|
|
if allow_docs:
|
|
# Office documents produced by the AI tool layer (#92).
|
|
allowed = allowed | {".xlsx", ".docx"}
|
|
|
|
if ext not in allowed and file_path.name.lower() not in ("dockerfile", "makefile"):
|
|
raise ServiceError(
|
|
f"Unsupported file extension: {ext}",
|
|
code="unsupported_extension",
|
|
status=400,
|
|
details={"extension": ext},
|
|
)
|
|
|
|
|
|
def _validate_new_name(name: str) -> str:
|
|
"""Validate a rename target (a plain name, not a path)."""
|
|
candidate = (name or "").strip()
|
|
if not candidate or candidate in (".", "..") or "/" in candidate or "\\" in candidate:
|
|
raise ServiceError(
|
|
f"Invalid name: {name!r}",
|
|
code="invalid_arguments",
|
|
status=400,
|
|
details={"new_name": name},
|
|
)
|
|
return candidate
|
|
|
|
|
|
# ── D1. Creation ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def create_file(
|
|
vault_name: str,
|
|
path: str,
|
|
content: str = "",
|
|
*,
|
|
overwrite: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Create a text file in a vault.
|
|
|
|
Args:
|
|
vault_name: Name of the vault.
|
|
path: Vault-relative path of the new file.
|
|
content: Initial content (an Excalidraw skeleton is injected for empty
|
|
``.excalidraw`` files).
|
|
overwrite: When True, replace an existing file (with a backup) instead
|
|
of raising ``already_exists``.
|
|
|
|
Returns:
|
|
``{"success", "vault", "path", "size"}``.
|
|
|
|
Raises:
|
|
ServiceError: ``not_found`` (404) unknown vault, ``read_only`` (403),
|
|
``unsupported_extension`` (400) or ``already_exists`` (409).
|
|
"""
|
|
root = get_vault_root(vault_name)
|
|
_ensure_writable(root)
|
|
file_path = resolve_safe_path(root, path)
|
|
_validate_extension(file_path)
|
|
|
|
if file_path.exists():
|
|
if not overwrite:
|
|
raise ServiceError(
|
|
f"File already exists: {path}",
|
|
code="already_exists",
|
|
status=409,
|
|
details={"vault": vault_name, "path": path},
|
|
)
|
|
create_backup(file_path, vault_name, _rel(root, file_path))
|
|
|
|
try:
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if file_path.suffix.lower() == ".excalidraw" and not content.strip():
|
|
content = _EXCALIDRAW_SKELETON
|
|
file_path.write_text(content, encoding="utf-8")
|
|
except PermissionError as e:
|
|
raise ServiceError("Permission denied: cannot create file", code="permission_denied", status=403) from e
|
|
|
|
rel_path = _rel(root, file_path)
|
|
logger.info(f"File created: {vault_name}/{rel_path}")
|
|
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)}
|
|
|
|
|
|
def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> dict[str, Any]:
|
|
"""Create a directory (and its parents) in a vault.
|
|
|
|
Args:
|
|
vault_name: Name of the vault.
|
|
path: Vault-relative path of the new directory.
|
|
exist_ok: When True, an existing directory is a success (idempotent)
|
|
instead of raising ``already_exists``. Used by the AI tool layer so
|
|
a "create folder then create file" plan does not fail when the
|
|
folder is already there (``create_file`` creates parents anyway).
|
|
|
|
Raises:
|
|
ServiceError: ``not_found`` (404), ``read_only`` (403) or
|
|
``already_exists`` (409) when *exist_ok* is False.
|
|
"""
|
|
root = get_vault_root(vault_name)
|
|
_ensure_writable(root)
|
|
dir_path = resolve_safe_path(root, path)
|
|
|
|
if dir_path.exists():
|
|
if exist_ok and dir_path.is_dir():
|
|
return {
|
|
"success": True,
|
|
"vault": vault_name,
|
|
"path": _rel(root, dir_path),
|
|
"existed": True,
|
|
}
|
|
raise ServiceError(
|
|
f"Directory already exists: {path}",
|
|
code="already_exists",
|
|
status=409,
|
|
details={"vault": vault_name, "path": path},
|
|
)
|
|
|
|
try:
|
|
dir_path.mkdir(parents=True, exist_ok=False)
|
|
except PermissionError as e:
|
|
raise ServiceError("Permission denied: cannot create directory", code="permission_denied", status=403) from e
|
|
|
|
rel_path = _rel(root, dir_path)
|
|
logger.info(f"Directory created: {vault_name}/{rel_path}")
|
|
return {"success": True, "vault": vault_name, "path": rel_path}
|
|
|
|
|
|
# ── D2. Edition / rename / move ────────────────────────────────────────────
|
|
|
|
|
|
def edit_file(
|
|
vault_name: str,
|
|
path: str,
|
|
content: str,
|
|
*,
|
|
backup: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""Overwrite an existing file's content (with a backup by default).
|
|
|
|
Raises:
|
|
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
|
"""
|
|
root = get_vault_root(vault_name)
|
|
_ensure_writable(root)
|
|
file_path = resolve_safe_path(root, path)
|
|
|
|
if not file_path.exists() or not file_path.is_file():
|
|
raise ServiceError(
|
|
f"File not found: {path}",
|
|
code="not_found",
|
|
status=404,
|
|
details={"vault": vault_name, "path": path},
|
|
)
|
|
|
|
rel_path = _rel(root, file_path)
|
|
if backup:
|
|
create_backup(file_path, vault_name, rel_path)
|
|
|
|
try:
|
|
file_path.write_text(content, encoding="utf-8")
|
|
except PermissionError as e:
|
|
raise ServiceError("Permission denied: cannot save file", code="permission_denied", status=403) from e
|
|
|
|
logger.info(f"File saved: {vault_name}/{rel_path}")
|
|
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)}
|
|
|
|
|
|
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
|
|
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),
|
|
}
|
|
|