diff --git a/CHANGELOG.md b/CHANGELOG.md index c51e70a..8c8636d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.27.7**. +> [Unreleased](#unreleased). La dernière version livrée est **2.27.8**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.27.8] — 2026-09-26 + +--- + ## [2.27.7] — 2026-09-26 --- @@ -38,6 +42,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Modifié +- **#85 (T6b) — extraction mutations fichiers/dossiers hors du monolithe `backend/main.py`.** + `PUT .../save|xlsx/save`, `DELETE/POST/PATCH /api/file`, `POST/PATCH/DELETE + /api/directory`, `POST /api/move`, `POST .../batch-upload` sont servis par + le nouveau `backend/routers/files_write.py` (effets de bord inchangés : + audit, index, SSE, webhooks, plugins, historique) ; 15 modèles dans + `schemas.py`. - **#85 (T6a) — extraction lecture fichiers hors du monolithe `backend/main.py`.** `/api/browse/{vault}`, `/api/file/{vault}/raw|download|backlinks` et `GET /api/file/{vault}` (vue rendue tous formats) sont servis par le diff --git a/README.fr.md b/README.fr.md index 43d073f..06514c5 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.27.7-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.8-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.7). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.8). --- -*Projet : ObsiGate | Version : 2.27.7 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.27.8 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 4ee85e8..7cd4569 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.27.7-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.8-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.7). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.8). --- -*Project: ObsiGate | Version: 2.27.7 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.27.8 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 28e21cc..a85fcf2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.27.7 +2.27.8 diff --git a/backend/main.py b/backend/main.py index 699720d..b307975 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,7 +10,6 @@ import string import urllib.request from contextlib import asynccontextmanager from pathlib import Path -from typing import Any import frontmatter import mistune @@ -25,10 +24,7 @@ from backend.collab import authenticate_websocket, collab_manager from backend.history import ( get_bookmarks, record_open, - remove_recent, toggle_bookmark, - update_bookmarks_after_rename, - update_history_after_rename, ) from backend.image_processor import preprocess_images from backend.indexer import ( @@ -83,36 +79,6 @@ from backend.search import ( from backend.semantic_search import init_semantic_index from backend.services.backups import get_backup_dir as service_get_backup_dir from backend.services.errors import ServiceError -from backend.services.mutations import ( - batch_upload_files as service_batch_upload_files, -) -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 ( - edit_xlsx_cells as service_edit_xlsx_cells, -) -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.recent import humanize_mtime, list_recent from backend.services.sanitizer import sanitize_html from backend.services.vaults import ( @@ -143,117 +109,7 @@ class VaultInfo(BaseModel): type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)") -class FileSaveResponse(BaseModel): - """Confirmation after saving a file.""" - status: str = Field(description="Always 'ok'") - vault: str = Field(description="Vault name") - path: str = Field(description="Relative file path within the vault") - size: int = Field(description="Size of saved content in characters") - - -class FileDeleteResponse(BaseModel): - """Confirmation after deleting a file.""" - status: str = Field(description="Always 'ok'") - vault: str = Field(description="Vault name") - path: str = Field(description="Relative file path within the vault") - - -# Search / suggest / graph models : voir backend.schemas (#85 T5) -# --------------------------------------------------------------------------- - -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 = Field(description="Whether creation succeeded") - path: str = Field(description="Path of the created directory") - - -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 = Field(description="Whether rename succeeded") - old_path: str = Field(description="Original directory path") - new_path: str = Field(description="New directory path") - - -class DirectoryDeleteResponse(BaseModel): - """Response after deleting a directory.""" - success: bool = Field(description="Whether deletion succeeded") - deleted_count: int = Field(description="Number of files recursively deleted") - - -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 = Field(description="Whether creation succeeded") - path: str = Field(description="Path of the created file") - - -class BatchUploadFileItem(BaseModel): - """A single file/dir entry in a batch upload request.""" - path: str = Field(description="Relative path of the item within the batch") - content: str | None = Field(default=None, description="Base64 encoded or text content for files") - is_dir: bool = Field(default=False, description="True if entry represents an empty directory") - - -class BatchUploadRequest(BaseModel): - """Request payload for batch file/directory upload.""" - target_dir: str = Field(default="", description="Base directory in vault to upload into (empty for root)") - files: list[BatchUploadFileItem] = Field(description="List of files and directories to upload") - overwrite: bool = Field(default=True, description="Whether to overwrite existing files (creates backups)") - - -class BatchUploadResponse(BaseModel): - """Response from batch file/directory upload.""" - success: bool = Field(description="True if all files uploaded without error") - vault: str = Field(description="Vault name") - target_dir: str = Field(description="Target directory") - uploaded: list[str] = Field(description="List of created/updated file paths") - created_dirs: list[str] = Field(description="List of created directory paths") - errors: list[dict[str, Any]] = Field(default_factory=list, description="List of items that failed") - total_files: int = Field(description="Total uploaded files count") - - -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 = Field(description="Whether rename succeeded") - old_path: str - new_path: str - - -class FileMoveRequest(BaseModel): - """Request to move a file or directory to a different parent directory.""" - source_path: str = Field(description="Current relative path of the file/directory") - destination_dir: str = Field(description="Target directory relative path (empty string for vault root)") - - -class FileMoveResponse(BaseModel): - """Response after moving a file or directory.""" - success: bool = Field(description="Whether move succeeded") - old_path: str = Field(description="Original path") - new_path: str = Field(description="New path after move") - item_type: str = Field(description="Type of item moved: 'file' or 'directory'") - +# Filesystem mutation + search / suggest / graph models : voir backend.schemas (#85 T5, T6b) class BackupEntry(BaseModel): """A single backup version of a file.""" @@ -630,7 +486,7 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000) app.add_middleware(SecurityHeadersMiddleware) # Auth router -from backend.audit import log_file_delete, log_file_save +from backend.audit import log_file_delete from backend.auth.middleware import ( check_vault_access, require_admin, @@ -654,14 +510,13 @@ from backend.bookslm_routes import router as bookslm_router from backend.export import ExportError, export_epub, export_html, export_md_bundle from backend.routers.backups import router as backups_router from backend.routers.files_read import router as files_read_router +from backend.routers.files_write import router as files_write_router from backend.routers.health import router as health_router from backend.routers.search import router as search_router from backend.routers.sharing import router as sharing_router from backend.routers.webhooks import router as webhooks_router from backend.saved_searches import delete_saved, get_saved, save_search -from backend.share import update_shares_after_rename from backend.skills_routes import router as skills_router -from backend.webhooks import dispatch_webhooks app.include_router(auth_router) app.include_router(ai_router) @@ -671,6 +526,7 @@ app.include_router(health_router) # ROADMAP #85 T1 — System / health app.include_router(search_router) # ROADMAP #85 T5 — Search app.include_router(backups_router) # ROADMAP #85 T4 — Backups app.include_router(files_read_router) # ROADMAP #85 T6a — Files read +app.include_router(files_write_router) # ROADMAP #85 T6b — Files write app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing @@ -1271,439 +1127,17 @@ async def api_guide_download( ) -@app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse) -async def api_file_save( - vault_name: str, - path: str = Query(..., description="Relative path to file"), - body: dict = Body(...), - backup: bool = Query(True, description="Create a backup before saving (default true, set false for auto-save)"), - current_user=Depends(require_auth), -): - """Save (overwrite) a file's content. - - Expects a JSON body with a ``content`` key containing the new text. - The path is validated against traversal attacks before writing. - - Args: - vault_name: Name of the vault. - path: Relative file path within the vault. - body: JSON body with ``content`` string. - - Returns: - ``FileSaveResponse`` confirming the write. - """ - if not check_vault_access(vault_name, current_user): - raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - - content = body.get("content", "") - result = service_edit_file(vault_name, path, content, backup=backup) - - # 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": result["vault"], "path": result["path"], "size": result["size"]} - - -@app.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse) -async def api_file_xlsx_save( - vault_name: str, - path: str = Query(..., description="Relative path to the .xlsx file"), - body: dict = Body(..., description='{"sheet": str, "cells": {"A1": value}}'), - current_user=Depends(require_auth), -): - """Apply cell edits to an .xlsx workbook. - - Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new - scalar values, max 500 per request). A backup is created before the - workbook is rewritten. - """ - if not check_vault_access(vault_name, current_user): - raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - - sheet = body.get("sheet") - cells = body.get("cells") - if not isinstance(sheet, str) or not sheet: - raise HTTPException(status_code=400, detail="Feuille manquante") - if not isinstance(cells, dict) or not cells or len(cells) > 500: - raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)") - for ref, value in cells.items(): - if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))): - raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}") - - result = service_edit_xlsx_cells(vault_name, path, sheet, cells) - log_file_save( - current_user["username"], vault_name, path, - sum(len(str(v)) for v in cells.values()), - current_user.get("_request_ip", "unknown"), - ) - return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]} - - -@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse) -async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)): - """Delete a file from the vault. - - The path is validated against traversal attacks before deletion. - - Args: - vault_name: Name of the vault. - path: Relative file path within the vault. - - Returns: - ``FileDeleteResponse`` confirming the deletion. - """ - if not check_vault_access(vault_name, current_user): - raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - - result = service_delete_file(vault_name, path) - - # Audit log - client_ip = current_user.get("_request_ip", "unknown") - log_file_delete(current_user["username"], vault_name, path, client_ip) - - # Update index - await remove_single_file(vault_name, path) - - # Broadcast SSE event - 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": result["vault"], "path": result["path"]} - - # --------------------------------------------------------------------------- -# Directory management endpoints +# File & directory mutations : voir backend.routers.files_write (#85 T6b) # --------------------------------------------------------------------------- -@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}'") - - 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) -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}'") - - 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) -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}'") - - 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} - - # --------------------------------------------------------------------------- -# File creation and rename endpoints +# File creation and rename endpoints : voir backend.routers.files_write (#85 T6b) # --------------------------------------------------------------------------- -@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}'") - - 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.post("/api/vault/{vault_name}/batch-upload", response_model=BatchUploadResponse) -async def api_batch_upload( - vault_name: str, - body: BatchUploadRequest, - current_user=Depends(require_auth), -): - """Upload multiple files and directories (recursively) into a vault. - - Accepts base64 encoded or plain text files with relative directory paths. - Creates missing parent folders safely. - - Args: - vault_name: Target vault name. - body: BatchUploadRequest with target_dir and files list. - - Returns: - BatchUploadResponse with summary of uploaded files and errors. - """ - if not check_vault_access(vault_name, current_user): - raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - - import base64 - - items: list[dict[str, Any]] = [] - for f in body.files: - if f.is_dir: - items.append({"path": f.path, "is_dir": True}) - continue - - raw_bytes = b"" - if f.content is not None: - # Check if content is base64 encoded data URI or raw base64 - content_str = f.content - if content_str.startswith("data:") and ";base64," in content_str: - content_str = content_str.split(";base64,", 1)[1] - try: - raw_bytes = base64.b64decode(content_str) - except Exception: - # Fallback to utf-8 text encoding - raw_bytes = f.content.encode("utf-8") - - items.append({"path": f.path, "content": raw_bytes, "is_dir": False}) - - result = service_batch_upload_files( - vault_name, - body.target_dir, - items, - overwrite=body.overwrite, - ) - - # Update index and SSE notifications for uploaded files - for path in result["uploaded"]: - try: - await update_single_file(vault_name, path) - await sse_manager.broadcast("file_created", { - "vault": vault_name, - "path": path, - }) - await dispatch_webhooks("file_created", {"vault": vault_name, "path": path}) - except Exception as e: - logger.warning(f"Failed to post-process upload of {path}: {e}") - - # SSE notification for tree refresh - if result["uploaded"] or result["created_dirs"]: - await sse_manager.broadcast("tree_updated", { - "vault": vault_name, - "target_dir": result["target_dir"], - }) - - return result - - -@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}'") - - 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) -async def api_file_move( - vault_name: str, - body: FileMoveRequest, - current_user=Depends(require_auth), -): - """Move a file or directory to a different parent directory within the same vault. - - Supports both files and directories. The item keeps its original name; - only the parent directory changes. - - Args: - vault_name: Name of the vault. - body: Request body with source_path and destination_dir. - - Returns: - FileMoveResponse 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}'") - - 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"] - - # Update index - if item_type == "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} +# --------------------------------------------------------------------------- +# File creation and rename endpoints : voir backend.routers.files_write (#85 T6b) +# --------------------------------------------------------------------------- # --------------------------------------------------------------------------- diff --git a/backend/routers/files_write.py b/backend/routers/files_write.py new file mode 100644 index 0000000..49f0132 --- /dev/null +++ b/backend/routers/files_write.py @@ -0,0 +1,506 @@ +"""File & directory mutation endpoints (ROADMAP #85, tranche 6b). + +Handlers déplacés depuis :mod:`backend.main` sans changement de +comportement : mêmes chemins (``PUT/DELETE/PATCH/POST /api/file/*``, +``/api/directory/*``, ``/api/move/*``, ``/api/vault/*/batch-upload``), +mêmes modèles de requête/réponse (déménagés dans :mod:`backend.schemas`), +mêmes dépendances d'authentification et mêmes effets de bord (audit, index +incrémental, SSE, webhooks, plugins, historique). + +La logique métier vit déjà dans :mod:`backend.services.mutations`. +""" + +import logging +from typing import Any + +from fastapi import APIRouter, Body, Depends, HTTPException, Query + +from backend.audit import log_file_delete, log_file_save +from backend.auth.middleware import check_vault_access, require_auth +from backend.history import ( + remove_recent, + update_bookmarks_after_rename, + update_history_after_rename, +) +from backend.indexer import handle_file_move, remove_single_file, update_single_file +from backend.schemas import ( + BatchUploadRequest, + BatchUploadResponse, + DirectoryCreateRequest, + DirectoryCreateResponse, + DirectoryDeleteResponse, + DirectoryRenameRequest, + DirectoryRenameResponse, + FileCreateRequest, + FileCreateResponse, + FileDeleteResponse, + FileMoveRequest, + FileMoveResponse, + FileRenameRequest, + FileRenameResponse, + FileSaveResponse, +) +from backend.services.mutations import ( + batch_upload_files as service_batch_upload_files, +) +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 ( + edit_xlsx_cells as service_edit_xlsx_cells, +) +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.share import update_shares_after_rename +from backend.sse import sse_manager +from backend.webhooks import dispatch_webhooks + +logger = logging.getLogger("obsigate") + +router = APIRouter(tags=["files"]) + + +@router.put("/api/file/{vault_name}/save", response_model=FileSaveResponse) +async def api_file_save( + vault_name: str, + path: str = Query(..., description="Relative path to file"), + body: dict = Body(...), + backup: bool = Query(True, description="Create a backup before saving (default true, set false for auto-save)"), + current_user=Depends(require_auth), +): + """Save (overwrite) a file's content. + + Expects a JSON body with a ``content`` key containing the new text. + The path is validated against traversal attacks before writing. + + Args: + vault_name: Name of the vault. + path: Relative file path within the vault. + body: JSON body with ``content`` string. + + Returns: + ``FileSaveResponse`` confirming the write. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + content = body.get("content", "") + result = service_edit_file(vault_name, path, content, backup=backup) + + # 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": result["vault"], "path": result["path"], "size": result["size"]} + + +@router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse) +async def api_file_xlsx_save( + vault_name: str, + path: str = Query(..., description="Relative path to the .xlsx file"), + body: dict = Body(..., description='{"sheet": str, "cells": {"A1": value}}'), + current_user=Depends(require_auth), +): + """Apply cell edits to an .xlsx workbook. + + Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new + scalar values, max 500 per request). A backup is created before the + workbook is rewritten. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + sheet = body.get("sheet") + cells = body.get("cells") + if not isinstance(sheet, str) or not sheet: + raise HTTPException(status_code=400, detail="Feuille manquante") + if not isinstance(cells, dict) or not cells or len(cells) > 500: + raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)") + for ref, value in cells.items(): + if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))): + raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}") + + result = service_edit_xlsx_cells(vault_name, path, sheet, cells) + log_file_save( + current_user["username"], vault_name, path, + sum(len(str(v)) for v in cells.values()), + current_user.get("_request_ip", "unknown"), + ) + return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]} + + +@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse) +async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)): + """Delete a file from the vault. + + The path is validated against traversal attacks before deletion. + + Args: + vault_name: Name of the vault. + path: Relative file path within the vault. + + Returns: + ``FileDeleteResponse`` confirming the deletion. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + result = service_delete_file(vault_name, path) + + # Audit log + client_ip = current_user.get("_request_ip", "unknown") + log_file_delete(current_user["username"], vault_name, path, client_ip) + + # Update index + await remove_single_file(vault_name, path) + + # Broadcast SSE event + 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": result["vault"], "path": result["path"]} + + +@router.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}'") + + 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"]} + + +@router.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}'") + + 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} + + +@router.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}'") + + 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} + + +@router.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}'") + + 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"]} + + +@router.post("/api/vault/{vault_name}/batch-upload", response_model=BatchUploadResponse) +async def api_batch_upload( + vault_name: str, + body: BatchUploadRequest, + current_user=Depends(require_auth), +): + """Upload multiple files and directories (recursively) into a vault. + + Accepts base64 encoded or plain text files with relative directory paths. + Creates missing parent folders safely. + + Args: + vault_name: Target vault name. + body: BatchUploadRequest with target_dir and files list. + + Returns: + BatchUploadResponse with summary of uploaded files and errors. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + import base64 + + items: list[dict[str, Any]] = [] + for f in body.files: + if f.is_dir: + items.append({"path": f.path, "is_dir": True}) + continue + + raw_bytes = b"" + if f.content is not None: + # Check if content is base64 encoded data URI or raw base64 + content_str = f.content + if content_str.startswith("data:") and ";base64," in content_str: + content_str = content_str.split(";base64,", 1)[1] + try: + raw_bytes = base64.b64decode(content_str) + except Exception: + # Fallback to utf-8 text encoding + raw_bytes = f.content.encode("utf-8") + + items.append({"path": f.path, "content": raw_bytes, "is_dir": False}) + + result = service_batch_upload_files( + vault_name, + body.target_dir, + items, + overwrite=body.overwrite, + ) + + # Update index and SSE notifications for uploaded files + for path in result["uploaded"]: + try: + await update_single_file(vault_name, path) + await sse_manager.broadcast("file_created", { + "vault": vault_name, + "path": path, + }) + await dispatch_webhooks("file_created", {"vault": vault_name, "path": path}) + except Exception as e: + logger.warning(f"Failed to post-process upload of {path}: {e}") + + # SSE notification for tree refresh + if result["uploaded"] or result["created_dirs"]: + await sse_manager.broadcast("tree_updated", { + "vault": vault_name, + "target_dir": result["target_dir"], + }) + + return result + + +@router.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}'") + + 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} + + +@router.post("/api/move/{vault_name}", response_model=FileMoveResponse) +async def api_file_move( + vault_name: str, + body: FileMoveRequest, + current_user=Depends(require_auth), +): + """Move a file or directory to a different parent directory within the same vault. + + Supports both files and directories. The item keeps its original name; + only the parent directory changes. + + Args: + vault_name: Name of the vault. + body: Request body with source_path and destination_dir. + + Returns: + FileMoveResponse 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}'") + + 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"] + + # Update index + if item_type == "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} diff --git a/backend/schemas.py b/backend/schemas.py index 276704e..9a170d0 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -281,6 +281,136 @@ class FileRawResponse(BaseModel): raw: str = Field(description="Raw file content as text") +# --------------------------------------------------------------------------- +# Files — mutations (#85 — extrait de backend.main, inchangé) +# --------------------------------------------------------------------------- + + +class FileSaveResponse(BaseModel): + """Confirmation after saving a file.""" + + status: str = Field(description="Always 'ok'") + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path within the vault") + size: int = Field(description="Size of saved content in characters") + + +class FileDeleteResponse(BaseModel): + """Confirmation after deleting a file.""" + + status: str = Field(description="Always 'ok'") + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path within the vault") + + +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 = Field(description="Whether creation succeeded") + path: str = Field(description="Path of the created directory") + + +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 = Field(description="Whether rename succeeded") + old_path: str = Field(description="Original directory path") + new_path: str = Field(description="New directory path") + + +class DirectoryDeleteResponse(BaseModel): + """Response after deleting a directory.""" + + success: bool = Field(description="Whether deletion succeeded") + deleted_count: int = Field(description="Number of files recursively deleted") + + +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 = Field(description="Whether creation succeeded") + path: str = Field(description="Path of the created file") + + +class BatchUploadFileItem(BaseModel): + """A single file/dir entry in a batch upload request.""" + + path: str = Field(description="Relative path of the item within the batch") + content: str | None = Field(default=None, description="Base64 encoded or text content for files") + is_dir: bool = Field(default=False, description="True if entry represents an empty directory") + + +class BatchUploadRequest(BaseModel): + """Request payload for batch file/directory upload.""" + + target_dir: str = Field(default="", description="Base directory in vault to upload into (empty for root)") + files: list[BatchUploadFileItem] = Field(description="List of files and directories to upload") + overwrite: bool = Field(default=True, description="Whether to overwrite existing files (creates backups)") + + +class BatchUploadResponse(BaseModel): + """Response from batch file/directory upload.""" + + success: bool = Field(description="True if all files uploaded without error") + vault: str = Field(description="Vault name") + target_dir: str = Field(description="Target directory") + uploaded: list[str] = Field(description="List of created/updated file paths") + created_dirs: list[str] = Field(description="List of created directory paths") + errors: list[dict[str, Any]] = Field(default_factory=list, description="List of items that failed") + total_files: int = Field(description="Total uploaded files count") + + +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 = Field(description="Whether rename succeeded") + old_path: str + new_path: str + + +class FileMoveRequest(BaseModel): + """Request to move a file or directory to a different parent directory.""" + + source_path: str = Field(description="Current relative path of the file/directory") + destination_dir: str = Field(description="Target directory relative path (empty string for vault root)") + + +class FileMoveResponse(BaseModel): + """Response after moving a file or directory.""" + + success: bool = Field(description="Whether move succeeded") + old_path: str = Field(description="Original path") + new_path: str = Field(description="New path after move") + item_type: str = Field(description="Type of item moved: 'file' or 'directory'") + + # --------------------------------------------------------------------------- # Search / suggest / graph (#85 — extrait de backend.main, inchangé) # --------------------------------------------------------------------------- diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index dc2415b..c74c35b 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.27.7" +version = "2.27.8" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index f13f4df..37c33e6 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.27.7" +version = "2.27.8" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 5f1f3e7..de9b44d 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.27.7", + "version": "2.27.8", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b10183e..0574e03 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.27.7 | **Dernière mise à jour :** 2026-09-26 +> **Version :** 2.27.8 | **Dernière mise à jour :** 2026-09-26 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -67,10 +67,10 @@ - **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend - **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).** -- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`). +- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`). **T6b livrée (v2.27.8) :** mutations fichiers/dossiers (save, xlsx/save, delete, create, rename, move, directories ×3, batch-upload → `backend/routers/files_write.py`, 15 modèles → `schemas.py`). - **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe. - **Sous-tâches :** - - [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer) + - [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`), `files-write` ✅ (T6b, `backend/routers/files_write.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer) - [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés - [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process) - [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test) diff --git a/package.json b/package.json index 9f286de..fd79d33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.27.7", + "version": "2.27.8", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": {