diff --git a/backend/main.py b/backend/main.py index 8347a51..c4a37fa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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. diff --git a/frontend/app.js b/frontend/app.js index 4dc61b6..ecacf7a 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2272,6 +2272,12 @@ safeCreateIcons(); } }); + + dirItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const isReadonly = false; + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly); + }); } else { const fileIconName = getFileIcon(item.name); const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; @@ -2281,6 +2287,13 @@ openFile(vaultName, item.path); closeMobileSidebar(); }); + + fileItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const isReadonly = false; + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly); + }); + fragment.appendChild(fileItem); } }); @@ -5486,6 +5499,485 @@ panel.innerHTML = html; } + // --------------------------------------------------------------------------- + // Context Menu Manager + // --------------------------------------------------------------------------- + + const ContextMenuManager = { + _menu: null, + _targetElement: null, + _targetVault: null, + _targetPath: null, + _targetType: null, + + init() { + this._menu = document.createElement('div'); + this._menu.className = 'context-menu'; + this._menu.id = 'context-menu'; + document.body.appendChild(this._menu); + + document.addEventListener('click', () => this.hide()); + document.addEventListener('contextmenu', (e) => { + if (!e.target.closest('.tree-item')) { + this.hide(); + } + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') this.hide(); + }); + + document.addEventListener('scroll', () => this.hide(), true); + }, + + show(x, y, vault, path, type, isReadonly) { + this._targetVault = vault; + this._targetPath = path; + this._targetType = type; + + this._menu.innerHTML = ''; + + if (type === 'directory') { + this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly); + this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly); + this._addSeparator(); + this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); + this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly); + } else if (type === 'file') { + this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly); + this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly); + } + + this._menu.classList.add('active'); + + const rect = this._menu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let finalX = x; + let finalY = y; + + if (x + rect.width > viewportWidth) { + finalX = viewportWidth - rect.width - 10; + } + + if (y + rect.height > viewportHeight) { + finalY = viewportHeight - rect.height - 10; + } + + this._menu.style.left = `${finalX}px`; + this._menu.style.top = `${finalY}px`; + + safeCreateIcons(); + }, + + hide() { + if (this._menu) { + this._menu.classList.remove('active'); + } + }, + + _addItem(icon, label, callback, disabled) { + const item = document.createElement('div'); + item.className = 'context-menu-item' + (disabled ? ' disabled' : ''); + item.innerHTML = ` + + ${label} + `; + + if (!disabled) { + item.addEventListener('click', (e) => { + e.stopPropagation(); + this.hide(); + callback(); + }); + } else { + item.title = 'Vault en lecture seule'; + } + + this._menu.appendChild(item); + }, + + _addSeparator() { + const sep = document.createElement('div'); + sep.className = 'context-menu-separator'; + this._menu.appendChild(sep); + }, + + _createDirectory() { + FileOperations.showCreateDirectoryModal(this._targetVault, this._targetPath); + }, + + _createFile() { + FileOperations.showCreateFileModal(this._targetVault, this._targetPath); + }, + + _renameItem() { + FileOperations.startInlineRename(this._targetVault, this._targetPath, this._targetType); + }, + + _deleteDirectory() { + FileOperations.confirmDeleteDirectory(this._targetVault, this._targetPath); + }, + + _deleteFile() { + FileOperations.confirmDeleteFile(this._targetVault, this._targetPath); + } + }; + + // --------------------------------------------------------------------------- + // File Operations Manager + // --------------------------------------------------------------------------- + + const FileOperations = { + showCreateDirectoryModal(vault, parentPath) { + const overlay = this._createModalOverlay(); + const modal = document.createElement('div'); + modal.className = 'obsigate-modal'; + modal.innerHTML = ` +