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:
+223
-573
@@ -36,7 +36,6 @@ from backend.history import (
|
||||
)
|
||||
from backend.image_processor import preprocess_images
|
||||
from backend.indexer import (
|
||||
SUPPORTED_EXTENSIONS,
|
||||
_extract_tags,
|
||||
add_vault_to_index,
|
||||
build_index,
|
||||
@@ -94,7 +93,6 @@ from backend.schemas import (
|
||||
WebhookModel,
|
||||
)
|
||||
from backend.search import (
|
||||
advanced_search,
|
||||
init_inverted_index,
|
||||
suggest_tags,
|
||||
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.files import read_raw_file
|
||||
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.search import advanced_search_vaults, search_paths, search_vaults
|
||||
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):
|
||||
"""Create a timestamped backup of a file before modification.
|
||||
|
||||
Backups are stored in {backup_root}/{vault}/{relative_path}.{timestamp}.bak
|
||||
Silently skips if the file doesn't exist or can't be read.
|
||||
Thin wrapper around :func:`backend.services.backups.create_backup`
|
||||
(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:
|
||||
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}")
|
||||
from backend.services.backups import create_backup
|
||||
|
||||
# Auto-cleanup: keep only the N most recent backups
|
||||
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)
|
||||
create_backup(file_path, vault_name, relative_path)
|
||||
|
||||
|
||||
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):
|
||||
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"])
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
content = body.get("content", "")
|
||||
result = service_edit_file(vault_name, path, content, backup=backup)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
# Audit log
|
||||
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', '')
|
||||
|
||||
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}")
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
|
||||
|
||||
@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):
|
||||
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"])
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
result = service_delete_file(vault_name, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
# Audit log
|
||||
client_ip = current_user.get("_request_ip", "unknown")
|
||||
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||
# Update index
|
||||
await remove_single_file(vault_name, path)
|
||||
|
||||
try:
|
||||
# Backup original content before deletion
|
||||
_backup_file(file_path, vault_name, path)
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_deleted", {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
})
|
||||
|
||||
file_path.unlink()
|
||||
logger.info(f"File deleted: {vault_name}/{path}")
|
||||
from backend.plugins import emit_file_deleted
|
||||
emit_file_deleted(vault_name, path)
|
||||
|
||||
# Audit log
|
||||
client_ip = current_user.get("_request_ip", "unknown")
|
||||
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
||||
# Remove from recent files
|
||||
remove_recent(current_user["username"], vault_name, path)
|
||||
|
||||
# Update index
|
||||
await remove_single_file(vault_name, path)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_deleted", {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
})
|
||||
# Dispatch webhooks
|
||||
await dispatch_webhooks("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}")
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1761,61 +1720,36 @@ async def api_directory_create(
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
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"])
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||
|
||||
# Resolve and validate 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}")
|
||||
|
||||
result = service_create_directory(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": result["path"],
|
||||
})
|
||||
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": result["path"]})
|
||||
|
||||
return {"success": True, "path": result["path"]}
|
||||
|
||||
|
||||
@app.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
|
||||
@@ -1835,60 +1769,24 @@ async def api_directory_rename(
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
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"])
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||
|
||||
# Resolve old path
|
||||
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}")
|
||||
|
||||
result = service_rename_directory(vault_name, body.path, body.new_name)
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
|
||||
# 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}
|
||||
|
||||
|
||||
@app.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
|
||||
@@ -1908,49 +1806,23 @@ async def api_directory_delete(
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
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"])
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||
|
||||
# Resolve and validate path
|
||||
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}")
|
||||
|
||||
result = service_delete_directory(vault_name, path, recursive=True)
|
||||
file_count = result["deleted_count"]
|
||||
|
||||
# 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": result["path"],
|
||||
"deleted_count": file_count,
|
||||
})
|
||||
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": result["path"]})
|
||||
|
||||
return {"success": True, "deleted_count": file_count}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1974,60 +1846,22 @@ async def api_file_create(
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
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"])
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||
|
||||
# Resolve and validate 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}")
|
||||
|
||||
result = service_create_file(vault_name, body.path, body.content)
|
||||
|
||||
# Update index
|
||||
await update_single_file(vault_name, result["path"])
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_created", {
|
||||
"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"])
|
||||
|
||||
return {"success": True, "path": result["path"]}
|
||||
|
||||
|
||||
@app.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
|
||||
@@ -2047,69 +1881,28 @@ async def api_file_rename(
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
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"])
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||
|
||||
# Resolve old path
|
||||
old_path = _resolve_safe_path(vault_root, body.path)
|
||||
|
||||
if not old_path.exists() or not old_path.is_file():
|
||||
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}")
|
||||
|
||||
result = service_rename_file(vault_name, body.path, body.new_name)
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
|
||||
# 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}
|
||||
|
||||
|
||||
@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):
|
||||
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")
|
||||
result = service_move_path(vault_name, body.source_path, body.destination_dir)
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
item_type = result["item_type"]
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
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}")
|
||||
# Update index
|
||||
if item_type == "directory":
|
||||
from backend.indexer import reload_single_vault
|
||||
await reload_single_vault(vault_name)
|
||||
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
|
||||
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}")
|
||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2331,64 +2057,28 @@ async def api_file_restore(
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
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")
|
||||
result = service_restore_backup(vault_name, path, body.version)
|
||||
current_backed_up = result["current_backed_up"]
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
# Update index
|
||||
await update_single_file(vault_name, path)
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||
# 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})
|
||||
|
||||
file_path = _resolve_safe_path(vault_root, path)
|
||||
original_name = Path(path).name
|
||||
|
||||
backup_dir = _get_backup_dir(vault_name, path)
|
||||
backup_path = backup_dir / f"{original_name}.{body.version}.bak"
|
||||
|
||||
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}")
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"restored_from": body.version,
|
||||
"current_backed_up": current_backed_up,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse)
|
||||
@@ -2924,7 +2614,6 @@ async def api_search_replace(
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Find and replace across vault files."""
|
||||
import re as re_mod
|
||||
query = body.get("query", "")
|
||||
replacement = body.get("replacement", "")
|
||||
vault_filter = body.get("vault", "all")
|
||||
@@ -2939,71 +2628,32 @@ async def api_search_replace(
|
||||
if not query:
|
||||
raise HTTPException(400, "Query is required")
|
||||
|
||||
search_results = advanced_search(
|
||||
query, vault_filter=vault_filter,
|
||||
case_sensitive=case_sensitive, whole_word=whole_word,
|
||||
regex=regex_mode, include_paths=include_paths, exclude_paths=exclude_paths,
|
||||
limit=500, sort_by="relevance",
|
||||
result = service_replace_in_files(
|
||||
query,
|
||||
replacement,
|
||||
vault=vault_filter,
|
||||
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:
|
||||
return {"matches": matches, "total_matches": total_replacements, "dry_run": True}
|
||||
return {"replaced": matches, "total_replacements": total_replacements}
|
||||
return result
|
||||
|
||||
# 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)
|
||||
|
||||
Reference in New Issue
Block a user