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:
@@ -14,6 +14,20 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
||||||
|
- **#79 Phase D — Catalogue d'outils mutations + confirmations two-step** — 11 nouveaux outils IA
|
||||||
|
destructifs ou d'écriture exposés par la couche partagée (`backend/tools/service.py`) :
|
||||||
|
`create_file`, `create_directory`, `edit_file`, `append_to_file`, `rename_file`,
|
||||||
|
`rename_directory`, `move_path`, `replace_in_files`, `delete_file`, `delete_directory`,
|
||||||
|
`restore_backup`. Nouveau service réutilisable `backend/services/mutations.py` (anti
|
||||||
|
path-traversal, garde lecture seule, **backup automatique** avant écriture/suppression/restauration) ;
|
||||||
|
les routes `/api/file/{vault}` (POST/PATCH/DELETE), `/api/file/{vault}/save`, `/api/directory/{vault}`
|
||||||
|
(POST/PATCH/DELETE), `/api/move/{vault}`, `/api/file/{vault}/restore` et `/api/search/replace`
|
||||||
|
délèguent désormais à cette couche. `backend/services/backups.py` expose `create_backup`
|
||||||
|
(source unique, réutilisée par `main._backup_file`). Confirmations **two-step** : tout outil
|
||||||
|
`WRITE`/`DANGEROUS` déclenche `ToolConfirmationRequired` (carte Apply in-app, `propose`/`apply`
|
||||||
|
côté MCP en phase E). **Toggle par vault** `aiDestructiveTools` (défaut : activés) pour désactiver
|
||||||
|
delete/rename/move/replace. Tests : `tests/test_tools_mutations.py` (40 tests).
|
||||||
|
|
||||||
- **#79 Phase C — Catalogue d'outils lecture & recherche** — 10 nouveaux outils IA exposés par la
|
- **#79 Phase C — Catalogue d'outils lecture & recherche** — 10 nouveaux outils IA exposés par la
|
||||||
couche partagée (`backend/tools/service.py`) : `list_all_files`, `read_file_raw`, `get_backlinks`,
|
couche partagée (`backend/tools/service.py`) : `list_all_files`, `read_file_raw`, `get_backlinks`,
|
||||||
`list_backups`, `diff_backup`, `get_graph`, `search_advanced`, `search_paths`, `suggest_tags`,
|
`list_backups`, `diff_backup`, `get_graph`, `search_advanced`, `search_paths`, `suggest_tags`,
|
||||||
|
|||||||
+202
-552
@@ -36,7 +36,6 @@ from backend.history import (
|
|||||||
)
|
)
|
||||||
from backend.image_processor import preprocess_images
|
from backend.image_processor import preprocess_images
|
||||||
from backend.indexer import (
|
from backend.indexer import (
|
||||||
SUPPORTED_EXTENSIONS,
|
|
||||||
_extract_tags,
|
_extract_tags,
|
||||||
add_vault_to_index,
|
add_vault_to_index,
|
||||||
build_index,
|
build_index,
|
||||||
@@ -94,7 +93,6 @@ from backend.schemas import (
|
|||||||
WebhookModel,
|
WebhookModel,
|
||||||
)
|
)
|
||||||
from backend.search import (
|
from backend.search import (
|
||||||
advanced_search,
|
|
||||||
init_inverted_index,
|
init_inverted_index,
|
||||||
suggest_tags,
|
suggest_tags,
|
||||||
suggest_titles,
|
suggest_titles,
|
||||||
@@ -105,6 +103,36 @@ from backend.services.backups import list_backup_files as service_list_backup_fi
|
|||||||
from backend.services.errors import ServiceError
|
from backend.services.errors import ServiceError
|
||||||
from backend.services.files import read_raw_file
|
from backend.services.files import read_raw_file
|
||||||
from backend.services.graph import get_graph as service_get_graph
|
from backend.services.graph import get_graph as service_get_graph
|
||||||
|
from backend.services.mutations import (
|
||||||
|
create_directory as service_create_directory,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
create_file as service_create_file,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
delete_directory as service_delete_directory,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
delete_file as service_delete_file,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
edit_file as service_edit_file,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
move_path as service_move_path,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
rename_directory as service_rename_directory,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
rename_file as service_rename_file,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
replace_in_files as service_replace_in_files,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
restore_backup as service_restore_backup,
|
||||||
|
)
|
||||||
from backend.services.recent import humanize_mtime, list_recent
|
from backend.services.recent import humanize_mtime, list_recent
|
||||||
from backend.services.search import advanced_search_vaults, search_paths, search_vaults
|
from backend.services.search import advanced_search_vaults, search_paths, search_vaults
|
||||||
from backend.services.search import list_tags as service_list_tags
|
from backend.services.search import list_tags as service_list_tags
|
||||||
@@ -912,39 +940,14 @@ def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
|||||||
def _backup_file(file_path: Path, vault_name: str, relative_path: str):
|
def _backup_file(file_path: Path, vault_name: str, relative_path: str):
|
||||||
"""Create a timestamped backup of a file before modification.
|
"""Create a timestamped backup of a file before modification.
|
||||||
|
|
||||||
Backups are stored in {backup_root}/{vault}/{relative_path}.{timestamp}.bak
|
Thin wrapper around :func:`backend.services.backups.create_backup`
|
||||||
Silently skips if the file doesn't exist or can't be read.
|
(single implementation used by both routes and tools). Backups are stored
|
||||||
|
in ``{backup_root}/{vault}/{relative_path}.{timestamp}.bak``; the operation
|
||||||
|
is best-effort and never blocks the caller.
|
||||||
"""
|
"""
|
||||||
try:
|
from backend.services.backups import create_backup
|
||||||
if not file_path.exists() or not file_path.is_file():
|
|
||||||
logger.debug(f"Backup skipped: file not found {file_path}")
|
|
||||||
return
|
|
||||||
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
|
|
||||||
# Resolve relative to vault root (inside vault, so it's always writable)
|
|
||||||
if not backup_root.is_absolute():
|
|
||||||
vault_data = get_vault_data(vault_name)
|
|
||||||
if vault_data:
|
|
||||||
backup_root = Path(vault_data["path"]) / backup_root
|
|
||||||
backup_dir = backup_root / vault_name / Path(relative_path).parent
|
|
||||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
timestamp = int(time.time())
|
|
||||||
backup_name = f"{file_path.name}.{timestamp}.bak"
|
|
||||||
backup_path = backup_dir / backup_name
|
|
||||||
shutil.copy2(file_path, backup_path)
|
|
||||||
logger.info(f"Backup created: {relative_path} -> {backup_path}")
|
|
||||||
|
|
||||||
# Auto-cleanup: keep only the N most recent backups
|
create_backup(file_path, vault_name, relative_path)
|
||||||
config = _load_config()
|
|
||||||
max_backups = config.get("max_backups_per_file", 10)
|
|
||||||
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[max_backups:]:
|
|
||||||
old.unlink()
|
|
||||||
logger.debug(f"Auto-cleanup: removed old backup {old.name}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to backup {relative_path} (vault={vault_name}): {e}", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_vault_writable(vault_root: Path) -> bool:
|
def _check_vault_writable(vault_root: Path) -> bool:
|
||||||
@@ -1642,36 +1645,15 @@ async def api_file_save(
|
|||||||
"""
|
"""
|
||||||
if not check_vault_access(vault_name, current_user):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
vault_data = get_vault_data(vault_name)
|
|
||||||
if not vault_data:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
content = body.get("content", "")
|
||||||
file_path = _resolve_safe_path(vault_root, path)
|
result = service_edit_file(vault_name, path, content, backup=backup)
|
||||||
|
|
||||||
if not file_path.exists():
|
# Audit log
|
||||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
client_ip = current_user.get("_request_ip", "unknown")
|
||||||
|
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
||||||
|
|
||||||
content = body.get('content', '')
|
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||||
|
|
||||||
try:
|
|
||||||
# Backup original content before overwriting (skip on auto-save)
|
|
||||||
if backup:
|
|
||||||
_backup_file(file_path, vault_name, path)
|
|
||||||
|
|
||||||
file_path.write_text(content, encoding="utf-8")
|
|
||||||
logger.info(f"File saved: {vault_name}/{path}")
|
|
||||||
|
|
||||||
# Audit log
|
|
||||||
client_ip = current_user.get("_request_ip", "unknown")
|
|
||||||
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
|
||||||
|
|
||||||
return {"status": "ok", "vault": vault_name, "path": path, "size": len(content)}
|
|
||||||
except PermissionError:
|
|
||||||
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error saving file {vault_name}/{path}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"Error saving file: {e!s}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||||
@@ -1689,55 +1671,32 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
|
|||||||
"""
|
"""
|
||||||
if not check_vault_access(vault_name, current_user):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
vault_data = get_vault_data(vault_name)
|
|
||||||
if not vault_data:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
result = service_delete_file(vault_name, path)
|
||||||
file_path = _resolve_safe_path(vault_root, path)
|
|
||||||
|
|
||||||
if not file_path.exists() or not file_path.is_file():
|
# Audit log
|
||||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
client_ip = current_user.get("_request_ip", "unknown")
|
||||||
|
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
||||||
|
|
||||||
# Check if vault is writable
|
# Update index
|
||||||
if not _check_vault_writable(vault_root):
|
await remove_single_file(vault_name, path)
|
||||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
|
||||||
|
|
||||||
try:
|
# Broadcast SSE event
|
||||||
# Backup original content before deletion
|
await sse_manager.broadcast("file_deleted", {
|
||||||
_backup_file(file_path, vault_name, path)
|
"vault": vault_name,
|
||||||
|
"path": path,
|
||||||
|
})
|
||||||
|
|
||||||
file_path.unlink()
|
from backend.plugins import emit_file_deleted
|
||||||
logger.info(f"File deleted: {vault_name}/{path}")
|
emit_file_deleted(vault_name, path)
|
||||||
|
|
||||||
# Audit log
|
# Remove from recent files
|
||||||
client_ip = current_user.get("_request_ip", "unknown")
|
remove_recent(current_user["username"], vault_name, path)
|
||||||
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
|
||||||
|
|
||||||
# Update index
|
# Dispatch webhooks
|
||||||
await remove_single_file(vault_name, path)
|
await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path})
|
||||||
|
|
||||||
# Broadcast SSE event
|
return {"status": "ok", "vault": result["vault"], "path": result["path"]}
|
||||||
await sse_manager.broadcast("file_deleted", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"path": path,
|
|
||||||
})
|
|
||||||
|
|
||||||
from backend.plugins import emit_file_deleted
|
|
||||||
emit_file_deleted(vault_name, path)
|
|
||||||
|
|
||||||
# Remove from recent files
|
|
||||||
remove_recent(current_user["username"], vault_name, path)
|
|
||||||
|
|
||||||
# Dispatch webhooks
|
|
||||||
await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path})
|
|
||||||
|
|
||||||
return {"status": "ok", "vault": vault_name, "path": path}
|
|
||||||
except PermissionError:
|
|
||||||
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting file {vault_name}/{path}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"Error deleting file: {e!s}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1762,60 +1721,35 @@ async def api_directory_create(
|
|||||||
if not check_vault_access(vault_name, current_user):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
vault_data = get_vault_data(vault_name)
|
result = service_create_directory(vault_name, body.path)
|
||||||
if not vault_data:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
# Update path_index with the new directory
|
||||||
|
from backend.indexer import _index_lock
|
||||||
|
from backend.indexer import path_index as _path_idx
|
||||||
|
with _index_lock:
|
||||||
|
if vault_name not in _path_idx:
|
||||||
|
_path_idx[vault_name] = []
|
||||||
|
existing = {p["path"] for p in _path_idx[vault_name]}
|
||||||
|
# Build all parent segments
|
||||||
|
parts = body.path.split("/")
|
||||||
|
for i in range(1, len(parts) + 1):
|
||||||
|
seg_path = "/".join(parts[:i])
|
||||||
|
if seg_path and seg_path not in existing:
|
||||||
|
existing.add(seg_path)
|
||||||
|
_path_idx[vault_name].append({
|
||||||
|
"path": seg_path,
|
||||||
|
"name": parts[i - 1],
|
||||||
|
"type": "directory",
|
||||||
|
})
|
||||||
|
|
||||||
# Check if vault is writable
|
# Broadcast SSE event
|
||||||
if not _check_vault_writable(vault_root):
|
await sse_manager.broadcast("directory_created", {
|
||||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
"vault": vault_name,
|
||||||
|
"path": result["path"],
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": result["path"]})
|
||||||
|
|
||||||
# Resolve and validate path
|
return {"success": True, "path": result["path"]}
|
||||||
dir_path = _resolve_safe_path(vault_root, body.path)
|
|
||||||
|
|
||||||
# Check if directory already exists
|
|
||||||
if dir_path.exists():
|
|
||||||
raise HTTPException(status_code=409, detail=f"Directory already exists: {body.path}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create directory with parents
|
|
||||||
dir_path.mkdir(parents=True, exist_ok=False)
|
|
||||||
logger.info(f"Directory created: {vault_name}/{body.path}")
|
|
||||||
|
|
||||||
# Update path_index with the new directory
|
|
||||||
from backend.indexer import _index_lock
|
|
||||||
from backend.indexer import path_index as _path_idx
|
|
||||||
with _index_lock:
|
|
||||||
if vault_name not in _path_idx:
|
|
||||||
_path_idx[vault_name] = []
|
|
||||||
existing = {p["path"] for p in _path_idx[vault_name]}
|
|
||||||
# Build all parent segments
|
|
||||||
parts = body.path.split("/")
|
|
||||||
for i in range(1, len(parts) + 1):
|
|
||||||
seg_path = "/".join(parts[:i])
|
|
||||||
if seg_path and seg_path not in existing:
|
|
||||||
existing.add(seg_path)
|
|
||||||
_path_idx[vault_name].append({
|
|
||||||
"path": seg_path,
|
|
||||||
"name": parts[i - 1],
|
|
||||||
"type": "directory",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("directory_created", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"path": body.path,
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": body.path})
|
|
||||||
|
|
||||||
return {"success": True, "path": body.path}
|
|
||||||
except PermissionError:
|
|
||||||
raise HTTPException(status_code=403, detail="Permission denied: cannot create directory")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error creating directory {vault_name}/{body.path}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"Error creating directory: {e!s}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
|
@app.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
|
||||||
@@ -1836,59 +1770,23 @@ async def api_directory_rename(
|
|||||||
if not check_vault_access(vault_name, current_user):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
vault_data = get_vault_data(vault_name)
|
result = service_rename_directory(vault_name, body.path, body.new_name)
|
||||||
if not vault_data:
|
old_path_str = result["old_path"]
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
new_path_str = result["new_path"]
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
# Update index for all files in the directory
|
||||||
|
from backend.indexer import reload_single_vault
|
||||||
|
await reload_single_vault(vault_name)
|
||||||
|
|
||||||
# Check if vault is writable
|
# Broadcast SSE event
|
||||||
if not _check_vault_writable(vault_root):
|
await sse_manager.broadcast("directory_renamed", {
|
||||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
"vault": vault_name,
|
||||||
|
"old_path": old_path_str,
|
||||||
|
"new_path": new_path_str,
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
||||||
|
|
||||||
# Resolve old path
|
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||||
old_path = _resolve_safe_path(vault_root, body.path)
|
|
||||||
|
|
||||||
if not old_path.exists() or not old_path.is_dir():
|
|
||||||
raise HTTPException(status_code=404, detail=f"Directory not found: {body.path}")
|
|
||||||
|
|
||||||
# Construct new path (same parent, new name)
|
|
||||||
new_path = old_path.parent / body.new_name
|
|
||||||
|
|
||||||
# Validate new path is still within vault
|
|
||||||
new_path = _resolve_safe_path(vault_root, str(new_path.relative_to(vault_root)))
|
|
||||||
|
|
||||||
# Check if destination already exists
|
|
||||||
if new_path.exists():
|
|
||||||
raise HTTPException(status_code=409, detail=f"Destination already exists: {body.new_name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Rename directory
|
|
||||||
old_path.rename(new_path)
|
|
||||||
|
|
||||||
old_path_str = str(old_path.relative_to(vault_root)).replace("\\", "/")
|
|
||||||
new_path_str = str(new_path.relative_to(vault_root)).replace("\\", "/")
|
|
||||||
|
|
||||||
logger.info(f"Directory renamed: {vault_name}/{old_path_str} -> {new_path_str}")
|
|
||||||
|
|
||||||
# Update index for all files in the directory
|
|
||||||
from backend.indexer import reload_single_vault
|
|
||||||
await reload_single_vault(vault_name)
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("directory_renamed", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"old_path": old_path_str,
|
|
||||||
"new_path": new_path_str,
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
|
||||||
|
|
||||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
|
||||||
except PermissionError:
|
|
||||||
raise HTTPException(status_code=403, detail="Permission denied: cannot rename directory")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error renaming directory {vault_name}/{body.path}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"Error renaming directory: {e!s}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
|
@app.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
|
||||||
@@ -1909,48 +1807,22 @@ async def api_directory_delete(
|
|||||||
if not check_vault_access(vault_name, current_user):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
vault_data = get_vault_data(vault_name)
|
result = service_delete_directory(vault_name, path, recursive=True)
|
||||||
if not vault_data:
|
file_count = result["deleted_count"]
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
# Update index
|
||||||
|
from backend.indexer import reload_single_vault
|
||||||
|
await reload_single_vault(vault_name)
|
||||||
|
|
||||||
# Check if vault is writable
|
# Broadcast SSE event
|
||||||
if not _check_vault_writable(vault_root):
|
await sse_manager.broadcast("directory_deleted", {
|
||||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
"vault": vault_name,
|
||||||
|
"path": result["path"],
|
||||||
|
"deleted_count": file_count,
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": result["path"]})
|
||||||
|
|
||||||
# Resolve and validate path
|
return {"success": True, "deleted_count": file_count}
|
||||||
dir_path = _resolve_safe_path(vault_root, path)
|
|
||||||
|
|
||||||
if not dir_path.exists() or not dir_path.is_dir():
|
|
||||||
raise HTTPException(status_code=404, detail=f"Directory not found: {path}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Count files before deletion
|
|
||||||
file_count = sum(1 for _ in dir_path.rglob('*') if _.is_file())
|
|
||||||
|
|
||||||
# Delete directory recursively
|
|
||||||
shutil.rmtree(dir_path)
|
|
||||||
logger.info(f"Directory deleted: {vault_name}/{path} ({file_count} files)")
|
|
||||||
|
|
||||||
# Update index
|
|
||||||
from backend.indexer import reload_single_vault
|
|
||||||
await reload_single_vault(vault_name)
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("directory_deleted", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"path": path,
|
|
||||||
"deleted_count": file_count,
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": path})
|
|
||||||
|
|
||||||
return {"success": True, "deleted_count": file_count}
|
|
||||||
except PermissionError:
|
|
||||||
raise HTTPException(status_code=403, detail="Permission denied: cannot delete directory")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting directory {vault_name}/{path}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"Error deleting directory: {e!s}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1975,59 +1847,21 @@ async def api_file_create(
|
|||||||
if not check_vault_access(vault_name, current_user):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
vault_data = get_vault_data(vault_name)
|
result = service_create_file(vault_name, body.path, body.content)
|
||||||
if not vault_data:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
# Update index
|
||||||
|
await update_single_file(vault_name, result["path"])
|
||||||
|
|
||||||
# Check if vault is writable
|
# Broadcast SSE event
|
||||||
if not _check_vault_writable(vault_root):
|
await sse_manager.broadcast("file_created", {
|
||||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
"vault": vault_name,
|
||||||
|
"path": result["path"],
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("file_created", {"vault": vault_name, "path": result["path"]})
|
||||||
|
from backend.plugins import emit_file_created
|
||||||
|
emit_file_created(vault_name, result["path"])
|
||||||
|
|
||||||
# Resolve and validate path
|
return {"success": True, "path": result["path"]}
|
||||||
file_path = _resolve_safe_path(vault_root, body.path)
|
|
||||||
|
|
||||||
# Validate file extension
|
|
||||||
ext = file_path.suffix.lower()
|
|
||||||
if ext not in SUPPORTED_EXTENSIONS and file_path.name.lower() not in ("dockerfile", "makefile"):
|
|
||||||
raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}")
|
|
||||||
|
|
||||||
# Check if file already exists
|
|
||||||
if file_path.exists():
|
|
||||||
raise HTTPException(status_code=409, detail=f"File already exists: {body.path}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create parent directories if needed
|
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# For .excalidraw files, inject skeleton JSON if content is empty
|
|
||||||
content = body.content
|
|
||||||
if ext == ".excalidraw" and not content.strip():
|
|
||||||
content = '{"type":"excalidraw","version":2,"elements":[],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}'
|
|
||||||
|
|
||||||
# Create file with initial content
|
|
||||||
file_path.write_text(content, encoding="utf-8")
|
|
||||||
logger.info(f"File created: {vault_name}/{body.path}")
|
|
||||||
|
|
||||||
# Update index
|
|
||||||
await update_single_file(vault_name, body.path)
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("file_created", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"path": body.path,
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("file_created", {"vault": vault_name, "path": body.path})
|
|
||||||
from backend.plugins import emit_file_created
|
|
||||||
emit_file_created(vault_name, body.path)
|
|
||||||
|
|
||||||
return {"success": True, "path": body.path}
|
|
||||||
except PermissionError:
|
|
||||||
raise HTTPException(status_code=403, detail="Permission denied: cannot create file")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error creating file {vault_name}/{body.path}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"Error creating file: {e!s}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
|
@app.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
|
||||||
@@ -2048,68 +1882,27 @@ async def api_file_rename(
|
|||||||
if not check_vault_access(vault_name, current_user):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
vault_data = get_vault_data(vault_name)
|
result = service_rename_file(vault_name, body.path, body.new_name)
|
||||||
if not vault_data:
|
old_path_str = result["old_path"]
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
new_path_str = result["new_path"]
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
# Update index
|
||||||
|
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||||
|
|
||||||
# Check if vault is writable
|
# Update bookmarks, history, and shares
|
||||||
if not _check_vault_writable(vault_root):
|
update_bookmarks_after_rename(vault_name, old_path_str, new_path_str)
|
||||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
update_history_after_rename(vault_name, old_path_str, new_path_str)
|
||||||
|
update_shares_after_rename(vault_name, old_path_str, new_path_str)
|
||||||
|
|
||||||
# Resolve old path
|
# Broadcast SSE event
|
||||||
old_path = _resolve_safe_path(vault_root, body.path)
|
await sse_manager.broadcast("file_renamed", {
|
||||||
|
"vault": vault_name,
|
||||||
|
"old_path": old_path_str,
|
||||||
|
"new_path": new_path_str,
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
||||||
|
|
||||||
if not old_path.exists() or not old_path.is_file():
|
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||||
raise HTTPException(status_code=404, detail=f"File not found: {body.path}")
|
|
||||||
|
|
||||||
# Construct new path (same parent, new name)
|
|
||||||
new_path = old_path.parent / body.new_name
|
|
||||||
|
|
||||||
# Validate new path is still within vault
|
|
||||||
new_path = _resolve_safe_path(vault_root, str(new_path.relative_to(vault_root)))
|
|
||||||
|
|
||||||
# Validate file extension
|
|
||||||
ext = new_path.suffix.lower()
|
|
||||||
if ext not in SUPPORTED_EXTENSIONS and new_path.name.lower() not in ("dockerfile", "makefile"):
|
|
||||||
raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}")
|
|
||||||
|
|
||||||
# Check if destination already exists
|
|
||||||
if new_path.exists():
|
|
||||||
raise HTTPException(status_code=409, detail=f"Destination already exists: {body.new_name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Rename file
|
|
||||||
old_path.rename(new_path)
|
|
||||||
|
|
||||||
old_path_str = str(old_path.relative_to(vault_root)).replace("\\", "/")
|
|
||||||
new_path_str = str(new_path.relative_to(vault_root)).replace("\\", "/")
|
|
||||||
|
|
||||||
logger.info(f"File renamed: {vault_name}/{old_path_str} -> {new_path_str}")
|
|
||||||
|
|
||||||
# Update index
|
|
||||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
|
||||||
|
|
||||||
# Update bookmarks, history, and shares
|
|
||||||
update_bookmarks_after_rename(vault_name, old_path_str, new_path_str)
|
|
||||||
update_history_after_rename(vault_name, old_path_str, new_path_str)
|
|
||||||
update_shares_after_rename(vault_name, old_path_str, new_path_str)
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("file_renamed", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"old_path": old_path_str,
|
|
||||||
"new_path": new_path_str,
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
|
|
||||||
|
|
||||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
|
||||||
except PermissionError:
|
|
||||||
raise HTTPException(status_code=403, detail="Permission denied: cannot rename file")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error renaming file {vault_name}/{body.path}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"Error renaming file: {e!s}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/move/{vault_name}", response_model=FileMoveResponse)
|
@app.post("/api/move/{vault_name}", response_model=FileMoveResponse)
|
||||||
@@ -2133,95 +1926,28 @@ async def api_file_move(
|
|||||||
if not check_vault_access(vault_name, current_user):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
vault_data = get_vault_data(vault_name)
|
result = service_move_path(vault_name, body.source_path, body.destination_dir)
|
||||||
if not vault_data:
|
old_path_str = result["old_path"]
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
new_path_str = result["new_path"]
|
||||||
|
item_type = result["item_type"]
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
# Update index
|
||||||
|
if item_type == "directory":
|
||||||
# Check if vault is writable
|
from backend.indexer import reload_single_vault
|
||||||
if not _check_vault_writable(vault_root):
|
await reload_single_vault(vault_name)
|
||||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
|
||||||
|
|
||||||
# Resolve source path
|
|
||||||
source = _resolve_safe_path(vault_root, body.source_path)
|
|
||||||
|
|
||||||
if not source.exists():
|
|
||||||
raise HTTPException(status_code=404, detail=f"Source not found: {body.source_path}")
|
|
||||||
|
|
||||||
is_directory = source.is_dir()
|
|
||||||
|
|
||||||
# Determine the item name (last segment)
|
|
||||||
item_name = source.name
|
|
||||||
|
|
||||||
# Construct destination path: destination_dir / item_name
|
|
||||||
dest_dir_clean = body.destination_dir.strip("/")
|
|
||||||
if dest_dir_clean:
|
|
||||||
dest_parent = _resolve_safe_path(vault_root, dest_dir_clean)
|
|
||||||
if not dest_parent.exists() or not dest_parent.is_dir():
|
|
||||||
raise HTTPException(status_code=404, detail=f"Destination directory not found: {body.destination_dir}")
|
|
||||||
else:
|
else:
|
||||||
dest_parent = vault_root
|
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||||
|
|
||||||
destination = dest_parent / item_name
|
# Broadcast SSE event
|
||||||
|
await sse_manager.broadcast("item_moved", {
|
||||||
|
"vault": vault_name,
|
||||||
|
"old_path": old_path_str,
|
||||||
|
"new_path": new_path_str,
|
||||||
|
"item_type": item_type,
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
|
||||||
|
|
||||||
# Validate destination is within vault
|
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
|
||||||
destination = _resolve_safe_path(vault_root, str(destination.relative_to(vault_root)))
|
|
||||||
|
|
||||||
# Don't move to the same location
|
|
||||||
if source.resolve() == destination.resolve():
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"old_path": body.source_path,
|
|
||||||
"new_path": body.source_path,
|
|
||||||
"item_type": "directory" if is_directory else "file",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if destination already exists
|
|
||||||
if destination.exists():
|
|
||||||
raise HTTPException(status_code=409, detail=f"A file or directory already exists at the destination: {destination.name}")
|
|
||||||
|
|
||||||
# For files, validate extension
|
|
||||||
if not is_directory:
|
|
||||||
ext = destination.suffix.lower()
|
|
||||||
if ext not in SUPPORTED_EXTENSIONS and destination.name.lower() not in ("dockerfile", "makefile"):
|
|
||||||
raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Move the file/directory
|
|
||||||
source.rename(destination)
|
|
||||||
|
|
||||||
old_path_str = str(source.relative_to(vault_root)).replace("\\", "/")
|
|
||||||
# After rename, source is now at destination — reconstruct the new relative path
|
|
||||||
new_path_str = body.destination_dir.strip("/")
|
|
||||||
new_path_str = (new_path_str + "/" if new_path_str else "") + item_name
|
|
||||||
|
|
||||||
item_type = "directory" if is_directory else "file"
|
|
||||||
|
|
||||||
logger.info(f"Item moved: {vault_name}/{old_path_str} -> {new_path_str}")
|
|
||||||
|
|
||||||
# Update index
|
|
||||||
if is_directory:
|
|
||||||
from backend.indexer import reload_single_vault
|
|
||||||
await reload_single_vault(vault_name)
|
|
||||||
else:
|
|
||||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("item_moved", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"old_path": old_path_str,
|
|
||||||
"new_path": new_path_str,
|
|
||||||
"item_type": item_type,
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
|
|
||||||
|
|
||||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
|
|
||||||
except PermissionError:
|
|
||||||
raise HTTPException(status_code=403, detail="Permission denied: cannot move item")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error moving item {vault_name}/{body.source_path}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"Error moving item: {e!s}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -2331,64 +2057,28 @@ async def api_file_restore(
|
|||||||
if not check_vault_access(vault_name, current_user):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
vault_data = get_vault_data(vault_name)
|
result = service_restore_backup(vault_name, path, body.version)
|
||||||
if not vault_data:
|
current_backed_up = result["current_backed_up"]
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
# Update index
|
||||||
|
await update_single_file(vault_name, path)
|
||||||
|
|
||||||
# Check if vault is writable
|
# Broadcast SSE event
|
||||||
if not _check_vault_writable(vault_root):
|
await sse_manager.broadcast("file_restored", {
|
||||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
"vault": vault_name,
|
||||||
|
"path": path,
|
||||||
|
"restored_from": body.version,
|
||||||
|
"current_backed_up": current_backed_up,
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version})
|
||||||
|
|
||||||
file_path = _resolve_safe_path(vault_root, path)
|
return {
|
||||||
original_name = Path(path).name
|
"success": True,
|
||||||
|
"vault": vault_name,
|
||||||
backup_dir = _get_backup_dir(vault_name, path)
|
"path": path,
|
||||||
backup_path = backup_dir / f"{original_name}.{body.version}.bak"
|
"restored_from": body.version,
|
||||||
|
"current_backed_up": current_backed_up,
|
||||||
if not backup_path.exists():
|
}
|
||||||
raise HTTPException(status_code=404, detail=f"Backup version {body.version} not found for {path}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Backup the current file before restoring
|
|
||||||
current_backed_up = None
|
|
||||||
if file_path.exists() and file_path.is_file():
|
|
||||||
_backup_file(file_path, vault_name, path)
|
|
||||||
current_backed_up = int(time.time())
|
|
||||||
|
|
||||||
# Read backup content
|
|
||||||
backup_content = backup_path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
# Write restored content
|
|
||||||
file_path.write_text(backup_content, encoding="utf-8")
|
|
||||||
|
|
||||||
logger.info(f"File restored from backup: {vault_name}/{path} <- version {body.version}")
|
|
||||||
|
|
||||||
# Update index
|
|
||||||
await update_single_file(vault_name, path)
|
|
||||||
|
|
||||||
# Broadcast SSE event
|
|
||||||
await sse_manager.broadcast("file_restored", {
|
|
||||||
"vault": vault_name,
|
|
||||||
"path": path,
|
|
||||||
"restored_from": body.version,
|
|
||||||
"current_backed_up": current_backed_up,
|
|
||||||
})
|
|
||||||
await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"vault": vault_name,
|
|
||||||
"path": path,
|
|
||||||
"restored_from": body.version,
|
|
||||||
"current_backed_up": current_backed_up,
|
|
||||||
}
|
|
||||||
except PermissionError:
|
|
||||||
raise HTTPException(status_code=403, detail="Permission denied: cannot restore file")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error restoring file {vault_name}/{path}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"Error restoring file: {e!s}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse)
|
@app.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse)
|
||||||
@@ -2924,7 +2614,6 @@ async def api_search_replace(
|
|||||||
current_user=Depends(require_auth),
|
current_user=Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""Find and replace across vault files."""
|
"""Find and replace across vault files."""
|
||||||
import re as re_mod
|
|
||||||
query = body.get("query", "")
|
query = body.get("query", "")
|
||||||
replacement = body.get("replacement", "")
|
replacement = body.get("replacement", "")
|
||||||
vault_filter = body.get("vault", "all")
|
vault_filter = body.get("vault", "all")
|
||||||
@@ -2939,71 +2628,32 @@ async def api_search_replace(
|
|||||||
if not query:
|
if not query:
|
||||||
raise HTTPException(400, "Query is required")
|
raise HTTPException(400, "Query is required")
|
||||||
|
|
||||||
search_results = advanced_search(
|
result = service_replace_in_files(
|
||||||
query, vault_filter=vault_filter,
|
query,
|
||||||
case_sensitive=case_sensitive, whole_word=whole_word,
|
replacement,
|
||||||
regex=regex_mode, include_paths=include_paths, exclude_paths=exclude_paths,
|
vault=vault_filter,
|
||||||
limit=500, sort_by="relevance",
|
case_sensitive=case_sensitive,
|
||||||
|
whole_word=whole_word,
|
||||||
|
regex=regex_mode,
|
||||||
|
include_paths=include_paths,
|
||||||
|
exclude_paths=exclude_paths,
|
||||||
|
replace_all=replace_all,
|
||||||
|
dry_run=dry_run,
|
||||||
|
is_vault_allowed=lambda v: check_vault_access(v, current_user),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not search_results["results"]:
|
|
||||||
return {"matches": [], "total_matches": 0}
|
|
||||||
|
|
||||||
flags = 0 if case_sensitive else re_mod.IGNORECASE
|
|
||||||
if regex_mode:
|
|
||||||
pattern = re_mod.compile(query, flags)
|
|
||||||
elif whole_word:
|
|
||||||
pattern = re_mod.compile(rf"\b{re_mod.escape(query)}\b", flags)
|
|
||||||
else:
|
|
||||||
pattern = re_mod.compile(re_mod.escape(query), flags)
|
|
||||||
|
|
||||||
matches = []
|
|
||||||
total_replacements = 0
|
|
||||||
|
|
||||||
for result in search_results["results"]:
|
|
||||||
if not check_vault_access(result["vault"], current_user):
|
|
||||||
continue
|
|
||||||
vault_data = get_vault_data(result["vault"])
|
|
||||||
if not vault_data:
|
|
||||||
continue
|
|
||||||
file_path = _resolve_safe_path(Path(vault_data["path"]), result["path"])
|
|
||||||
if not file_path.exists():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
original = file_path.read_text(encoding="utf-8", errors="replace")
|
|
||||||
except Exception: # nosec B112 — fichier illisible, on passe au suivant
|
|
||||||
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["title"], "match_count": len(occurrences),
|
|
||||||
"preview": previews,
|
|
||||||
})
|
|
||||||
total_replacements += len(occurrences)
|
|
||||||
else:
|
|
||||||
new_content, count = pattern.subn(replacement, original)
|
|
||||||
if count > 0:
|
|
||||||
_backup_file(file_path, result["vault"], result["path"])
|
|
||||||
file_path.write_text(new_content, encoding="utf-8")
|
|
||||||
log_file_save(current_user["username"], result["vault"], result["path"], len(new_content))
|
|
||||||
matches.append({
|
|
||||||
"vault": result["vault"], "path": result["path"],
|
|
||||||
"title": result["title"], "replacements": count,
|
|
||||||
})
|
|
||||||
total_replacements += count
|
|
||||||
await update_single_file(result["vault"], str(file_path))
|
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
return {"matches": matches, "total_matches": total_replacements, "dry_run": True}
|
return result
|
||||||
return {"replaced": matches, "total_replacements": total_replacements}
|
|
||||||
|
# Side effects for applied replacements (audit + incremental index).
|
||||||
|
for match in result.get("replaced", []):
|
||||||
|
log_file_save(current_user["username"], match["vault"], match["path"], match.get("size", 0))
|
||||||
|
vault_data = get_vault_data(match["vault"])
|
||||||
|
if vault_data:
|
||||||
|
abs_path = str(Path(vault_data["path"]) / match["path"])
|
||||||
|
await update_single_file(match["vault"], abs_path)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/suggest", response_model=SuggestResponse)
|
@app.get("/api/suggest", response_model=SuggestResponse)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from __future__ import annotations
|
|||||||
import difflib
|
import difflib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -22,6 +24,19 @@ from backend.services.vaults import get_vault_root
|
|||||||
|
|
||||||
logger = logging.getLogger("obsigate.services.backups")
|
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:
|
def get_backup_dir(vault_name: str, relative_path: str) -> Path:
|
||||||
"""Return the directory where backups for a specific file are stored.
|
"""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
|
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]]:
|
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.
|
"""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,
|
||||||
|
}
|
||||||
@@ -139,6 +139,27 @@ class ToolContext:
|
|||||||
details={"vault": vault},
|
details={"vault": vault},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def destructive_tools_enabled(self, vault: str) -> bool:
|
||||||
|
"""Return whether destructive tools are allowed for *vault*.
|
||||||
|
|
||||||
|
Controlled by the per-vault ``aiDestructiveTools`` setting (default:
|
||||||
|
enabled). Disabling it blocks delete/rename/move/find-replace tools
|
||||||
|
while keeping create/edit/append available.
|
||||||
|
"""
|
||||||
|
from backend.vault_settings import get_vault_setting
|
||||||
|
|
||||||
|
settings = get_vault_setting(vault) or {}
|
||||||
|
return bool(settings.get("aiDestructiveTools", True))
|
||||||
|
|
||||||
|
def require_destructive_allowed(self, vault: str) -> None:
|
||||||
|
"""Raise :class:`ToolPermissionError` if destructive tools are disabled."""
|
||||||
|
if not self.destructive_tools_enabled(vault):
|
||||||
|
raise ToolPermissionError(
|
||||||
|
f"Destructive tools are disabled for vault '{vault}'",
|
||||||
|
code="destructive_tools_disabled",
|
||||||
|
details={"vault": vault},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
||||||
"""Resolve a vault-relative path, rejecting traversal outside the vault.
|
"""Resolve a vault-relative path, rejecting traversal outside the vault.
|
||||||
|
|||||||
@@ -194,6 +194,13 @@ def call_tool(
|
|||||||
_audit(ctx, spec, arguments, ok=False, error=e.code)
|
_audit(ctx, spec, arguments, ok=False, error=e.code)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
if spec.risk == ToolRisk.DANGEROUS and vault and vault != "all":
|
||||||
|
try:
|
||||||
|
ctx.require_destructive_allowed(vault)
|
||||||
|
except ToolPermissionError as e:
|
||||||
|
_audit(ctx, spec, arguments, ok=False, error=e.code)
|
||||||
|
raise
|
||||||
|
|
||||||
if spec.requires_confirmation and not (confirm or ctx.confirmed):
|
if spec.requires_confirmation and not (confirm or ctx.confirmed):
|
||||||
raise ToolConfirmationRequired(spec.name, arguments)
|
raise ToolConfirmationRequired(spec.name, arguments)
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,105 @@ class ListRecentInput(BaseModel):
|
|||||||
mode: str = Field("opened", description="'opened' (history) or 'modified'")
|
mode: str = Field("opened", description="'opened' (history) or 'modified'")
|
||||||
|
|
||||||
|
|
||||||
|
# ── D. Mutations ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CreateFileInput(BaseModel):
|
||||||
|
"""Create a new text file in a vault."""
|
||||||
|
|
||||||
|
vault: str = Field(..., description="Vault name")
|
||||||
|
path: str = Field(..., description="Vault-relative path of the new file")
|
||||||
|
content: str = Field("", description="Initial file content")
|
||||||
|
|
||||||
|
|
||||||
|
class CreateDirectoryInput(BaseModel):
|
||||||
|
"""Create a new directory in a vault."""
|
||||||
|
|
||||||
|
vault: str = Field(..., description="Vault name")
|
||||||
|
path: str = Field(..., description="Vault-relative path of the new directory")
|
||||||
|
|
||||||
|
|
||||||
|
class EditFileInput(BaseModel):
|
||||||
|
"""Overwrite the full content of an existing file."""
|
||||||
|
|
||||||
|
vault: str = Field(..., description="Vault name")
|
||||||
|
path: str = Field(..., description="Vault-relative file path")
|
||||||
|
content: str = Field(..., description="New full content of the file")
|
||||||
|
|
||||||
|
|
||||||
|
class AppendToFileInput(BaseModel):
|
||||||
|
"""Append text to the end of an existing file."""
|
||||||
|
|
||||||
|
vault: str = Field(..., description="Vault name")
|
||||||
|
path: str = Field(..., description="Vault-relative file path")
|
||||||
|
content: str = Field(..., description="Text to append")
|
||||||
|
|
||||||
|
|
||||||
|
class RenameFileInput(BaseModel):
|
||||||
|
"""Rename a file in place (same parent directory)."""
|
||||||
|
|
||||||
|
vault: str = Field(..., description="Vault name")
|
||||||
|
path: str = Field(..., description="Current vault-relative file path")
|
||||||
|
new_name: str = Field(..., description="New file name (no directory separator)")
|
||||||
|
|
||||||
|
|
||||||
|
class RenameDirectoryInput(BaseModel):
|
||||||
|
"""Rename a directory in place (same parent directory)."""
|
||||||
|
|
||||||
|
vault: str = Field(..., description="Vault name")
|
||||||
|
path: str = Field(..., description="Current vault-relative directory path")
|
||||||
|
new_name: str = Field(..., description="New directory name (no directory separator)")
|
||||||
|
|
||||||
|
|
||||||
|
class MovePathInput(BaseModel):
|
||||||
|
"""Move a file or directory to another directory within the same vault."""
|
||||||
|
|
||||||
|
vault: str = Field(..., description="Vault name")
|
||||||
|
source_path: str = Field(..., description="Current vault-relative path of the file/directory")
|
||||||
|
destination_dir: str = Field("", description="Target directory path (empty = vault root)")
|
||||||
|
|
||||||
|
|
||||||
|
class ReplaceInFilesInput(BaseModel):
|
||||||
|
"""Find and replace text across vault files (destructive, dry-run by default)."""
|
||||||
|
|
||||||
|
find: str = Field(..., min_length=1, description="Text or pattern to search for")
|
||||||
|
replace: str = Field("", description="Replacement text")
|
||||||
|
vault: str = Field("all", description="Vault name or 'all'")
|
||||||
|
case_sensitive: bool = Field(False, description="Match case")
|
||||||
|
whole_word: bool = Field(False, description="Match whole words only")
|
||||||
|
regex: bool = Field(False, description="Treat 'find' as a regular expression")
|
||||||
|
include_paths: str | None = Field(None, description="Comma-separated glob patterns to include")
|
||||||
|
exclude_paths: str | None = Field(None, description="Comma-separated glob patterns to exclude")
|
||||||
|
replace_all: bool = Field(False, description="Apply the replacement (otherwise only preview)")
|
||||||
|
dry_run: bool | None = Field(
|
||||||
|
None,
|
||||||
|
description="Preview matches without writing. Defaults to the opposite of replace_all.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteFileInput(BaseModel):
|
||||||
|
"""Delete a file from a vault (destructive)."""
|
||||||
|
|
||||||
|
vault: str = Field(..., description="Vault name")
|
||||||
|
path: str = Field(..., description="Vault-relative file path")
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteDirectoryInput(BaseModel):
|
||||||
|
"""Delete a directory from a vault (destructive)."""
|
||||||
|
|
||||||
|
vault: str = Field(..., description="Vault name")
|
||||||
|
path: str = Field(..., description="Vault-relative directory path")
|
||||||
|
recursive: bool = Field(True, description="Delete non-empty directories recursively")
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreBackupInput(BaseModel):
|
||||||
|
"""Restore a file from one of its backup versions."""
|
||||||
|
|
||||||
|
vault: str = Field(..., description="Vault name")
|
||||||
|
path: str = Field(..., description="Vault-relative file path")
|
||||||
|
version: int = Field(..., description="Backup timestamp to restore")
|
||||||
|
|
||||||
|
|
||||||
class ToolResult(BaseModel):
|
class ToolResult(BaseModel):
|
||||||
"""Uniform result returned by :func:`backend.tools.registry.call_tool`."""
|
"""Uniform result returned by :func:`backend.tools.registry.call_tool`."""
|
||||||
|
|
||||||
|
|||||||
+206
-2
@@ -1,9 +1,11 @@
|
|||||||
"""Built-in tool services (Phase 0 + Phase C — read/search catalogue).
|
"""Built-in tool services (Phase 0 + Phase C read/search + Phase D mutations).
|
||||||
|
|
||||||
These functions are the single source of truth consumed by both the in-app
|
These functions are the single source of truth consumed by both the in-app
|
||||||
assistant and the MCP server. They delegate to the shared business-logic
|
assistant and the MCP server. They delegate to the shared business-logic
|
||||||
services (``backend.services``) so routes and tools never diverge.
|
services (``backend.services``) so routes and tools never diverge.
|
||||||
Mutating tools (create/edit/rename/move/delete) are added in later phases.
|
Mutating tools (create/edit/rename/move/delete/restore) are registered with
|
||||||
|
``ToolRisk.WRITE`` or ``ToolRisk.DANGEROUS`` and gated by the registry's
|
||||||
|
confirmation mechanism (two-step propose/apply).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -17,6 +19,39 @@ from backend.services.backups import diff_backup as _diff_backup
|
|||||||
from backend.services.backups import list_backup_files as _list_backup_files
|
from backend.services.backups import list_backup_files as _list_backup_files
|
||||||
from backend.services.files import read_file_text
|
from backend.services.files import read_file_text
|
||||||
from backend.services.graph import get_graph as _get_graph
|
from backend.services.graph import get_graph as _get_graph
|
||||||
|
from backend.services.mutations import (
|
||||||
|
append_to_file as _append_to_file,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
create_directory as _create_directory,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
create_file as _create_file,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
delete_directory as _delete_directory,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
delete_file as _delete_file,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
edit_file as _edit_file,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
move_path as _move_path,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
rename_directory as _rename_directory,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
rename_file as _rename_file,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
replace_in_files as _replace_in_files,
|
||||||
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
restore_backup as _restore_backup,
|
||||||
|
)
|
||||||
from backend.services.recent import list_recent as _list_recent
|
from backend.services.recent import list_recent as _list_recent
|
||||||
from backend.services.search import advanced_search_vaults, search_vaults
|
from backend.services.search import advanced_search_vaults, search_vaults
|
||||||
from backend.services.search import list_tags as _list_tags
|
from backend.services.search import list_tags as _list_tags
|
||||||
@@ -25,7 +60,13 @@ from backend.services.vaults import browse_directory, list_accessible_vaults, li
|
|||||||
from backend.tools.context import ToolContext, ToolRisk
|
from backend.tools.context import ToolContext, ToolRisk
|
||||||
from backend.tools.registry import tool
|
from backend.tools.registry import tool
|
||||||
from backend.tools.schemas import (
|
from backend.tools.schemas import (
|
||||||
|
AppendToFileInput,
|
||||||
|
CreateDirectoryInput,
|
||||||
|
CreateFileInput,
|
||||||
|
DeleteDirectoryInput,
|
||||||
|
DeleteFileInput,
|
||||||
DiffBackupInput,
|
DiffBackupInput,
|
||||||
|
EditFileInput,
|
||||||
GetBacklinksInput,
|
GetBacklinksInput,
|
||||||
GetGraphInput,
|
GetGraphInput,
|
||||||
ListAllFilesInput,
|
ListAllFilesInput,
|
||||||
@@ -34,8 +75,13 @@ from backend.tools.schemas import (
|
|||||||
ListRecentInput,
|
ListRecentInput,
|
||||||
ListTagsInput,
|
ListTagsInput,
|
||||||
ListVaultsInput,
|
ListVaultsInput,
|
||||||
|
MovePathInput,
|
||||||
ReadFileInput,
|
ReadFileInput,
|
||||||
ReadFileRawInput,
|
ReadFileRawInput,
|
||||||
|
RenameDirectoryInput,
|
||||||
|
RenameFileInput,
|
||||||
|
ReplaceInFilesInput,
|
||||||
|
RestoreBackupInput,
|
||||||
SearchAdvancedInput,
|
SearchAdvancedInput,
|
||||||
SearchFulltextInput,
|
SearchFulltextInput,
|
||||||
SearchPathsInput,
|
SearchPathsInput,
|
||||||
@@ -300,3 +346,161 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
|
|||||||
limit=params.limit,
|
limit=params.limit,
|
||||||
mode=params.mode,
|
mode=params.mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── D. Mutations ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="create_file",
|
||||||
|
description="Create a new text file in a vault with optional initial content.",
|
||||||
|
input_model=CreateFileInput,
|
||||||
|
risk=ToolRisk.WRITE,
|
||||||
|
requires_vault=True,
|
||||||
|
)
|
||||||
|
def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]:
|
||||||
|
"""Create a vault file (fails if it already exists)."""
|
||||||
|
return _create_file(params.vault, params.path, params.content)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="create_directory",
|
||||||
|
description="Create a new directory (and parents) in a vault.",
|
||||||
|
input_model=CreateDirectoryInput,
|
||||||
|
risk=ToolRisk.WRITE,
|
||||||
|
requires_vault=True,
|
||||||
|
)
|
||||||
|
def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str, Any]:
|
||||||
|
"""Create a vault directory."""
|
||||||
|
return _create_directory(params.vault, params.path)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="edit_file",
|
||||||
|
description="Overwrite the full content of an existing file (automatic backup).",
|
||||||
|
input_model=EditFileInput,
|
||||||
|
risk=ToolRisk.WRITE,
|
||||||
|
requires_vault=True,
|
||||||
|
)
|
||||||
|
def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
|
||||||
|
"""Replace a vault file's content."""
|
||||||
|
return _edit_file(params.vault, params.path, params.content)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="append_to_file",
|
||||||
|
description="Append text to the end of an existing file (automatic backup).",
|
||||||
|
input_model=AppendToFileInput,
|
||||||
|
risk=ToolRisk.WRITE,
|
||||||
|
requires_vault=True,
|
||||||
|
)
|
||||||
|
def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]:
|
||||||
|
"""Append content to a vault file."""
|
||||||
|
return _append_to_file(params.vault, params.path, params.content)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="rename_file",
|
||||||
|
description="Rename a file in place (same parent directory). Destructive: requires confirmation.",
|
||||||
|
input_model=RenameFileInput,
|
||||||
|
risk=ToolRisk.DANGEROUS,
|
||||||
|
requires_vault=True,
|
||||||
|
)
|
||||||
|
def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]:
|
||||||
|
"""Rename a vault file."""
|
||||||
|
return _rename_file(params.vault, params.path, params.new_name)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="rename_directory",
|
||||||
|
description="Rename a directory in place (same parent directory). Destructive: requires confirmation.",
|
||||||
|
input_model=RenameDirectoryInput,
|
||||||
|
risk=ToolRisk.DANGEROUS,
|
||||||
|
requires_vault=True,
|
||||||
|
)
|
||||||
|
def rename_directory(ctx: ToolContext, params: RenameDirectoryInput) -> dict[str, Any]:
|
||||||
|
"""Rename a vault directory."""
|
||||||
|
return _rename_directory(params.vault, params.path, params.new_name)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="move_path",
|
||||||
|
description="Move a file or directory to another directory in the same vault. Destructive: requires confirmation.",
|
||||||
|
input_model=MovePathInput,
|
||||||
|
risk=ToolRisk.DANGEROUS,
|
||||||
|
requires_vault=True,
|
||||||
|
)
|
||||||
|
def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]:
|
||||||
|
"""Move a vault file or directory."""
|
||||||
|
return _move_path(params.vault, params.source_path, params.destination_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="replace_in_files",
|
||||||
|
description=(
|
||||||
|
"Find and replace text across vault files. Previews by default "
|
||||||
|
"(dry_run); set replace_all=true to apply. Destructive: requires confirmation."
|
||||||
|
),
|
||||||
|
input_model=ReplaceInFilesInput,
|
||||||
|
risk=ToolRisk.DANGEROUS,
|
||||||
|
)
|
||||||
|
def replace_in_files(ctx: ToolContext, params: ReplaceInFilesInput) -> dict[str, Any]:
|
||||||
|
"""Preview or apply a find/replace, filtered to permitted vaults."""
|
||||||
|
if params.vault and params.vault != "all":
|
||||||
|
ctx.require_vault_access(params.vault)
|
||||||
|
ctx.require_destructive_allowed(params.vault)
|
||||||
|
|
||||||
|
dry_run = params.dry_run if params.dry_run is not None else not params.replace_all
|
||||||
|
|
||||||
|
def _allowed(vault: str) -> bool:
|
||||||
|
return ctx.has_vault_access(vault) and ctx.destructive_tools_enabled(vault)
|
||||||
|
|
||||||
|
return _replace_in_files(
|
||||||
|
params.find,
|
||||||
|
params.replace,
|
||||||
|
vault=params.vault,
|
||||||
|
case_sensitive=params.case_sensitive,
|
||||||
|
whole_word=params.whole_word,
|
||||||
|
regex=params.regex,
|
||||||
|
include_paths=params.include_paths,
|
||||||
|
exclude_paths=params.exclude_paths,
|
||||||
|
replace_all=params.replace_all,
|
||||||
|
dry_run=dry_run,
|
||||||
|
is_vault_allowed=_allowed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="delete_file",
|
||||||
|
description="Delete a file from a vault (automatic backup). Destructive: requires confirmation.",
|
||||||
|
input_model=DeleteFileInput,
|
||||||
|
risk=ToolRisk.DANGEROUS,
|
||||||
|
requires_vault=True,
|
||||||
|
)
|
||||||
|
def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]:
|
||||||
|
"""Delete a vault file."""
|
||||||
|
return _delete_file(params.vault, params.path)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="delete_directory",
|
||||||
|
description="Delete a directory (recursive by default) from a vault. Destructive: requires confirmation.",
|
||||||
|
input_model=DeleteDirectoryInput,
|
||||||
|
risk=ToolRisk.DANGEROUS,
|
||||||
|
requires_vault=True,
|
||||||
|
)
|
||||||
|
def delete_directory(ctx: ToolContext, params: DeleteDirectoryInput) -> dict[str, Any]:
|
||||||
|
"""Delete a vault directory."""
|
||||||
|
return _delete_directory(params.vault, params.path, recursive=params.recursive)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="restore_backup",
|
||||||
|
description="Restore a file from one of its backup versions (current version backed up first).",
|
||||||
|
input_model=RestoreBackupInput,
|
||||||
|
risk=ToolRisk.WRITE,
|
||||||
|
requires_vault=True,
|
||||||
|
)
|
||||||
|
def restore_backup(ctx: ToolContext, params: RestoreBackupInput) -> dict[str, Any]:
|
||||||
|
"""Restore a vault file from a backup."""
|
||||||
|
return _restore_backup(params.vault, params.path, params.version)
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ ObsiGate possède aujourd'hui **deux sous-systèmes IA** qui partagent la même
|
|||||||
### 2.5 Limites de l'existant
|
### 2.5 Limites de l'existant
|
||||||
|
|
||||||
1. **Pas de tool calling** : parsing regex fragile, pas de résultats structurés, pas de multi-étapes.
|
1. **Pas de tool calling** : parsing regex fragile, pas de résultats structurés, pas de multi-étapes.
|
||||||
|
→ **résolu** (phases B/C/D) : tool calling natif, catalogue lecture/recherche + mutations.
|
||||||
2. **2 actions seulement** (`create_file`, `create_directory`) ; ni lecture active, ni recherche, ni édition, ni suppression exposées au modèle.
|
2. **2 actions seulement** (`create_file`, `create_directory`) ; ni lecture active, ni recherche, ni édition, ni suppression exposées au modèle.
|
||||||
|
→ **résolu** (phases C/D) : catalogue complet (lecture, recherche, mutations avec confirmation).
|
||||||
3. **SSE** : ~~non réellement streaming~~ → **corrigé** (B4) : `ai_chat.stream_completion` alimente `/api/ai/bookslm/chat` token par token ; le tool-calling (`/agent`) reste non-streaming (les appels d'outils exigent la réponse complète).
|
3. **SSE** : ~~non réellement streaming~~ → **corrigé** (B4) : `ai_chat.stream_completion` alimente `/api/ai/bookslm/chat` token par token ; le tool-calling (`/agent`) reste non-streaming (les appels d'outils exigent la réponse complète).
|
||||||
4. **`PROVIDERS` chargé une seule fois** : les modèles par défaut ne sont modifiables que par variables d'environnement (pas de persistance UI).
|
4. **`PROVIDERS` chargé une seule fois** : les modèles par défaut ne sont modifiables que par variables d'environnement (pas de persistance UI).
|
||||||
|
|
||||||
@@ -93,7 +95,7 @@ ObsiGate possède aujourd'hui **deux sous-systèmes IA** qui partagent la même
|
|||||||
|
|
||||||
Extraire la logique métier des routes de `backend/main.py` vers des fonctions réutilisables (services). Les routes REST, l'agent in-app et le serveur MCP appellent ces mêmes services.
|
Extraire la logique métier des routes de `backend/main.py` vers des fonctions réutilisables (services). Les routes REST, l'agent in-app et le serveur MCP appellent ces mêmes services.
|
||||||
|
|
||||||
**Nouveau module `backend/services/` (A2 lecture/recherche + C) :**
|
**Nouveau module `backend/services/` (A2 lecture/recherche + C + D mutations) :**
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/services/
|
backend/services/
|
||||||
@@ -101,17 +103,21 @@ backend/services/
|
|||||||
├── paths.py # resolve_safe_path (anti path-traversal, source unique)
|
├── paths.py # resolve_safe_path (anti path-traversal, source unique)
|
||||||
├── vaults.py # list_accessible_vaults, browse_directory, get_vault_root, list_all_files
|
├── vaults.py # list_accessible_vaults, browse_directory, get_vault_root, list_all_files
|
||||||
├── files.py # read_raw_file, read_file_text (redaction + quota)
|
├── files.py # read_raw_file, read_file_text (redaction + quota)
|
||||||
|
├── mutations.py # create/edit/append/rename/move/delete/restore/replace (D)
|
||||||
├── search.py # search_vaults (pagination), list_tags, advanced_search_vaults, search_paths
|
├── search.py # search_vaults (pagination), list_tags, advanced_search_vaults, search_paths
|
||||||
├── backups.py # get_backup_dir, list_backup_files, diff_backup
|
├── backups.py # get_backup_dir, create_backup, list_backup_files, diff_backup
|
||||||
├── graph.py # get_graph (nodes/edges, wikilinks)
|
├── graph.py # get_graph (nodes/edges, wikilinks)
|
||||||
└── recent.py # list_recent, humanize_mtime
|
└── recent.py # list_recent, humanize_mtime
|
||||||
```
|
```
|
||||||
|
|
||||||
Les routes `/api/vaults`, `/api/browse/{vault}`, `/api/file/{vault}/raw`, `/api/search`, `/api/tags`,
|
Les routes `/api/vaults`, `/api/browse/{vault}`, `/api/file/{vault}/raw`, `/api/search`, `/api/tags`,
|
||||||
`/api/recent`, `/api/vault/{vault}/files`, `/api/file/{vault}/backups`, `/api/file/{vault}/diff`,
|
`/api/recent`, `/api/vault/{vault}/files`, `/api/file/{vault}/backups`, `/api/file/{vault}/diff`,
|
||||||
`/api/graph/{vault}`, `/api/tree-search` et `/api/search/advanced` en sont de simples wrappers, tout
|
`/api/graph/{vault}`, `/api/tree-search`, `/api/search/advanced` (phase C) ainsi que
|
||||||
comme les outils correspondants du catalogue (phase C). Un `ServiceError` est traduit en
|
`/api/file/{vault}` (POST/PATCH/DELETE), `/api/file/{vault}/save`, `/api/file/{vault}/restore`,
|
||||||
`HTTPException` (handler global de `backend/main.py`) ou en `ToolError` (`backend/tools/registry.py`).
|
`/api/directory/{vault}` (POST/PATCH/DELETE), `/api/move/{vault}` et `/api/search/replace` (phase D)
|
||||||
|
en sont de simples wrappers, tout comme les outils correspondants du catalogue. Un `ServiceError`
|
||||||
|
est traduit en `HTTPException` (handler global de `backend/main.py`) ou en `ToolError`
|
||||||
|
(`backend/tools/registry.py`).
|
||||||
|
|
||||||
**Nouveau module `backend/tools/` :**
|
**Nouveau module `backend/tools/` :**
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -43,11 +43,11 @@
|
|||||||
### 79. Assistant IA — Outils (function calling) & serveur MCP
|
### 79. Assistant IA — Outils (function calling) & serveur MCP
|
||||||
|
|
||||||
- **Effort :** 10-15 jours | **Impact :** 🟡
|
- **Effort :** 10-15 jours | **Impact :** 🟡
|
||||||
- **Statut :** 🔵 Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + G livrés (2026-09-11). Détail complet : [features/ai-tools-mcp.md](./features/ai-tools-mcp.md)
|
- **Statut :** 🔵 Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + D + G livrés (2026-09-11). Détail complet : [features/ai-tools-mcp.md](./features/ai-tools-mcp.md)
|
||||||
- **Description :** Transformer l'assistant BooksLM en agent (lire, chercher, lister, ouvrir, modifier) via **function calling natif**, puis exposer ObsiGate à des **clients MCP externes** (Claude Desktop, Cursor…). Les deux fronts consomment une **couche d'outils partagée**.
|
- **Description :** Transformer l'assistant BooksLM en agent (lire, chercher, lister, ouvrir, modifier) via **function calling natif**, puis exposer ObsiGate à des **clients MCP externes** (Claude Desktop, Cursor…). Les deux fronts consomment une **couche d'outils partagée**.
|
||||||
- **Reste à faire :**
|
- **Reste à faire :**
|
||||||
- [x] **C.** Catalogue lecture & recherche (vaults, read, backlinks, backups, search, tags)
|
- [x] **C.** Catalogue lecture & recherche (vaults, read, backlinks, backups, search, tags)
|
||||||
- [ ] **D.** Catalogue mutations (create/edit/append/rename/move/delete) + confirmations two-step
|
- [x] **D.** Catalogue mutations (create/edit/append/rename/move/delete) + confirmations two-step
|
||||||
- [ ] **E.** Serveur MCP (Streamable HTTP `/mcp`, auth Bearer JWT, `propose`/`apply`, toggle par vault)
|
- [ ] **E.** Serveur MCP (Streamable HTTP `/mcp`, auth Bearer JWT, `propose`/`apply`, toggle par vault)
|
||||||
- [ ] **F.** Durcissement (rate limiting, redaction secrets, OpenAPI + guide MCP, E2E)
|
- [ ] **F.** Durcissement (rate limiting, redaction secrets, OpenAPI + guide MCP, E2E)
|
||||||
- **Documentation :** [AI_ARCHITECTURE_GUIDE.md](./AI_ARCHITECTURE_GUIDE.md) (architecture, catalogue d'outils, sécurité, phases).
|
- **Documentation :** [AI_ARCHITECTURE_GUIDE.md](./AI_ARCHITECTURE_GUIDE.md) (architecture, catalogue d'outils, sécurité, phases).
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ✅ Complété | #1 → #59, #61, #63–68, #71, #72, #74–76, #78 | ~82 jours réalisés |
|
| ✅ Complété | #1 → #59, #61, #63–68, #71, #72, #74–76, #78 | ~82 jours réalisés |
|
||||||
| 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours |
|
| 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours |
|
||||||
| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + A2 + B + C + G livrés) · #80 Assistant IA UX (livré, en attente vérif) | ~15-22 jours |
|
| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + A2 + B + C + D + G livrés) · #80 Assistant IA UX (livré, en attente vérif) | ~15-22 jours |
|
||||||
| ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours |
|
| ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours |
|
||||||
| **Total restant** | **6 items + finitions** | **~27-39 jours** |
|
| **Total restant** | **6 items + finitions** | **~27-39 jours** |
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# #79 — Assistant IA — Outils (function calling) & serveur MCP
|
# #79 — Assistant IA — Outils (function calling) & serveur MCP
|
||||||
|
|
||||||
> **Statut :** 🔵 En cours — Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + G livrés (2026-09-11)
|
> **Statut :** 🔵 En cours — Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + D + G livrés (2026-09-11)
|
||||||
> **Effort :** 10-15 jours | **Impact :** 🟡
|
> **Effort :** 10-15 jours | **Impact :** 🟡
|
||||||
> **Références :** [Roadmap](../ROADMAP.md) · [Guide d'architecture IA](../AI_ARCHITECTURE_GUIDE.md)
|
> **Références :** [Roadmap](../ROADMAP.md) · [Guide d'architecture IA](../AI_ARCHITECTURE_GUIDE.md)
|
||||||
|
|
||||||
@@ -29,11 +29,17 @@
|
|||||||
- [x] **C3.** Recherche : `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` — extensions `backend/services/search.py` (`advanced_search_vaults`, `search_paths`) et `recent.py` ; filtrage systématique par permissions vault
|
- [x] **C3.** Recherche : `search_fulltext`, `search_advanced`, `search_paths`, `list_tags`, `suggest_tags`, `list_recent` — extensions `backend/services/search.py` (`advanced_search_vaults`, `search_paths`) et `recent.py` ; filtrage systématique par permissions vault
|
||||||
- Routes `/api/recent`, `/api/vault/{vault}/files`, `/api/file/{vault}/backups`, `/api/file/{vault}/diff`, `/api/graph/{vault}`, `/api/tree-search`, `/api/search/advanced` refactorées en wrappers des services ; tests `tests/test_tools.py` (+17)
|
- Routes `/api/recent`, `/api/vault/{vault}/files`, `/api/file/{vault}/backups`, `/api/file/{vault}/diff`, `/api/graph/{vault}`, `/api/tree-search`, `/api/search/advanced` refactorées en wrappers des services ; tests `tests/test_tools.py` (+17)
|
||||||
|
|
||||||
## D. Catalogue d'outils — mutations (2 jours)
|
## D. Catalogue d'outils — mutations (2 jours) — ✅ livré (2026-09-11)
|
||||||
- [ ] **D1.** `create_file`, `create_directory` (migration du protocole texte existant)
|
- [x] **D1.** `create_file`, `create_directory` (migration du protocole texte existant)
|
||||||
- [ ] **D2.** `edit_file`, `append_to_file`, `rename_file`, `rename_directory`, `move_path`
|
- [x] **D2.** `edit_file`, `append_to_file`, `rename_file`, `rename_directory`, `move_path`
|
||||||
- [ ] **D3.** `replace_in_files` (⚠️ confirmation two-step + backup auto)
|
- [x] **D3.** `replace_in_files` (⚠️ confirmation two-step + backup auto)
|
||||||
- [ ] **D4.** `delete_file`, `delete_directory`, `restore_backup` (⚠️ confirmation two-step + backup auto, toggle par vault)
|
- [x] **D4.** `delete_file`, `delete_directory`, `restore_backup` (⚠️ confirmation two-step + backup auto, toggle par vault)
|
||||||
|
- **Détail :** service `backend/services/mutations.py` (source unique : anti path-traversal, garde
|
||||||
|
lecture seule, backup auto via `backend/services/backups.create_backup`) ; les routes REST
|
||||||
|
correspondantes délèguent à cette couche. Risques : `WRITE` (create/edit/append/restore) et
|
||||||
|
`DANGEROUS` (rename/move/replace/delete) → confirmation two-step automatique par le registry.
|
||||||
|
Toggle par vault `aiDestructiveTools` (défaut activé) pour désactiver les outils `DANGEROUS`.
|
||||||
|
Tests : `tests/test_tools_mutations.py` (40 tests).
|
||||||
|
|
||||||
## E. Serveur MCP (2-3 jours)
|
## E. Serveur MCP (2-3 jours)
|
||||||
- [ ] **E1.** `backend/mcp/server.py` (SDK MCP Python) enregistrant les outils depuis le registry
|
- [ ] **E1.** `backend/mcp/server.py` (SDK MCP Python) enregistrant les outils depuis le registry
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ BUILTIN_TOOLS = {
|
|||||||
"search_paths",
|
"search_paths",
|
||||||
"suggest_tags",
|
"suggest_tags",
|
||||||
"list_recent",
|
"list_recent",
|
||||||
|
# Phase D — mutations
|
||||||
|
"create_file",
|
||||||
|
"create_directory",
|
||||||
|
"edit_file",
|
||||||
|
"append_to_file",
|
||||||
|
"rename_file",
|
||||||
|
"rename_directory",
|
||||||
|
"move_path",
|
||||||
|
"replace_in_files",
|
||||||
|
"delete_file",
|
||||||
|
"delete_directory",
|
||||||
|
"restore_backup",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
# tests/test_tools_mutations.py — Unit tests for the AI tool layer (Phase D)
|
||||||
|
"""Tests for the mutation tools: create/edit/append/rename/move/delete/restore.
|
||||||
|
|
||||||
|
These exercise the shared tool layer end-to-end against the ``TestVault``
|
||||||
|
fixture (``client``), including confirmation gating, backups and the
|
||||||
|
per-vault destructive-tools toggle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.tools.api import (
|
||||||
|
ToolConfirmationRequired,
|
||||||
|
ToolContext,
|
||||||
|
ToolError,
|
||||||
|
ToolPermissionError,
|
||||||
|
ToolRisk,
|
||||||
|
call_tool,
|
||||||
|
get_tool,
|
||||||
|
)
|
||||||
|
|
||||||
|
MUTATION_TOOLS = {
|
||||||
|
"create_file",
|
||||||
|
"create_directory",
|
||||||
|
"edit_file",
|
||||||
|
"append_to_file",
|
||||||
|
"rename_file",
|
||||||
|
"rename_directory",
|
||||||
|
"move_path",
|
||||||
|
"replace_in_files",
|
||||||
|
"delete_file",
|
||||||
|
"delete_directory",
|
||||||
|
"restore_backup",
|
||||||
|
}
|
||||||
|
|
||||||
|
WRITE_TOOLS = {"create_file", "create_directory", "edit_file", "append_to_file", "restore_backup"}
|
||||||
|
DANGEROUS_TOOLS = {
|
||||||
|
"rename_file",
|
||||||
|
"rename_directory",
|
||||||
|
"move_path",
|
||||||
|
"replace_in_files",
|
||||||
|
"delete_file",
|
||||||
|
"delete_directory",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(vaults=None, **kwargs) -> ToolContext:
|
||||||
|
user = {"username": "tester", "role": "admin", "vaults": vaults or ["*"]}
|
||||||
|
kwargs.setdefault("audit_enabled", False)
|
||||||
|
return ToolContext(user=user, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _vault_path() -> Path:
|
||||||
|
from backend.indexer import get_vault_data
|
||||||
|
|
||||||
|
return Path(get_vault_data("TestVault")["path"])
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Registry / risk levels
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestMutationRegistry:
|
||||||
|
def test_mutation_tools_registered(self):
|
||||||
|
for name in MUTATION_TOOLS:
|
||||||
|
assert get_tool(name) is not None, name
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", sorted(WRITE_TOOLS))
|
||||||
|
def test_write_risk(self, name):
|
||||||
|
assert get_tool(name).risk is ToolRisk.WRITE
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", sorted(DANGEROUS_TOOLS))
|
||||||
|
def test_dangerous_risk(self, name):
|
||||||
|
assert get_tool(name).risk is ToolRisk.DANGEROUS
|
||||||
|
|
||||||
|
def test_all_mutations_require_confirmation(self):
|
||||||
|
for name in MUTATION_TOOLS:
|
||||||
|
assert get_tool(name).requires_confirmation is True
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Confirmation gating
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestMutationConfirmation:
|
||||||
|
def test_create_file_requires_confirmation(self, client):
|
||||||
|
with pytest.raises(ToolConfirmationRequired):
|
||||||
|
call_tool("create_file", _ctx(), {"vault": "TestVault", "path": "n.md", "content": "x"})
|
||||||
|
assert not (_vault_path() / "n.md").exists()
|
||||||
|
|
||||||
|
def test_delete_file_requires_confirmation(self, client):
|
||||||
|
with pytest.raises(ToolConfirmationRequired):
|
||||||
|
call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "note1.md"})
|
||||||
|
assert (_vault_path() / "note1.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# D1. Creation
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreate:
|
||||||
|
def test_create_file(self, client):
|
||||||
|
result = call_tool(
|
||||||
|
"create_file",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "path": "new/note.md", "content": "# Hi\n"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert result.ok
|
||||||
|
assert (_vault_path() / "new" / "note.md").read_text(encoding="utf-8") == "# Hi\n"
|
||||||
|
assert result.data["path"] == "new/note.md"
|
||||||
|
|
||||||
|
def test_create_file_already_exists(self, client):
|
||||||
|
with pytest.raises(ToolError) as exc:
|
||||||
|
call_tool("create_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}, confirm=True)
|
||||||
|
assert exc.value.code == "already_exists"
|
||||||
|
|
||||||
|
def test_create_file_unsupported_extension(self, client):
|
||||||
|
with pytest.raises(ToolError) as exc:
|
||||||
|
call_tool(
|
||||||
|
"create_file",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "path": "binary.exe", "content": "x"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert exc.value.code == "unsupported_extension"
|
||||||
|
|
||||||
|
def test_create_file_traversal_rejected(self, client):
|
||||||
|
with pytest.raises(ToolPermissionError):
|
||||||
|
call_tool(
|
||||||
|
"create_file",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "path": "../evil.md", "content": "x"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_directory(self, client):
|
||||||
|
result = call_tool("create_directory", _ctx(), {"vault": "TestVault", "path": "A/B"}, confirm=True)
|
||||||
|
assert result.ok
|
||||||
|
assert (_vault_path() / "A" / "B").is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# D2. Edit / append / rename / move
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestEditAppend:
|
||||||
|
def test_edit_file(self, client):
|
||||||
|
result = call_tool(
|
||||||
|
"edit_file",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "path": "note1.md", "content": "# Replaced\n"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert result.ok
|
||||||
|
assert (_vault_path() / "note1.md").read_text(encoding="utf-8") == "# Replaced\n"
|
||||||
|
|
||||||
|
def test_edit_file_creates_backup(self, client):
|
||||||
|
from backend.services.backups import get_backup_dir
|
||||||
|
|
||||||
|
call_tool(
|
||||||
|
"edit_file",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "path": "note1.md", "content": "# Changed\n"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
backups = list(get_backup_dir("TestVault", "note1.md").glob("note1.md.*.bak"))
|
||||||
|
assert backups
|
||||||
|
|
||||||
|
def test_edit_missing_file(self, client):
|
||||||
|
with pytest.raises(ToolError) as exc:
|
||||||
|
call_tool(
|
||||||
|
"edit_file",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "path": "missing.md", "content": "x"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert exc.value.code == "not_found"
|
||||||
|
|
||||||
|
def test_append_to_file(self, client):
|
||||||
|
original = (_vault_path() / "note1.md").read_text(encoding="utf-8")
|
||||||
|
result = call_tool(
|
||||||
|
"append_to_file",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "path": "note1.md", "content": "APPENDED"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert result.ok
|
||||||
|
updated = (_vault_path() / "note1.md").read_text(encoding="utf-8")
|
||||||
|
assert updated.startswith(original)
|
||||||
|
assert updated.endswith("APPENDED")
|
||||||
|
assert result.data["appended"] == len("APPENDED")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenameMove:
|
||||||
|
def test_rename_file(self, client):
|
||||||
|
result = call_tool(
|
||||||
|
"rename_file",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "path": "note1.md", "new_name": "note1_renamed.md"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert result.ok
|
||||||
|
assert not (_vault_path() / "note1.md").exists()
|
||||||
|
assert (_vault_path() / "note1_renamed.md").exists()
|
||||||
|
assert result.data["new_path"] == "note1_renamed.md"
|
||||||
|
|
||||||
|
def test_rename_file_rejects_path_separator(self, client):
|
||||||
|
with pytest.raises(ToolError) as exc:
|
||||||
|
call_tool(
|
||||||
|
"rename_file",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "path": "note1.md", "new_name": "sub/note1.md"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert exc.value.code == "invalid_arguments"
|
||||||
|
|
||||||
|
def test_rename_directory(self, client):
|
||||||
|
call_tool("create_directory", _ctx(), {"vault": "TestVault", "path": "OldDir"}, confirm=True)
|
||||||
|
result = call_tool(
|
||||||
|
"rename_directory",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "path": "OldDir", "new_name": "NewDir"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert result.ok
|
||||||
|
assert not (_vault_path() / "OldDir").exists()
|
||||||
|
assert (_vault_path() / "NewDir").is_dir()
|
||||||
|
|
||||||
|
def test_move_file(self, client):
|
||||||
|
result = call_tool(
|
||||||
|
"move_path",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "source_path": "note1.md", "destination_dir": "Projets"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert result.ok
|
||||||
|
assert result.data["new_path"] == "Projets/note1.md"
|
||||||
|
assert (_vault_path() / "Projets" / "note1.md").exists()
|
||||||
|
|
||||||
|
def test_move_directory(self, client):
|
||||||
|
result = call_tool(
|
||||||
|
"move_path",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "source_path": "Projets", "destination_dir": ""},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert result.data["item_type"] == "directory"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# D3. Find & replace
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestReplaceInFiles:
|
||||||
|
def test_dry_run_does_not_write(self, client):
|
||||||
|
before = (_vault_path() / "note1.md").read_text(encoding="utf-8")
|
||||||
|
result = call_tool(
|
||||||
|
"replace_in_files",
|
||||||
|
_ctx(),
|
||||||
|
{"find": "Python", "replace": "Rust", "vault": "TestVault"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert result.data["dry_run"] is True
|
||||||
|
assert result.data["total_matches"] >= 1
|
||||||
|
assert (_vault_path() / "note1.md").read_text(encoding="utf-8") == before
|
||||||
|
|
||||||
|
def test_apply_replaces_and_backs_up(self, client):
|
||||||
|
result = call_tool(
|
||||||
|
"replace_in_files",
|
||||||
|
_ctx(),
|
||||||
|
{
|
||||||
|
"find": "Python",
|
||||||
|
"replace": "Rust",
|
||||||
|
"vault": "TestVault",
|
||||||
|
"replace_all": True,
|
||||||
|
"dry_run": False,
|
||||||
|
},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert result.data["dry_run"] is False
|
||||||
|
assert result.data["total_replacements"] >= 1
|
||||||
|
assert "Rust" in (_vault_path() / "note1.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_filters_inaccessible_vaults(self, client):
|
||||||
|
ctx = ToolContext(user={"username": "limited", "vaults": ["OtherVault"]}, audit_enabled=False)
|
||||||
|
result = call_tool(
|
||||||
|
"replace_in_files",
|
||||||
|
ctx,
|
||||||
|
{"find": "Python", "replace": "Rust", "vault": "all"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert result.data["total_matches"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# D4. Delete / restore
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteRestore:
|
||||||
|
def test_delete_file_with_backup(self, client):
|
||||||
|
from backend.services.backups import get_backup_dir
|
||||||
|
|
||||||
|
result = call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}, confirm=True)
|
||||||
|
assert result.ok
|
||||||
|
assert not (_vault_path() / "note1.md").exists()
|
||||||
|
assert list(get_backup_dir("TestVault", "note1.md").glob("note1.md.*.bak"))
|
||||||
|
|
||||||
|
def test_delete_missing_file(self, client):
|
||||||
|
with pytest.raises(ToolError) as exc:
|
||||||
|
call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "missing.md"}, confirm=True)
|
||||||
|
assert exc.value.code == "not_found"
|
||||||
|
|
||||||
|
def test_delete_directory(self, client):
|
||||||
|
result = call_tool("delete_directory", _ctx(), {"vault": "TestVault", "path": "Projets"}, confirm=True)
|
||||||
|
assert result.ok
|
||||||
|
assert result.data["deleted_count"] >= 1
|
||||||
|
assert not (_vault_path() / "Projets").exists()
|
||||||
|
|
||||||
|
def test_restore_backup(self, client):
|
||||||
|
from backend.services.backups import create_backup
|
||||||
|
|
||||||
|
file_path = _vault_path() / "note1.md"
|
||||||
|
original = file_path.read_text(encoding="utf-8")
|
||||||
|
backup_path = create_backup(file_path, "TestVault", "note1.md")
|
||||||
|
assert backup_path is not None
|
||||||
|
|
||||||
|
# Change the file directly (no extra backup), then restore the version.
|
||||||
|
file_path.write_text("# modified\n", encoding="utf-8")
|
||||||
|
assert file_path.read_text(encoding="utf-8") == "# modified\n"
|
||||||
|
|
||||||
|
version = int(backup_path.name.split(".")[-2])
|
||||||
|
result = call_tool(
|
||||||
|
"restore_backup",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "path": "note1.md", "version": version},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert result.ok
|
||||||
|
assert file_path.read_text(encoding="utf-8") == original
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Per-vault destructive-tools toggle
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestDestructiveToggle:
|
||||||
|
def _disable(self, monkeypatch):
|
||||||
|
import backend.vault_settings as vs
|
||||||
|
|
||||||
|
monkeypatch.setattr(vs, "get_vault_setting", lambda name: {"aiDestructiveTools": False})
|
||||||
|
|
||||||
|
def test_delete_blocked_when_disabled(self, client, monkeypatch):
|
||||||
|
self._disable(monkeypatch)
|
||||||
|
with pytest.raises(ToolPermissionError) as exc:
|
||||||
|
call_tool("delete_file", _ctx(), {"vault": "TestVault", "path": "note1.md"}, confirm=True)
|
||||||
|
assert exc.value.code == "destructive_tools_disabled"
|
||||||
|
assert (_vault_path() / "note1.md").exists()
|
||||||
|
|
||||||
|
def test_rename_blocked_when_disabled(self, client, monkeypatch):
|
||||||
|
self._disable(monkeypatch)
|
||||||
|
with pytest.raises(ToolPermissionError):
|
||||||
|
call_tool(
|
||||||
|
"rename_file",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "path": "note1.md", "new_name": "x.md"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_edit_still_allowed_when_disabled(self, client, monkeypatch):
|
||||||
|
self._disable(monkeypatch)
|
||||||
|
result = call_tool(
|
||||||
|
"edit_file",
|
||||||
|
_ctx(),
|
||||||
|
{"vault": "TestVault", "path": "note1.md", "content": "# ok\n"},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert result.ok
|
||||||
|
|
||||||
|
def test_replace_filters_disabled_vault(self, client, monkeypatch):
|
||||||
|
self._disable(monkeypatch)
|
||||||
|
result = call_tool(
|
||||||
|
"replace_in_files",
|
||||||
|
_ctx(),
|
||||||
|
{"find": "Python", "replace": "Rust", "vault": "all", "replace_all": True, "dry_run": False},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
assert result.data["total_replacements"] == 0
|
||||||
Reference in New Issue
Block a user