feat: add file and directory management endpoints with context menu support

- Add POST/PATCH/DELETE endpoints for directory operations (create, rename, delete)
- Add POST/PATCH endpoints for file operations (create, rename)
- Implement writable vault check to prevent modifications on read-only vaults
- Update file delete endpoint to broadcast SSE events and update index
- Add Pydantic models for all new request/response schemas
- Integrate context menu support in frontend for files and directories
- Broadcast real
This commit is contained in:
2026-03-30 15:26:44 -04:00
parent d26a40a99d
commit d3b9298dfa
3 changed files with 1182 additions and 0 deletions
+418
View File
@@ -6,6 +6,7 @@ import html as html_mod
import logging
import mimetypes
import secrets
import shutil
import string
import time
from concurrent.futures import ThreadPoolExecutor
@@ -234,6 +235,61 @@ class HealthResponse(BaseModel):
total_files: int
class DirectoryCreateRequest(BaseModel):
"""Request to create a new directory."""
path: str = Field(description="Relative path of the new directory")
class DirectoryCreateResponse(BaseModel):
"""Response after creating a directory."""
success: bool
path: str
class DirectoryRenameRequest(BaseModel):
"""Request to rename a directory."""
path: str = Field(description="Current path of the directory")
new_name: str = Field(description="New name for the directory")
class DirectoryRenameResponse(BaseModel):
"""Response after renaming a directory."""
success: bool
old_path: str
new_path: str
class DirectoryDeleteResponse(BaseModel):
"""Response after deleting a directory."""
success: bool
deleted_count: int
class FileCreateRequest(BaseModel):
"""Request to create a new file."""
path: str = Field(description="Relative path of the new file")
content: str = Field(default="", description="Initial content")
class FileCreateResponse(BaseModel):
"""Response after creating a file."""
success: bool
path: str
class FileRenameRequest(BaseModel):
"""Request to rename a file."""
path: str = Field(description="Current path of the file")
new_name: str = Field(description="New name for the file")
class FileRenameResponse(BaseModel):
"""Response after renaming a file."""
success: bool
old_path: str
new_path: str
# ---------------------------------------------------------------------------
# SSE Manager — Server-Sent Events for real-time notifications
# ---------------------------------------------------------------------------
@@ -523,6 +579,18 @@ def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
return resolved
def _check_vault_writable(vault_root: Path) -> bool:
"""Check if a vault is writable (not mounted read-only).
Args:
vault_root: The vault's root directory (absolute).
Returns:
True if the vault is writable, False otherwise.
"""
return os.access(vault_root, os.W_OK)
# ---------------------------------------------------------------------------
# Markdown rendering helpers (singleton renderer)
# ---------------------------------------------------------------------------
@@ -1028,9 +1096,23 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
# Check if vault is writable
if not _check_vault_writable(vault_root):
raise HTTPException(status_code=403, detail="Vault is read-only")
try:
file_path.unlink()
logger.info(f"File deleted: {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,
})
return {"status": "ok", "vault": vault_name, "path": path}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
@@ -1039,6 +1121,342 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}")
# ---------------------------------------------------------------------------
# Directory management endpoints
# ---------------------------------------------------------------------------
@app.post("/api/directory/{vault_name}", response_model=DirectoryCreateResponse)
async def api_directory_create(
vault_name: str,
body: DirectoryCreateRequest,
current_user=Depends(require_auth),
):
"""Create a new directory in a vault.
Args:
vault_name: Name of the vault.
body: Request body with directory path.
Returns:
DirectoryCreateResponse confirming creation.
"""
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}")
# Broadcast SSE event
await sse_manager.broadcast("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: {str(e)}")
@app.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
async def api_directory_rename(
vault_name: str,
body: DirectoryRenameRequest,
current_user=Depends(require_auth),
):
"""Rename a directory in a vault.
Args:
vault_name: Name of the vault.
body: Request body with current path and new name.
Returns:
DirectoryRenameResponse with old and new paths.
"""
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,
})
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: {str(e)}")
@app.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
async def api_directory_delete(
vault_name: str,
path: str = Query(..., description="Relative path to directory"),
current_user=Depends(require_auth),
):
"""Delete a directory and all its contents from a vault.
Args:
vault_name: Name of the vault.
path: Relative directory path within the vault.
Returns:
DirectoryDeleteResponse with count of deleted files.
"""
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,
})
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: {str(e)}")
# ---------------------------------------------------------------------------
# File creation and rename endpoints
# ---------------------------------------------------------------------------
@app.post("/api/file/{vault_name}", response_model=FileCreateResponse)
async def api_file_create(
vault_name: str,
body: FileCreateRequest,
current_user=Depends(require_auth),
):
"""Create a new file in a vault.
Args:
vault_name: Name of the vault.
body: Request body with file path and initial content.
Returns:
FileCreateResponse confirming creation.
"""
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)
# Create file with initial content
file_path.write_text(body.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,
})
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: {str(e)}")
@app.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
async def api_file_rename(
vault_name: str,
body: FileRenameRequest,
current_user=Depends(require_auth),
):
"""Rename a file in a vault.
Args:
vault_name: Name of the vault.
body: Request body with current path and new name.
Returns:
FileRenameResponse with old and new paths.
"""
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)
# Broadcast SSE event
await sse_manager.broadcast("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: {str(e)}")
@app.get("/api/file/{vault_name}", response_model=FileContentResponse)
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return rendered HTML and metadata for a file.