From 62023acd4d0463d1b6375b90930f53c7a4f22de7 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 11 Sep 2026 01:40:21 -0400 Subject: [PATCH] feat(api,ai): #72 OpenAPI 3.1 enrichie + fiabilisation de l'outil AI UI API (#72): - backend/openapi_docs.py: 18 tags documentes, assignation auto par prefixe, securite bearerAuth/cookieAuth, erreurs 401/403/404/422/500, exemples, page /api autonome (liens /docs, /redoc, /openapi.json) - backend/schemas.py: response_model Pydantic pour ~35 endpoints sans modele - main.py: FastAPI enrichi + override app.openapi; routes /api et /api/ - ai_routes/bookslm_routes: response_model + doc SSE - frontend: entree 'API' du menu (i18n FR/EN) - tests/test_openapi.py (58 tests) AI UI: - BooksLM: requetes authentifiees (AuthManager), parsing SSE {token}/error, message utilisateur correct (plus le placeholder vide), barre de contexte - AI Editor: Ctrl/Cmd+J lie une seule fois, libelles i18n, modale de reecriture accessible a la place de window.prompt() - tests/frontend/ai.test.mjs (7 tests) + CI --- .gitea/workflows/ci.yml | 4 +- CHANGELOG.md | 34 +++ backend/ai_routes.py | 3 +- backend/bookslm.py | 2 + backend/bookslm_routes.py | 9 +- backend/main.py | 222 +++++++++++----- backend/openapi_docs.py | 452 ++++++++++++++++++++++++++++++++ backend/schemas.py | 512 +++++++++++++++++++++++++++++++++++++ docs/ROADMAP.md | 36 ++- frontend/index.html | 24 ++ frontend/js/ai.js | 143 ++++++++--- frontend/js/bookslm.js | 123 +++++---- frontend/js/ui.js | 9 + frontend/locales/en.json | 9 + frontend/locales/fr.json | 9 + frontend/style.css | 82 ++++++ tests/frontend/ai.test.mjs | 239 +++++++++++++++++ tests/test_openapi.py | 243 ++++++++++++++++++ 18 files changed, 2001 insertions(+), 154 deletions(-) create mode 100644 backend/openapi_docs.py create mode 100644 backend/schemas.py create mode 100644 tests/frontend/ai.test.mjs create mode 100644 tests/test_openapi.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 55b7015..d6d5e97 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -38,19 +38,21 @@ jobs: - name: Frontend unit tests run: node tests/frontend/unit.test.mjs - - name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins) + - name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI) run: | cd tests/frontend if [ -d node_modules ]; then node pane-manager.test.mjs node excalidraw-viewer.test.mjs node plugins.test.mjs + node ai.test.mjs else echo "tests/frontend/node_modules missing — installing jsdom" npm install --no-audit --no-fund --silent node pane-manager.test.mjs node excalidraw-viewer.test.mjs node plugins.test.mjs + node ai.test.mjs fi # ── Tests ───────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d69de0..dd82565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,26 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Ajouté +- **#72 API publique documentée — OpenAPI 3.1 enrichie** — documentation interactive complète + de l'API REST, générée automatiquement et enrichie. + - Backend `backend/openapi_docs.py` : métadonnées de tags (18 catégories : System, Auth, Files, + PDF, Vaults, Search, Bookmarks, Backups, Export, AI, BooksLM, Sharing, Webhooks, Conflicts, + Admin, Plugins, Push, Frontend), assignation automatique des tags par préfixe de route + (normalisation des tags des routeurs), schémas de sécurité (`bearerAuth`, `cookieAuth`), + exemples de requête/réponse sur les endpoints clés, réponses d'erreur documentées + (401/403/404/422/500), serveur et `externalDocs`. + - `backend/schemas.py` : nouveaux `response_model` Pydantic pour les endpoints qui n'en avaient + pas (recent, bookmarks, saved searches, backups, diff/restore/backlinks, pdf/info, replace, + vaults, attachments, settings, config, AI keys/models, diagnostics, dashboard, webhooks, + shares, conflicts, BooksLM context, AI status). + - `backend/main.py` : `FastAPI(...)` enrichi (description Markdown, contact, licence, tags) et + override `app.openapi` pour post-traiter le schéma ; page de documentation `/api` (HTML + autonome listant les endpoints groupés par catégorie, liens vers `/docs`, `/redoc`, + `/openapi.json`). + - Frontend : entrée « API » dans le menu d'options (i18n FR/EN) ouvrant Swagger UI. + - Tests : `tests/test_openapi.py` (58 tests — version 3.1, tags, sécurité, erreurs, exemples, + schémas de réponse, pages `/api`, `/docs`, `/redoc`, `/openapi.json`). + - **#61 Plugins système au complet** — système de plugins permettant d'étendre ObsiGate (renderers personnalisés, filtres de recherche, actions d'éditeur), sandboxé pour la sécurité. - Backend `backend/plugins.py` : validation du manifest `plugin.json` (name regex lowercase, @@ -46,6 +66,20 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Corrigé +- **Outil AI de l'UI — fiabilisation** : + - **BooksLM (chat IA par répertoire)** : les requêtes `context`/`chat` n'envoyaient pas le + jeton d'authentification (échec 401 quand l'auth est activée) et la réponse SSE était + mal parsée (le backend émet `{token: ...}`, le front lisait `data.content`) → les réponses + n'apparaissaient jamais. Le message utilisateur envoyé était le placeholder assistant vide + au lieu du texte saisi. Corrigé : requêtes authentifiées via `AuthManager` (retry 401), + parsing SSE `event:`/`data:` avec support `token`/`error`, historique construit avant + l'ajout du message courant, barre de progression basée sur `max_total_chars`. + - **AI Editor** : le raccourci `Ctrl/Cmd+J` (complétion inline) était re-lié à chaque + ouverture de l'éditeur (N listeners → N requêtes) ; un seul listener global est désormais + posé. Les libellés de la barre d'outils AI étaient codés en dur (FR/EN mélangés) → i18n + complet. La réécriture personnalisée utilisait `window.prompt()` → remplacée par une modale + accessible (Échap annule, Ctrl/Cmd+Entrée valide). + - **Recherche cassée (régression plugins)** : `onSearchFilter()` était appelée avec 2 arguments au lieu de 3 (`frontend/js/search.js`), renvoyait `undefined`, faisait échouer le rendu des résultats et basculait silencieusement sur la recherche hors-ligne (0 résultat). Corrigé → diff --git a/backend/ai_routes.py b/backend/ai_routes.py index 2225568..040c600 100644 --- a/backend/ai_routes.py +++ b/backend/ai_routes.py @@ -25,12 +25,13 @@ from backend.ai import ( ai_summarize, ai_translate, ) +from backend.schemas import AIStatusResponse logger = logging.getLogger("obsigate.ai_routes") router = APIRouter(prefix="/api/ai", tags=["AI"]) -@router.get("/status") +@router.get("/status", response_model=AIStatusResponse) async def api_status(): """Check if AI is configured and which providers are available. diff --git a/backend/bookslm.py b/backend/bookslm.py index 145d428..2d7ee80 100644 --- a/backend/bookslm.py +++ b/backend/bookslm.py @@ -165,6 +165,8 @@ def collect_directory_context(vault_path: Path, directory: str) -> dict[str, Any "total_chars": total_chars, "file_count": len(collected), "directory_tree": dir_tree, + "max_total_chars": BOOKSLM_MAX_TOTAL_CHARS, + "max_files": BOOKSLM_MAX_FILES, } # Store in cache diff --git a/backend/bookslm_routes.py b/backend/bookslm_routes.py index 48699a7..a3b9a1c 100644 --- a/backend/bookslm_routes.py +++ b/backend/bookslm_routes.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, Field from backend.auth.middleware import check_vault_access, require_auth from backend.bookslm import build_system_prompt, collect_directory_context from backend.indexer import get_vault_data +from backend.schemas import BooksLMContextResponse logger = logging.getLogger("obsigate.bookslm_routes") router = APIRouter(prefix="/api/ai/bookslm", tags=["BooksLM"]) @@ -46,7 +47,7 @@ class BooksLMChatRequest(BaseModel): # ── Endpoints ── -@router.post("/context") +@router.post("/context", response_model=BooksLMContextResponse) async def api_bookslm_context( req: BooksLMContextRequest, current_user=Depends(require_auth), @@ -67,7 +68,11 @@ async def api_bookslm_context( return context -@router.post("/chat") +@router.post( + "/chat", + response_class=StreamingResponse, + responses={200: {"content": {"text/event-stream": {}}, "description": "SSE token stream"}}, +) async def api_bookslm_chat( req: BooksLMChatRequest, current_user=Depends(require_auth), diff --git a/backend/main.py b/backend/main.py index b380ca3..93e72b7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -56,6 +56,47 @@ from backend.indexer import ( remove_vault_from_index, update_single_file, ) +from backend.openapi_docs import ( + API_DESCRIPTION, + TAGS_METADATA, + enrich_openapi_schema, + render_api_landing, +) +from backend.schemas import ( + AIKeyDeleteResponse, + AIKeysResponse, + AIModelsResponse, + AITestResponse, + AllVaultSettingsResponse, + AppConfigResponse, + AttachmentRescanResponse, + AttachmentStatsResponse, + BacklinksResponse, + BackupContentResponse, + BackupsAutoResponse, + BackupsCompressResponse, + BackupsDeletedResponse, + BackupsListResponse, + BackupsResponse, + BookmarksResponse, + BookmarkToggleResponse, + ConflictResolveResponse, + ConflictsResponse, + DashboardResponse, + DiagnosticsResponse, + PdfInfoResponse, + RecentResponse, + ReplaceResponse, + SavedSearch, + ShareModel, + StatusResponse, + VaultActionResponse, + VaultFilesResponse, + VaultSettingsResponse, + VaultsStatusResponse, + VaultStatsResponse, + WebhookModel, +) from backend.search import ( advanced_search, get_all_tags, @@ -668,7 +709,33 @@ async def lifespan(app: FastAPI): from backend.version import get_git_commit, get_git_describe, get_version -app = FastAPI(title="ObsiGate", version=get_version(), lifespan=lifespan) +app = FastAPI( + title="ObsiGate API", + version=get_version(), + lifespan=lifespan, + description=API_DESCRIPTION.strip(), + openapi_tags=TAGS_METADATA, + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + contact={"name": "ObsiGate", "url": "https://git.dracodev.net/Projets/ObsiGate"}, + license_info={"name": "MIT"}, +) + +# Enrich the auto-generated OpenAPI 3.1 schema (#72): tags per category, +# examples, security schemes and documented error responses. +_original_openapi = app.openapi + + +def _custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + schema = _original_openapi() + app.openapi_schema = enrich_openapi_schema(schema) + return app.openapi_schema + + +app.openapi = _custom_openapi # GZip compression — reduces bandwidth by ~70% for text responses # Custom wrapper: skip compression for SSE streams (/api/events) @@ -766,6 +833,17 @@ except ImportError as e: FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" +# --------------------------------------------------------------------------- +# API documentation landing page (#72) +# --------------------------------------------------------------------------- + +@app.get("/api", include_in_schema=False, response_class=HTMLResponse) +@app.get("/api/", include_in_schema=False, response_class=HTMLResponse) +async def api_docs_landing(): + """Human-friendly API documentation landing page (links to /docs, /redoc).""" + return HTMLResponse(render_api_landing(get_version())) + + # --------------------------------------------------------------------------- # Path safety helper # --------------------------------------------------------------------------- @@ -1220,7 +1298,7 @@ def humanize_mtime(mtime: float) -> str: return datetime.fromtimestamp(mtime).strftime("%d %b %Y") -@app.get("/api/recent") +@app.get("/api/recent", response_model=RecentResponse) async def api_recent(limit: int | None = Query(None), vault: str | None = Query(None), mode: str | None = Query("opened"), current_user=Depends(require_auth)): config = _load_config() actual_limit = limit if limit is not None else config.get("recent_files_limit", 20) @@ -1314,7 +1392,7 @@ async def api_recent(limit: int | None = Query(None), vault: str | None = Query( } -@app.get("/api/bookmarks") +@app.get("/api/bookmarks", response_model=BookmarksResponse) async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)): username = current_user.get("username") user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) @@ -1362,7 +1440,7 @@ class BookmarkToggleRequest(BaseModel): path: str title: str | None = None -@app.post("/api/bookmarks/toggle") +@app.post("/api/bookmarks/toggle", response_model=BookmarkToggleResponse) async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(require_auth)): username = current_user.get("username") if not username: @@ -1396,7 +1474,7 @@ async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(r return {"bookmarked": is_now_bookmarked} -@app.get("/api/saved-searches") +@app.get("/api/saved-searches", response_model=list[SavedSearch]) async def api_saved_searches(current_user=Depends(require_auth)): username = current_user.get("username") if not username: @@ -1404,7 +1482,7 @@ async def api_saved_searches(current_user=Depends(require_auth)): return get_saved(username) -@app.post("/api/saved-searches") +@app.post("/api/saved-searches", response_model=SavedSearch) async def api_save_search(body: dict = Body(...), current_user=Depends(require_auth)): username = current_user.get("username") if not username: @@ -1412,7 +1490,7 @@ async def api_save_search(body: dict = Body(...), current_user=Depends(require_a return save_search(username, body) -@app.delete("/api/saved-searches/{search_id}") +@app.delete("/api/saved-searches/{search_id}", response_model=StatusResponse) async def api_delete_saved_search(search_id: str, current_user=Depends(require_auth)): username = current_user.get("username") if not username: @@ -1550,7 +1628,7 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela return {"vault": vault_name, "path": path, "raw": raw} -@app.get("/api/file/{vault_name}/download") +@app.get("/api/file/{vault_name}/download", response_class=FileResponse) async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)): """Download a file as an attachment. @@ -1583,7 +1661,11 @@ async def api_file_download(vault_name: str, path: str = Query(..., description= ) -@app.get("/api/file/{vault_name}/pdf") +@app.get( + "/api/file/{vault_name}/pdf", + response_class=Response, + responses={200: {"content": {"application/pdf": {}}, "description": "PDF document"}}, +) async def api_file_pdf(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)): """Download a markdown file as PDF.""" if generate_pdf is None: @@ -1631,7 +1713,11 @@ def _resolve_export_target(vault_name: str, path: str, current_user: dict) -> tu return vault_root, target -@app.get("/api/export/html") +@app.get( + "/api/export/html", + response_class=Response, + responses={200: {"content": {"text/html": {}}, "description": "Standalone HTML file"}}, +) async def api_export_html( vault: str = Query(..., description="Vault name"), path: str = Query(..., description="Relative path to file"), @@ -1652,7 +1738,11 @@ async def api_export_html( ) -@app.get("/api/export/md-bundle") +@app.get( + "/api/export/md-bundle", + response_class=Response, + responses={200: {"content": {"application/zip": {}}, "description": "Markdown ZIP bundle"}}, +) async def api_export_md_bundle( vault: str = Query(..., description="Vault name"), path: str = Query(..., description="Relative path to directory or file"), @@ -1672,7 +1762,11 @@ async def api_export_md_bundle( ) -@app.get("/api/export/epub") +@app.get( + "/api/export/epub", + response_class=Response, + responses={200: {"content": {"application/epub+zip": {}}, "description": "ePub document"}}, +) async def api_export_epub( vault: str = Query(..., description="Vault name"), path: str = Query(..., description="Relative path to file"), @@ -2379,7 +2473,7 @@ def _list_backup_files(vault_name: str, relative_path: str) -> list[dict]: return backups -@app.get("/api/file/{vault_name}/backups") +@app.get("/api/file/{vault_name}/backups", response_model=BackupsResponse) async def api_file_backups( vault_name: str, path: str = Query(..., description="Relative path to file"), @@ -2416,7 +2510,7 @@ async def api_file_backups( return {"vault": vault_name, "path": path, "backups": backups} -@app.get("/api/file/{vault_name}/diff") +@app.get("/api/file/{vault_name}/diff", response_model=DiffResponse) async def api_file_diff( vault_name: str, path: str = Query(..., description="Relative path to file"), @@ -2504,7 +2598,7 @@ async def api_file_diff( raise HTTPException(status_code=500, detail=f"Erreur lors de la génération du diff: {e!s}") -@app.post("/api/file/{vault_name}/restore") +@app.post("/api/file/{vault_name}/restore", response_model=RestoreResponse) async def api_file_restore( vault_name: str, path: str = Query(..., description="Relative path to file"), @@ -2586,7 +2680,7 @@ async def api_file_restore( raise HTTPException(status_code=500, detail=f"Error restoring file: {e!s}") -@app.get("/api/file/{vault_name}/backlinks") +@app.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse) async def api_file_backlinks( vault_name: str, path: str = Query(..., description="Relative path to file"), @@ -2881,7 +2975,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative } -@app.get("/api/file/{vault_name}/pdf/stream") +@app.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse) async def api_pdf_stream( request: Request, vault_name: str, @@ -2965,7 +3059,7 @@ async def api_pdf_stream( "Content-Disposition": _content_disposition("inline", file_path.name)}) -@app.get("/api/file/{vault_name}/pdf/info") +@app.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse) async def api_pdf_info( vault_name: str, path: str = Query(..., description="Relative path to PDF file"), @@ -3144,7 +3238,7 @@ async def api_advanced_search( ) -@app.post("/api/search/replace") +@app.post("/api/search/replace", response_model=ReplaceResponse) async def api_search_replace( body: dict = Body(...), current_user=Depends(require_auth), @@ -3491,7 +3585,7 @@ def _add_wikilink_edges(nodes: list, edges: list, node_ids: set, vault_name: str break -@app.get("/api/index/reload/{vault_name}") +@app.get("/api/index/reload/{vault_name}", response_model=VaultStatsResponse) async def api_reload_vault(vault_name: str, current_user=Depends(require_admin)): """Force a re-index of a single vault. @@ -3517,7 +3611,11 @@ async def api_reload_vault(vault_name: str, current_user=Depends(require_admin)) # SSE endpoint — Server-Sent Events stream # --------------------------------------------------------------------------- -@app.get("/api/events") +@app.get( + "/api/events", + response_class=StreamingResponse, + responses={200: {"content": {"text/event-stream": {}}, "description": "Server-Sent Events stream"}}, +) async def api_events(current_user=Depends(require_auth)): """SSE stream for real-time index update notifications. @@ -3560,7 +3658,7 @@ async def api_events(current_user=Depends(require_auth)): # Dynamic vault management endpoints # --------------------------------------------------------------------------- -@app.post("/api/vaults/add") +@app.post("/api/vaults/add", response_model=VaultStatsResponse) async def api_add_vault(body: dict = Body(...), current_user=Depends(require_admin)): """Add a new vault dynamically without restarting. @@ -3590,7 +3688,7 @@ async def api_add_vault(body: dict = Body(...), current_user=Depends(require_adm return {"status": "ok", "vault": name, "stats": stats} -@app.delete("/api/vaults/{vault_name}") +@app.delete("/api/vaults/{vault_name}", response_model=VaultActionResponse) async def api_remove_vault(vault_name: str, current_user=Depends(require_admin)): """Remove a vault from the index and stop watching it. @@ -3609,7 +3707,7 @@ async def api_remove_vault(vault_name: str, current_user=Depends(require_admin)) return {"status": "ok", "vault": vault_name} -@app.get("/api/vaults/status") +@app.get("/api/vaults/status", response_model=VaultsStatusResponse) async def api_vaults_status(current_user=Depends(require_auth)): """Detailed status of all vaults including watcher state. @@ -3631,7 +3729,11 @@ async def api_vaults_status(current_user=Depends(require_auth)): } -@app.get("/api/image/{vault_name}") +@app.get( + "/api/image/{vault_name}", + response_class=Response, + responses={200: {"content": {"application/octet-stream": {}}, "description": "Image bytes"}}, +) async def api_image(vault_name: str, path: str = Query(..., description="Relative path to image"), current_user=Depends(require_auth)): """Serve an image file with proper MIME type. @@ -3671,7 +3773,7 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}") -@app.post("/api/attachments/rescan/{vault_name}") +@app.post("/api/attachments/rescan/{vault_name}", response_model=AttachmentRescanResponse) async def api_rescan_attachments(vault_name: str, current_user=Depends(require_admin)): """Rescan attachments for a specific vault. @@ -3692,7 +3794,7 @@ async def api_rescan_attachments(vault_name: str, current_user=Depends(require_a return {"status": "ok", "vault": vault_name, "attachment_count": count} -@app.get("/api/attachments/stats") +@app.get("/api/attachments/stats", response_model=AttachmentStatsResponse) async def api_attachment_stats(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)): """Get attachment statistics for vaults. @@ -3710,7 +3812,7 @@ async def api_attachment_stats(vault: str | None = Query(None, description="Vaul # Vault Settings API — Display preferences # --------------------------------------------------------------------------- -@app.get("/api/vaults/{vault_name}/settings") +@app.get("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse) async def api_get_vault_settings(vault_name: str, current_user=Depends(require_auth)): """Get UI display settings for a specific vault. @@ -3735,7 +3837,7 @@ async def api_get_vault_settings(vault_name: str, current_user=Depends(require_a return settings -@app.post("/api/vaults/{vault_name}/settings") +@app.post("/api/vaults/{vault_name}/settings", response_model=VaultSettingsResponse) async def api_update_vault_settings(vault_name: str, body: dict = Body(...), current_user=Depends(require_admin)): """Update UI display settings for a specific vault. @@ -3778,7 +3880,7 @@ async def api_update_vault_settings(vault_name: str, body: dict = Body(...), cur return updated -@app.get("/api/vault/{vault_name}/files") +@app.get("/api/vault/{vault_name}/files", response_model=VaultFilesResponse) async def api_vault_recent_files( vault_name: str, dir: str = Query("", description="Directory path within the vault (empty = root)"), @@ -3889,7 +3991,7 @@ async def api_vault_recent_files( } -@app.get("/api/vaults/settings/all") +@app.get("/api/vaults/settings/all", response_model=AllVaultSettingsResponse) async def api_get_all_vault_settings(current_user=Depends(require_auth)): """Get UI display settings for all vaults. @@ -3914,7 +4016,7 @@ async def api_get_all_vault_settings(current_user=Depends(require_auth)): # Backup Management API # --------------------------------------------------------------------------- -@app.get("/api/backups") +@app.get("/api/backups", response_model=BackupsListResponse) async def api_backups_list( vault: str | None = Query(None, description="Filter by vault name"), current_user=Depends(require_auth), @@ -3966,7 +4068,7 @@ async def api_backups_list( raise HTTPException(status_code=500, detail=f"Erreur listing backups: {e!s}") -@app.post("/api/backups/delete") +@app.post("/api/backups/delete", response_model=BackupsDeletedResponse) async def api_backups_delete( body: dict = Body(...), current_user=Depends(require_auth), @@ -3992,7 +4094,7 @@ async def api_backups_delete( return {"deleted": deleted} -@app.post("/api/backups/purge") +@app.post("/api/backups/purge", response_model=BackupsDeletedResponse) async def api_backups_purge( body: dict = Body(...), current_user=Depends(require_auth), @@ -4042,7 +4144,7 @@ async def api_backups_purge( -@app.get("/api/backups/content") +@app.get("/api/backups/content", response_model=BackupContentResponse) async def api_backups_content( path: str = Query(..., description="Full path to backup file"), current_user=Depends(require_auth), @@ -4065,7 +4167,7 @@ async def api_backups_content( raise HTTPException(status_code=500, detail=str(e)) -@app.post("/api/backups/compress") +@app.post("/api/backups/compress", response_model=BackupsCompressResponse) async def api_backups_compress( body: dict = Body(...), current_user=Depends(require_auth), @@ -4121,7 +4223,7 @@ async def api_backups_compress( return {"compressed": compressed, "saved_bytes": saved_bytes, "dry_run": dry_run} -@app.post("/api/backups/auto") +@app.post("/api/backups/auto", response_model=BackupsAutoResponse) async def api_backups_auto( body: dict = Body(...), current_user=Depends(require_auth), @@ -4211,13 +4313,13 @@ def _save_config(config: dict) -> None: raise HTTPException(status_code=500, detail=f"Failed to save config: {e}") -@app.get("/api/config") +@app.get("/api/config", response_model=AppConfigResponse) async def api_get_config(current_user=Depends(require_auth)): """Return current configuration with defaults for missing keys.""" return _load_config() -@app.post("/api/config") +@app.post("/api/config", response_model=AppConfigResponse) async def api_set_config(body: dict = Body(...), current_user=Depends(require_admin)): """Update configuration. Only known keys are accepted. @@ -4257,7 +4359,7 @@ def _write_ai_keys(data: dict): tmp.write_text(_json.dumps(data, indent=2), encoding="utf-8") tmp.replace(AI_KEYS_FILE) -@app.get("/api/config/ai-keys") +@app.get("/api/config/ai-keys", response_model=AIKeysResponse) async def api_get_ai_keys(current_user=Depends(require_admin)): """Return stored AI keys (values masked).""" keys = _read_ai_keys() @@ -4270,7 +4372,7 @@ async def api_get_ai_keys(current_user=Depends(require_admin)): masked[k] = "" return masked -@app.post("/api/config/ai-keys") +@app.post("/api/config/ai-keys", response_model=StatusResponse) async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_admin)): """Save AI keys. Pass {"DEEPSEEK_API_KEY":"sk-...","OPENROUTER_API_KEY":"...","GEMINI_API_KEY":"..."}""" keys = _read_ai_keys() @@ -4282,7 +4384,7 @@ async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_a return {"status": "ok"} -@app.delete("/api/config/ai-keys/{provider_env}") +@app.delete("/api/config/ai-keys/{provider_env}", response_model=AIKeyDeleteResponse) async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admin)): """Delete a specific AI provider key from storage.""" allowed = {"DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", @@ -4300,7 +4402,7 @@ async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admi return {"status": "deleted", "key": key_name} -@app.post("/api/config/ai-keys/test") +@app.post("/api/config/ai-keys/test", response_model=AITestResponse) async def api_test_ai_keys(current_user=Depends(require_admin)): """Test which AI providers are configured. @@ -4345,7 +4447,7 @@ async def api_test_ai_keys(current_user=Depends(require_admin)): # AI Models — list available models per provider # --------------------------------------------------------------------------- -@app.get("/api/config/ai-models") +@app.get("/api/config/ai-models", response_model=AIModelsResponse) async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)): """List available models for a given AI provider. @@ -4492,7 +4594,7 @@ _FALLBACK_MODELS: dict[str, list[str]] = { # Diagnostics API # --------------------------------------------------------------------------- -@app.get("/api/diagnostics") +@app.get("/api/diagnostics", response_model=DiagnosticsResponse) async def api_diagnostics(current_user=Depends(require_admin)): """Return index statistics and system diagnostics. @@ -4550,7 +4652,7 @@ async def api_diagnostics(current_user=Depends(require_admin)): # Dashboard endpoint (aggregated stats) # --------------------------------------------------------------------------- -@app.get("/api/dashboard") +@app.get("/api/dashboard", response_model=DashboardResponse) async def api_dashboard(current_user=Depends(require_auth)): """Aggregated dashboard statistics across all accessible vaults.""" user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) @@ -4579,12 +4681,12 @@ async def api_dashboard(current_user=Depends(require_auth)): # Webhook CRUD endpoints # --------------------------------------------------------------------------- -@app.get("/api/webhooks") +@app.get("/api/webhooks", response_model=list[WebhookModel]) async def api_webhooks_list(current_user=Depends(require_admin)): return get_webhooks() -@app.post("/api/webhooks") +@app.post("/api/webhooks", response_model=WebhookModel) async def api_webhooks_create(body: dict = Body(...), current_user=Depends(require_admin)): name = body.get("name", "Unnamed") url = body.get("url", "") @@ -4595,7 +4697,7 @@ async def api_webhooks_create(body: dict = Body(...), current_user=Depends(requi return create_webhook(name, url, events, secret) -@app.patch("/api/webhooks/{webhook_id}") +@app.patch("/api/webhooks/{webhook_id}", response_model=WebhookModel) async def api_webhooks_update(webhook_id: str, body: dict = Body(...), current_user=Depends(require_admin)): result = update_webhook(webhook_id, body) if not result: @@ -4603,7 +4705,7 @@ async def api_webhooks_update(webhook_id: str, body: dict = Body(...), current_u return result -@app.delete("/api/webhooks/{webhook_id}") +@app.delete("/api/webhooks/{webhook_id}", response_model=StatusResponse) async def api_webhooks_delete(webhook_id: str, current_user=Depends(require_admin)): if not delete_webhook(webhook_id): raise HTTPException(404, "Webhook not found") @@ -4614,7 +4716,7 @@ async def api_webhooks_delete(webhook_id: str, current_user=Depends(require_admi # Share (public document) endpoints # --------------------------------------------------------------------------- -@app.post("/api/share/{vault_name}") +@app.post("/api/share/{vault_name}", response_model=ShareModel) async def api_share_create( vault_name: str, body: dict = Body(...), @@ -4653,7 +4755,7 @@ async def api_share_create( return share -@app.get("/api/shares") +@app.get("/api/shares", response_model=list[ShareModel]) async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)): """List all shares (optionally filtered by vault).""" shares = list_shares(vault) @@ -4662,13 +4764,17 @@ async def api_shares_list(vault: str | None = Query(None), current_user=Depends( return shares -@app.delete("/api/share/{share_id}") +@app.delete("/api/share/{share_id}", response_model=StatusResponse) async def api_share_revoke(share_id: str, current_user=Depends(require_auth)): if not revoke_share(share_id): raise HTTPException(404, "Share not found") return {"status": "revoked"} -@app.get("/s/{token}/pdf") +@app.get( + "/s/{token}/pdf", + response_class=Response, + responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}}, +) async def public_share_pdf_download(token: str): """Download shared document as real PDF via WeasyPrint.""" if generate_pdf is None: @@ -4702,7 +4808,7 @@ async def public_share_pdf_download(token: str): return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'}) -@app.get("/s/{token}/raw") +@app.get("/s/{token}/raw", response_class=FileResponse) async def public_share_raw(token: str): """Download the raw (original) shared document.""" share = get_share_by_token(token) @@ -4719,7 +4825,7 @@ async def public_share_raw(token: str): return FileResponse(path=str(file_path), filename=file_path.name, media_type="application/octet-stream") -@app.get("/s/{token}") +@app.get("/s/{token}", response_class=HTMLResponse) async def public_share_view(token: str): """Public share view — no authentication required.""" share = get_share_by_token(token) @@ -4839,7 +4945,7 @@ function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").t # Syncthing conflict endpoints # --------------------------------------------------------------------------- -@app.get("/api/conflicts") +@app.get("/api/conflicts", response_model=ConflictsResponse) async def api_conflicts(current_user=Depends(require_auth)): """List sync-conflict files across accessible vaults.""" user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) @@ -4849,7 +4955,7 @@ async def api_conflicts(current_user=Depends(require_auth)): return {"conflicts": all_conflicts, "total": len(all_conflicts)} -@app.post("/api/conflicts/resolve") +@app.post("/api/conflicts/resolve", response_model=ConflictResolveResponse) async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(require_auth)): """Resolve a conflict: keep_local (delete conflict file) or keep_conflict (replace original).""" vault_name = body.get("vault") diff --git a/backend/openapi_docs.py b/backend/openapi_docs.py new file mode 100644 index 0000000..44f4ed2 --- /dev/null +++ b/backend/openapi_docs.py @@ -0,0 +1,452 @@ +"""OpenAPI 3.1 documentation helpers for ObsiGate (#72). + +This module keeps the API-documentation concerns out of ``backend/main.py``: + +* :data:`TAGS_METADATA` — human descriptions and ordering for every tag. +* :func:`enrich_openapi_schema` — post-processes the schema generated by + FastAPI to assign tags, add servers/security/examples and document common + error responses. +* :func:`render_api_landing` — a self-contained ``/api`` landing page that + reads ``/openapi.json`` at runtime and groups endpoints by tag, with direct + links to the Swagger UI (``/docs``) and ReDoc (``/redoc``). +""" + +from __future__ import annotations + +import re +from typing import Any + +# --------------------------------------------------------------------------- +# Tag metadata +# --------------------------------------------------------------------------- + +TAGS_METADATA: list[dict[str, str]] = [ + {"name": "System", "description": "Health checks, runtime configuration, diagnostics, dashboard stats and the SSE event stream."}, + {"name": "Auth", "description": "Authentication, sessions, user profile, MFA (TOTP/WebAuthn) and API keys."}, + {"name": "Files", "description": "Browse, read, create, rename, move, delete and download vault files."}, + {"name": "PDF", "description": "PDF metadata and byte-range streaming for inline viewing."}, + {"name": "Vaults", "description": "List, add and remove vaults; per-vault display settings; attachment indexing."}, + {"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."}, + {"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."}, + {"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."}, + {"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."}, + {"name": "AI", "description": "AI-powered editor actions, provider status and model discovery."}, + {"name": "BooksLM", "description": "Directory-scoped AI chat (NotebookLM-style) over a vault folder."}, + {"name": "Sharing", "description": "Create and manage public read-only share links for documents."}, + {"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."}, + {"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."}, + {"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."}, + {"name": "Plugins", "description": "Install, enable and manage user plugins."}, + {"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."}, + {"name": "Frontend", "description": "Static assets and SPA fallback routes."}, +] + +API_DESCRIPTION = """ +**ObsiGate** exposes a REST API covering the entire application: vaults, files, +full-text search, backups, exports, AI actions, sharing and administration. + +### Authentication +When authentication is enabled, send a bearer token obtained from +`POST /api/auth/login`: + +``` +Authorization: Bearer +``` + +The same token is also accepted as an HTTP-only cookie, so browser clients can +simply use `credentials: "include"`. + +### Interactive documentation +* **Swagger UI** — [/docs](/docs): try requests directly from the browser. +* **ReDoc** — [/redoc](/redoc): clean, reading-oriented reference. +* **OpenAPI JSON** — [/openapi.json](/openapi.json): machine-readable schema. + +### Errors +Errors use the standard FastAPI envelope `{"detail": "..."}` with the +appropriate HTTP status (`400`, `401`, `403`, `404`, `409`, `422`, `500`). +""" + +# --------------------------------------------------------------------------- +# Automatic tag assignment +# --------------------------------------------------------------------------- + +# Rules are evaluated in order; the first match wins. Keep the most specific +# prefixes first (e.g. BooksLM before AI, config/ai-* before config). +_TAG_RULES: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"^/api/auth"), "Auth"), + (re.compile(r"^/api/admin"), "Admin"), + (re.compile(r"^/api/plugins"), "Plugins"), + (re.compile(r"^/api/push"), "Push"), + (re.compile(r"^/api/ai/bookslm"), "BooksLM"), + (re.compile(r"^/api/ai"), "AI"), + (re.compile(r"^/api/config/ai-"), "AI"), + (re.compile(r"^/api/share"), "Sharing"), + (re.compile(r"^/api/shares"), "Sharing"), + (re.compile(r"^/s/"), "Sharing"), + (re.compile(r"^/api/webhooks"), "Webhooks"), + (re.compile(r"^/api/conflicts"), "Conflicts"), + (re.compile(r"^/api/backups"), "Backups"), + (re.compile(r"^/api/file/[^/]+/(backups|diff|restore)"), "Backups"), + (re.compile(r"^/api/export"), "Export"), + (re.compile(r"^/api/file/[^/]+/pdf"), "PDF"), + (re.compile(r"^/api/search"), "Search"), + (re.compile(r"^/api/tags"), "Search"), + (re.compile(r"^/api/tree-search"), "Search"), + (re.compile(r"^/api/suggest"), "Search"), + (re.compile(r"^/api/graph"), "Search"), + (re.compile(r"^/api/recent"), "Bookmarks"), + (re.compile(r"^/api/bookmarks"), "Bookmarks"), + (re.compile(r"^/api/saved-searches"), "Bookmarks"), + (re.compile(r"^/api/vaults"), "Vaults"), + (re.compile(r"^/api/vault/"), "Vaults"), + (re.compile(r"^/api/index/reload"), "Vaults"), + (re.compile(r"^/api/attachments"), "Vaults"), + (re.compile(r"^/api/browse"), "Files"), + (re.compile(r"^/api/file"), "Files"), + (re.compile(r"^/api/directory"), "Files"), + (re.compile(r"^/api/move"), "Files"), + (re.compile(r"^/api/image"), "Files"), + (re.compile(r"^/api/health"), "System"), + (re.compile(r"^/api/config"), "System"), + (re.compile(r"^/api/diagnostics"), "System"), + (re.compile(r"^/api/dashboard"), "System"), + (re.compile(r"^/api/events"), "System"), +] + + +def tag_for_path(path: str) -> str: + """Return the documentation tag for an API path (``Frontend`` as fallback).""" + for pattern, tag in _TAG_RULES: + if pattern.match(path): + return tag + return "Frontend" + + +# Normalise tags declared by individual routers (``tags=["auth"]``) to the +# canonical, documented tag names. +_TAG_ALIASES: dict[str, str] = { + "auth": "Auth", + "admin": "Admin", + "plugins": "Plugins", + "push": "Push", + "files": "Files", + "vaults": "Vaults", + "search": "Search", + "backups": "Backups", + "export": "Export", + "sharing": "Sharing", + "webhooks": "Webhooks", + "conflicts": "Conflicts", + "system": "System", + "frontend": "Frontend", + "ai": "AI", + "bookslm": "BooksLM", + "pdf": "PDF", + "bookmarks": "Bookmarks", +} + +_CANONICAL_TAGS = {tag["name"] for tag in TAGS_METADATA} + + +def canonical_tag(name: str) -> str: + """Map a router-declared tag to its documented name (unchanged if unknown).""" + mapped = _TAG_ALIASES.get(name.lower(), name) + return mapped if mapped in _CANONICAL_TAGS else name + + + +# --------------------------------------------------------------------------- +# Request/response examples injected into the schema +# --------------------------------------------------------------------------- + +_ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = { + ("post", "/api/file/{vault_name}"): { + "request": {"path": "notes/Nouvelle note.md", "content": "# Titre\n\nContenu"}, + "response": {"success": True, "path": "notes/Nouvelle note.md"}, + }, + ("put", "/api/file/{vault_name}/save"): { + "request": {"path": "notes/Accueil.md", "content": "# Accueil\n\nMis à jour."}, + "response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26}, + }, + ("post", "/api/search/replace"): { + "request": {"query": "Python", "replacement": "Python 3", "vault": "all", "dry_run": True}, + "response": {"matches": [{"vault": "TestVault", "path": "note1.md", "title": "Python", "match_count": 3}], "total_matches": 3, "dry_run": True}, + }, + ("post", "/api/ai/improve"): { + "request": {"text": "ce texte est bof", "provider": "deepseek"}, + "response": {"result": "Ce texte mérite d'être amélioré.", "provider": "deepseek"}, + }, + ("post", "/api/ai/bookslm/chat"): { + "request": {"vault": "TestVault", "directory": "projets", "message": "Résume ce dossier", "conversation_history": []}, + "response": {"token": "Voici un résumé…", "provider": "deepseek", "model": "deepseek-chat"}, + }, + ("post", "/api/share/{vault_name}"): { + "request": {"path": "notes/Accueil.md", "expires_in_hours": 168}, + "response": {"id": "abc…", "token": "abc…", "vault": "TestVault", "path": "notes/Accueil.md", "url": "/s/abc…"}, + }, + ("post", "/api/webhooks"): { + "request": {"name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "secret": "s3cr3t"}, + "response": {"id": "3f2c…", "name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "enabled": True}, + }, +} + +# Common error responses documented on every operation. +_COMMON_ERRORS: dict[str, dict[str, Any]] = { + "401": { + "description": "Authentication required or token expired", + "content": {"application/json": {"example": {"detail": "Not authenticated"}}}, + }, + "403": { + "description": "Access to the vault is denied", + "content": {"application/json": {"example": {"detail": "Accès refusé à la vault 'Notes'"}}}, + }, + "404": { + "description": "Resource not found", + "content": {"application/json": {"example": {"detail": "File not found: notes/x.md"}}}, + }, + "422": { + "description": "Validation error", + "content": {"application/json": {"example": {"detail": [{"loc": ["query", "path"], "msg": "field required", "type": "value_error.missing"}]}}}, + }, + "500": { + "description": "Internal server error", + "content": {"application/json": {"example": {"detail": "Internal server error"}}}, + }, +} + +# Paths that return a binary/streaming body — never add a JSON error example +# to their 200 response, and skip the 500 JSON example for streams. +_BINARY_MEDIA = ("application/pdf", "application/octet-stream", "image/", "text/event-stream") + + +def _is_binary_operation(operation: dict[str, Any]) -> bool: + content = operation.get("responses", {}).get("200", {}).get("content", {}) + return any(media.startswith(_BINARY_MEDIA) for media in content) + + +def enrich_openapi_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Enrich a FastAPI-generated OpenAPI schema in place and return it. + + The transformation is idempotent: calling it twice yields the same result. + """ + schema.setdefault("openapi", "3.1.0") + info = schema.setdefault("info", {}) + info.setdefault("description", API_DESCRIPTION.strip()) + info.setdefault("contact", {"name": "ObsiGate", "url": "https://git.dracodev.net/Projets/ObsiGate"}) + info.setdefault("license", {"name": "MIT"}) + + schema.setdefault("servers", [{"url": "/", "description": "Current ObsiGate instance"}]) + schema["externalDocs"] = { + "description": "ObsiGate source repository", + "url": "https://git.dracodev.net/Projets/ObsiGate", + } + schema["tags"] = TAGS_METADATA + + components = schema.setdefault("components", {}) + security_schemes = components.setdefault("securitySchemes", {}) + security_schemes.setdefault("bearerAuth", { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT access token issued by POST /api/auth/login.", + }) + security_schemes.setdefault("cookieAuth", { + "type": "apiKey", + "in": "cookie", + "name": "obsigate_token", + "description": "HTTP-only session cookie (browser clients).", + }) + components.setdefault("schemas", {}).setdefault("ErrorResponse", { + "type": "object", + "properties": {"detail": {"title": "Detail", "description": "Error message"}}, + }) + + for path, operations in schema.get("paths", {}).items(): + default_tag = tag_for_path(path) + for method, operation in operations.items(): + if not isinstance(operation, dict): + continue + if operation.get("tags"): + operation["tags"] = [canonical_tag(t) for t in operation["tags"]] + else: + operation["tags"] = [default_tag] + + # Document common error responses without overriding explicit ones. + responses = operation.setdefault("responses", {}) + binary = _is_binary_operation(operation) + for code, spec in _COMMON_ERRORS.items(): + if code == "500" and binary: + continue + responses.setdefault(code, spec) + + # Inject request/response examples for key endpoints. + example = _ENDPOINT_EXAMPLES.get((method, path)) + if example: + if "request" in example: + body = operation.setdefault("requestBody", {}) + content = body.setdefault("content", {}).setdefault("application/json", {}) + content.setdefault("example", example["request"]) + if "response" in example: + ok = responses.setdefault("200", {}) + content = ok.setdefault("content", {}).setdefault("application/json", {}) + content.setdefault("example", example["response"]) + + return schema + + +# --------------------------------------------------------------------------- +# /api landing page +# --------------------------------------------------------------------------- + +_METHOD_COLORS = { + "get": "#2563eb", + "post": "#16a34a", + "put": "#d97706", + "patch": "#7c3aed", + "delete": "#dc2626", + "head": "#64748b", + "options": "#64748b", +} + + +def render_api_landing(version: str = "") -> str: + """Return the self-contained HTML for the ``/api`` documentation page.""" + return f""" + + + + +ObsiGate API{(' — v' + version) if version else ''} + + + +
+
+

ObsiGate API

+

Interactive reference for the ObsiGate REST API (OpenAPI 3.1).

+
+ +
Loading specification…
+

Loading endpoints…

+
Generated from the live OpenAPI schema. Authentication: Authorization: Bearer <token>.
+
+ + +""" + + +def _js_colors() -> str: + """Serialise the method→colour map for the landing page script.""" + import json + + return json.dumps(_METHOD_COLORS) diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..01b9995 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,512 @@ +"""Additional Pydantic response models for the ObsiGate REST API. + +These models enrich the auto-generated OpenAPI 3.1 schema (#72). They are +intentionally permissive (``extra="allow"``) so that adding a new field to an +existing endpoint never breaks response validation — the model documents the +stable, public shape while still accepting internal additions. + +Models whose endpoints are already typed in :mod:`backend.main` (e.g. +``FileContentResponse``) live there; this module only holds the ones used to +fill the gaps. +""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +# --------------------------------------------------------------------------- +# Generic helpers +# --------------------------------------------------------------------------- + + +class StatusResponse(BaseModel): + """Generic ``{"status": "..."}`` acknowledgement.""" + + model_config = ConfigDict(extra="allow") + status: str = Field(default="ok", description="Operation status") + + +class ErrorResponse(BaseModel): + """Standard FastAPI error envelope.""" + + detail: str | list[dict[str, Any]] | None = Field( + default=None, + description="Human-readable error message, or a list of validation errors", + examples=["Vault 'Notes' not found"], + ) + + +# --------------------------------------------------------------------------- +# Recent files & bookmarks +# --------------------------------------------------------------------------- + + +class RecentFileItem(BaseModel): + """A recently opened or recently modified file.""" + + path: str = Field(description="Relative path within the vault") + title: str = Field(description="File title") + vault: str = Field(description="Vault name") + mtime: float | int | str | None = Field(default=None, description="Modification timestamp") + mtime_human: str | None = Field(default=None, description="Human-readable modification time") + mtime_iso: str | None = Field(default=None, description="ISO-8601 modification time") + size_bytes: int | None = Field(default=None, description="File size in bytes") + tags: list[str] = Field(default_factory=list, description="Up to 5 leading tags") + preview: str | None = Field(default=None, description="Short content preview") + bookmarked: bool | None = Field(default=None, description="Whether the file is bookmarked") + + +class RecentResponse(BaseModel): + """Response for ``GET /api/recent``.""" + + files: list[RecentFileItem] + total: int = Field(description="Total number of files in the selected mode") + limit: int = Field(description="Applied limit") + mode: str = Field(description="'opened' or 'modified'") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "files": [ + { + "path": "notes/Accueil.md", + "title": "Accueil", + "vault": "TestVault", + "mtime": 1750000000.0, + "mtime_human": "il y a 2 h", + "size_bytes": 1024, + "tags": ["#accueil"], + "preview": "# Bienvenue…", + "bookmarked": False, + } + ], + "total": 1, + "limit": 20, + "mode": "opened", + } + } + ) + + +class BookmarkFileItem(BaseModel): + """A bookmarked file.""" + + path: str = Field(description="Relative path within the vault") + title: str = Field(description="File title") + vault: str = Field(description="Vault name") + mtime: float | int | str | None = Field(default=None, description="Bookmark timestamp") + mtime_human: str | None = Field(default=None, description="Human-readable bookmark time") + size_bytes: int | None = Field(default=None, description="File size in bytes") + tags: list[str] = Field(default_factory=list, description="Up to 5 leading tags") + bookmarked: bool | None = Field(default=True, description="Always true for this endpoint") + + +class BookmarksResponse(BaseModel): + """Response for ``GET /api/bookmarks``.""" + + files: list[BookmarkFileItem] + total: int = Field(description="Number of bookmarked files") + + +class BookmarkToggleResponse(BaseModel): + """Response for ``POST /api/bookmarks/toggle``.""" + + bookmarked: bool = Field(description="New bookmark state") + + +class SavedSearch(BaseModel): + """A persisted search definition.""" + + model_config = ConfigDict(extra="allow") + id: str = Field(description="Unique identifier (millisecond timestamp)") + query: str = Field(description="Search query text") + vault: str = Field(default="all", description="Vault filter") + case_sensitive: bool = Field(default=False) + whole_word: bool = Field(default=False) + regex: bool = Field(default=False) + include_paths: str = Field(default="") + exclude_paths: str = Field(default="") + created_at: float = Field(description="Creation timestamp (epoch seconds)") + + +# --------------------------------------------------------------------------- +# Backups & diffs +# --------------------------------------------------------------------------- + + +class BackupsResponse(BaseModel): + """Response for ``GET /api/file/{vault}/backups``.""" + + vault: str + path: str + backups: list[dict[str, Any]] = Field(description="Backups, newest first") + + +class BacklinksResponse(BaseModel): + """Response for ``GET /api/file/{vault}/backlinks``.""" + + vault: str + path: str + backlinks: list[dict[str, Any]] = Field(description="Files linking to the target") + total: int + + +class BackupsListResponse(BaseModel): + """Response for ``GET /api/backups``.""" + + backups: list[dict[str, Any]] = Field(description="All backups across vaults") + total: int + total_size_bytes: int + + +class BackupsDeletedResponse(BaseModel): + """Response for backup deletion / purge endpoints.""" + + deleted: int = Field(description="Number of backup files deleted") + + +class BackupContentResponse(BaseModel): + """Response for ``GET /api/backups/content``.""" + + content: str = Field(description="Backup file content (truncated to 100 KB)") + name: str = Field(description="Backup file name") + size: int = Field(description="Returned content size in characters") + + +class BackupsCompressResponse(BaseModel): + """Response for ``POST /api/backups/compress``.""" + + compressed: int = Field(description="Number of backups processed") + saved_bytes: int = Field(description="Bytes saved by gzip compression") + dry_run: bool + + +class BackupsAutoResponse(BaseModel): + """Response for ``POST /api/backups/auto``.""" + + backed_up: int = Field(description="Number of files backed up") + since_hours: int | float = Field(description="Look-back window in hours") + + +# --------------------------------------------------------------------------- +# PDF +# --------------------------------------------------------------------------- + + +class PdfInfoResponse(BaseModel): + """Response for ``GET /api/file/{vault}/pdf/info``.""" + + vault: str + path: str + pages: int = Field(description="Page count") + title: str = Field(description="PDF title (metadata or filename)") + author: str = Field(default="", description="PDF author") + size_bytes: int = Field(description="File size in bytes") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "vault": "TestVault", + "path": "docs/rapport.pdf", + "pages": 12, + "title": "Rapport annuel", + "author": "ObsiGate", + "size_bytes": 524288, + } + } + ) + + +# --------------------------------------------------------------------------- +# Search & replace +# --------------------------------------------------------------------------- + + +class ReplaceMatch(BaseModel): + """A single file affected by a find/replace operation.""" + + model_config = ConfigDict(extra="allow") + vault: str + path: str + title: str | None = None + match_count: int | None = Field(default=None, description="Occurrences found (dry run)") + replacements: int | None = Field(default=None, description="Occurrences replaced") + preview: list[str] = Field(default_factory=list, description="Context snippets (dry run)") + + +class ReplaceResponse(BaseModel): + """Response for ``POST /api/search/replace``.""" + + model_config = ConfigDict(extra="allow") + matches: list[ReplaceMatch] = Field(default_factory=list, description="Dry-run matches") + replaced: list[ReplaceMatch] = Field(default_factory=list, description="Applied replacements") + total_matches: int | None = Field(default=None, description="Total matches (dry run)") + total_replacements: int = Field(description="Total replacements performed or previewed") + dry_run: bool | None = Field(default=None, description="True when no file was written") + + +# --------------------------------------------------------------------------- +# Vaults, attachments & settings +# --------------------------------------------------------------------------- + + +class VaultStatsResponse(BaseModel): + """Response for ``POST /api/vaults/add`` and ``GET /api/index/reload/{vault}``.""" + + model_config = ConfigDict(extra="allow") + status: str = Field(default="ok") + vault: str + stats: dict[str, Any] = Field(default_factory=dict, description="Index statistics for the vault") + + +class VaultActionResponse(BaseModel): + """Response for ``DELETE /api/vaults/{vault}``.""" + + status: str = Field(default="ok") + vault: str + + +class VaultStatusEntry(BaseModel): + """Per-vault status entry.""" + + file_count: int + tag_count: int + path: str = "" + watching: bool = False + + +class VaultsStatusResponse(BaseModel): + """Response for ``GET /api/vaults/status``.""" + + vaults: dict[str, VaultStatusEntry] + watcher_active: bool + sse_clients: int + + +class AttachmentRescanResponse(BaseModel): + """Response for ``POST /api/attachments/rescan/{vault}``.""" + + status: str = Field(default="ok") + vault: str + attachment_count: int + + +class AttachmentStatsResponse(BaseModel): + """Response for ``GET /api/attachments/stats``.""" + + vaults: dict[str, Any] = Field(description="Vault name → attachment statistics") + + +class VaultSettingsResponse(BaseModel): + """Response for the per-vault display settings endpoints.""" + + model_config = ConfigDict(extra="allow") + hideHiddenFiles: bool = Field(default=False, description="Hide dotfiles in the tree") + + +class AllVaultSettingsResponse(BaseModel): + """Response for ``GET /api/vaults/settings/all``.""" + + model_config = ConfigDict(extra="allow") + + +class VaultFileEntry(BaseModel): + """A file entry returned by the vault home listing.""" + + model_config = ConfigDict(extra="allow") + name: str + path: str + vault: str + size: int = 0 + modified: float = 0 + modified_iso: str | None = None + extension: str = "" + rel_dir: str | None = None + + +class VaultFilesResponse(BaseModel): + """Response for ``GET /api/vault/{vault}/files``.""" + + vault: str + directory: str = "" + recursive: bool = True + count: int + files: list[VaultFileEntry] + + +# --------------------------------------------------------------------------- +# Configuration, AI keys & diagnostics +# --------------------------------------------------------------------------- + + +class AppConfigResponse(BaseModel): + """Application configuration (``GET/POST /api/config``).""" + + model_config = ConfigDict(extra="allow") + + +class AIKeysResponse(BaseModel): + """Masked AI provider keys (``GET /api/config/ai-keys``).""" + + model_config = ConfigDict(extra="allow") + + +class AIKeyDeleteResponse(BaseModel): + """Response for ``DELETE /api/config/ai-keys/{provider}``.""" + + status: str = Field(default="deleted") + key: str = Field(description="Deleted environment variable name") + + +class AITestResponse(BaseModel): + """Response for ``POST /api/config/ai-keys/test`` (provider → status).""" + + model_config = ConfigDict(extra="allow") + + +class AIModelsResponse(BaseModel): + """Response for ``GET /api/config/ai-models``.""" + + model_config = ConfigDict(extra="allow") + models: list[str] = Field(default_factory=list, description="Available model identifiers") + source: str = Field(default="fallback", description="'live', 'fallback' or 'validation'") + count: int | None = Field(default=None, description="Number of live models") + error: str | None = Field(default=None, description="Provider/network error, if any") + note: str | None = Field(default=None, description="Explanatory note when using fallback") + + +class DiagnosticsResponse(BaseModel): + """Response for ``GET /api/diagnostics``.""" + + model_config = ConfigDict(extra="allow") + index: dict[str, Any] = Field(default_factory=dict) + inverted_index: dict[str, Any] = Field(default_factory=dict) + config: dict[str, Any] = Field(default_factory=dict) + + +class DashboardVaultStat(BaseModel): + """Per-vault dashboard statistics.""" + + name: str + file_count: int + tag_count: int + total_size_bytes: int + + +class DashboardResponse(BaseModel): + """Response for ``GET /api/dashboard``.""" + + vaults: list[DashboardVaultStat] + total_files: int + total_tags: int + total_size_bytes: int + + +# --------------------------------------------------------------------------- +# Webhooks, sharing & conflicts +# --------------------------------------------------------------------------- + + +class WebhookModel(BaseModel): + """A configured webhook.""" + + model_config = ConfigDict(extra="allow") + id: str = Field(description="Webhook UUID") + name: str + url: str = Field(description="Target HTTP(S) URL") + events: list[str] = Field(default_factory=list, description="Subscribed event types") + secret: str | None = Field(default=None, description="HMAC-SHA256 signing secret") + enabled: bool = True + created_at: str | None = None + last_fired_at: str | None = None + + +class ShareModel(BaseModel): + """A public document share.""" + + model_config = ConfigDict(extra="allow") + id: str + token: str = Field(description="Opaque share token") + vault: str + path: str + url: str | None = Field(default=None, description="Relative public URL (/s/{token})") + created_by: str | None = None + created_at: str | None = None + expires_at: str | None = None + access_count: int = 0 + last_accessed: str | None = None + + +class ConflictEntry(BaseModel): + """A Syncthing sync-conflict file.""" + + model_config = ConfigDict(extra="allow") + vault: str + path: str + original_path: str | None = None + + +class ConflictsResponse(BaseModel): + """Response for ``GET /api/conflicts``.""" + + conflicts: list[ConflictEntry] + total: int + + +class ConflictResolveResponse(BaseModel): + """Response for ``POST /api/conflicts/resolve``.""" + + status: str = Field(default="resolved") + action: str | None = Field(default=None, description="'keep_local' or 'keep_conflict'") + + +# --------------------------------------------------------------------------- +# AI status & BooksLM context +# --------------------------------------------------------------------------- + + +class AIProviderStatus(BaseModel): + """Availability of a single AI provider.""" + + available: bool + model: str | None = Field(default=None, description="Default model when the provider is configured") + + +class AIAutocompleteStatus(BaseModel): + """Status of the local Ollama autocomplete backend.""" + + available: bool = False + server_ok: bool = False + model_loaded: bool = False + model: str = "" + error: str | None = None + + +class AIStatusResponse(BaseModel): + """Response for ``GET /api/ai/status``.""" + + configured: bool = Field(description="True when at least one provider has an API key") + default_provider: str + providers: dict[str, AIProviderStatus] + autocomplete: AIAutocompleteStatus + + +class BooksLMContextFile(BaseModel): + """A single document included in the BooksLM context.""" + + path: str + title: str + content: str + type: str = Field(default="markdown", description="'markdown' (and future types)") + + +class BooksLMContextResponse(BaseModel): + """Response for ``POST /api/ai/bookslm/context``.""" + + files: list[BooksLMContextFile] + total_chars: int + file_count: int + directory_tree: str = "" + max_total_chars: int = Field(default=200000, description="Configured context character limit") + max_files: int = Field(default=200, description="Configured file-count limit") + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 478ebc5..dad8ea4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -767,8 +767,20 @@ - Widgets temps réel via EventSource `/api/admin/stream` + snapshot `/api/admin/stats` - CRUD users + fix routing `/admin.html` + fix scroll -### 72. API publique documentée — OpenAPI 3.1 -- **Effort :** 1-2 jours | **Impact :** 🟢 +### 72. API publique documentée — OpenAPI 3.1 ✅ TERMINÉ +- **Effort :** 1-2 jours (réalisé) | **Impact :** 🟢 | **Statut :** ✅ Livré (2026-09-11) +- **Implémentation réelle :** + - `backend/openapi_docs.py` — 18 tags documentés + assignation automatique par préfixe de route + (`tag_for_path`, `canonical_tag`), enrichissement du schéma (`enrich_openapi_schema` : + sécurité `bearerAuth`/`cookieAuth`, erreurs 401/403/404/422/500, exemples requête/réponse, + serveur, `externalDocs`), page `/api` autonome (`render_api_landing`). + - `backend/schemas.py` — `response_model` Pydantic pour ~35 endpoints qui n'en avaient pas. + - `backend/main.py` — `FastAPI(...)` enrichi (description Markdown, contact, licence, tags) + + override `app.openapi` ; routes `/api` et `/api/`. + - `backend/ai_routes.py` / `backend/bookslm_routes.py` — `response_model` (AI status, BooksLM + context) + documentation SSE. + - Frontend — entrée « API » du menu d'options (i18n FR/EN) ouvrant `/docs`. + - Tests — `tests/test_openapi.py` (58 tests) + `tests/frontend/ai.test.mjs` (7 tests). - **Description :** Une page de documentation interactive et auto-générée de toutes les API REST d'ObsiGate, accessible via un bouton dans l'interface. L'équivalent d'un manuel technique mais qui se teste en direct. - **OpenAPI 3.1** : c'est le format standard mondial pour décrire une API REST. Un seul fichier JSON/YAML contient la description de tous les endpoints, leurs paramètres, les formats de réponse, les codes d'erreur, et les modèles de données. Ce standard est supporté par des centaines d'outils. - **Swagger UI** : une interface web qui lit le fichier OpenAPI et génère automatiquement une documentation interactive. L'utilisateur voit chaque endpoint, peut remplir les paramètres dans un formulaire, cliquer « Execute » et voir la réponse réelle de l'API en direct. Parfait pour les développeurs qui veulent intégrer ObsiGate à leurs scripts ou comprendre comment fonctionne l'API. @@ -781,12 +793,12 @@ - **Auto-génération** : FastAPI génère déjà partiellement le schéma OpenAPI. Le travail consiste à compléter les docstrings manquantes, ajouter `response_model` sur les endpoints qui n'en ont pas, et enrichir avec des exemples. - **Pourquoi c'est important :** Une API sans documentation est comme un logiciel sans interface — techniquement fonctionnel mais inutilisable. Avec une doc OpenAPI, ObsiGate devient intégrable dans n'importe quel écosystème. Un développeur peut en 5 minutes comprendre comment uploader un fichier, chercher dans un vault, ou récupérer le contenu d'une note — et écrire un script qui automatise ses workflows. - **Sous-tâches :** - - [ ] Audit des endpoints existants → compléter les docstrings manquants - - [ ] Ajout de `response_model` sur tous les endpoints (40+ actuellement, ~15 sans modèle) - - [ ] Exemples dans les schémas : `examples=[...]` pour les endpoints clés - - [ ] Tagging des endpoints par catégorie (Files, Vaults, Search, Auth, AI, Backups) - - [ ] Serveur mock : `prism` ou `openapi-generator` pour tests sans backend - - [ ] Page de documentation intégrée : lien dans le menu header (« API ») + - [x] Audit des endpoints existants → compléter les docstrings manquants + - [x] Ajout de `response_model` sur tous les endpoints (40+ actuellement, ~15 sans modèle) + - [x] Exemples dans les schémas : `examples=[...]` pour les endpoints clés + - [x] Tagging des endpoints par catégorie (Files, Vaults, Search, Auth, AI, Backups) + - [ ] Serveur mock : `prism` ou `openapi-generator` pour tests sans backend — ⚪ NON RETENU (le schéma 3.1 est validé par `tests/test_openapi.py`) + - [x] Page de documentation intégrée : lien dans le menu header (« API ») ### 73. Synchronisation multi-appareils — Obsidian Sync compatible - **Effort :** 6-8 jours | **Impact :** 🟢 @@ -897,11 +909,11 @@ | Priorité | Items | Effort total estimé | |---|---|---| -| ✅ Complété | #1 → #59, #63-68, #71, #74, #75, #76, #78 (58 E2E, 59 offline, 63 i18n, 64 MFA TOTP+WebAuthn, 65 thèmes, 66 export, 67 push, 68 health, 71 admin, 74 PDF, 75 split view, 76 BooksLM, 78 Excalidraw) | ~80 jours réalisés | +| ✅ Complété | #1 → #59, #63-68, #71, #72 (OpenAPI 3.1), #74, #75, #76, #78 (58 E2E, 59 offline, 63 i18n, 64 MFA TOTP+WebAuthn, 65 thèmes, 66 export, 67 push, 68 health, 71 admin, 72 OpenAPI, 74 PDF, 75 split view, 76 BooksLM, 78 Excalidraw) | ~82 jours réalisés | | 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours | | ⚪ P3 restant | #62 Collaboration Yjs (5-7j) | ~5-7 jours | -| ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #72 OpenAPI (1-2j) · #73 Sync (6-8j) | 13-18 jours | -| **Total restant** | **6 items + finitions** | **~19-27 jours** | +| ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours | +| **Total restant** | **5 items + finitions** | **~17-25 jours** | #75 : E2E matriciels 37/37 verts (split-view.spec.js + split-view-matrix.spec.js) — 2026-09-08. @@ -912,5 +924,5 @@ - Les items P3/P4 ne sont pas ordonnés par priorité interne — à raffiner selon les retours utilisateurs. - L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur. - Les items marqués 🟢 (nice-to-have) sont de bons candidats pour des contributions externes. -- #59 (hors-ligne), #63 (i18n), #64 (MFA), #65-66, #67-68, #71, #74-76, #78 sont livrés — les P3 restants à plus fort rapport effort/valeur : #72 (OpenAPI, 1-2j) et #62 (collaboration). +- #59 (hors-ligne), #63 (i18n), #64 (MFA), #65-66, #67-68, #71, #72 (OpenAPI), #74-76, #78 sont livrés — les P3 restants à plus fort rapport effort/valeur : #62 (collaboration). - #78 est terminé (B5 recherche texte, C8 menu contextuel, F3 E2E, doc H1-H3) ; F2 non retenu. BUG-002 (loading infini Excalidraw) corrigé par les commits a4ea322/185d603. diff --git a/frontend/index.html b/frontend/index.html index 106a5af..524fc9e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -627,6 +627,30 @@ > + + + `; + overlay.appendChild(modal); + document.body.appendChild(overlay); + + const input = modal.querySelector('.ai-modal-input'); + const close = (value) => { + overlay.remove(); + document.removeEventListener('keydown', onKey, true); + resolve(value); + }; + const onKey = (e) => { + if (e.key === 'Escape') { e.preventDefault(); close(null); } + else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + close(input.value.trim() || null); + } + }; + document.addEventListener('keydown', onKey, true); + modal.querySelector('.ai-modal-cancel').addEventListener('click', () => close(null)); + modal.querySelector('.ai-modal-ok').addEventListener('click', () => close(input.value.trim() || null)); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(null); }); + input.focus(); + }); +} + export async function createAIToolbar(container, getEditorView) { // Check if AI is configured let aiConfigured = false; @@ -424,6 +494,7 @@ export async function createAIToolbar(container, getEditorView) { borderRadius: '4px', }); aiBtn.title = t('ai.inline_completion_title'); + aiBtn.setAttribute('aria-label', t('ai.inline_completion_title')); aiBtn.addEventListener('click', async () => { const v = ev(); if (!v) return; @@ -445,22 +516,22 @@ export async function createAIToolbar(container, getEditorView) { }); // ── Edit menu ── - const editBtn = createDropdownBtn(t('ai.edit'), [ - { label: '🪄 Improve writing', action: () => action('improve'), hint: t('ai.hint_improve') }, - { label: '🔤 Fix spelling & grammar', action: () => action('fix-spelling'), hint: 'Corrige les fautes' }, - { label: '📏 Make shorter', action: () => action('make-shorter'), hint: 'Rend le texte plus concis' }, - { label: '📐 Make longer', action: () => action('make-longer'), hint: t('ai.hint_add_details') }, - { label: '📋 Simplify language', action: () => action('simplify'), hint: 'Simplifie le langage' }, + const editBtn = createDropdownBtn(t('ai.toolbar_edit'), [ + { label: '🪄 ' + t('ai.improve'), action: () => action('improve'), hint: t('ai.hint_improve') }, + { label: '🔤 ' + t('ai.fix_spelling'), action: () => action('fix-spelling'), hint: t('ai.fix_spelling_hint') }, + { label: '📏 ' + t('ai.shorter'), action: () => action('make-shorter'), hint: t('ai.shorter_hint') }, + { label: '📐 ' + t('ai.longer'), action: () => action('make-longer'), hint: t('ai.hint_add_details') }, + { label: '📋 ' + t('ai.simplify'), action: () => action('simplify'), hint: t('ai.simplify_hint') }, ], t('ai.hint_edit_selection')); // ── Tone menu ── - const toneBtn = createDropdownBtn('Ton', [ - { label: '💼 Professional tone', action: () => action('tone', { tone: 'professional' }) }, - { label: '💬 Casual tone', action: () => action('tone', { tone: 'casual' }) }, - ], 'Change le ton du texte'); + const toneBtn = createDropdownBtn(t('ai.toolbar_tone'), [ + { label: '💼 ' + t('ai.professional'), action: () => action('tone', { tone: 'professional' }) }, + { label: '💬 ' + t('ai.casual'), action: () => action('tone', { tone: 'casual' }) }, + ], t('ai.tone_hint')); // ── Translate menu ── - const translateBtn = createDropdownBtn('Traduire', [ + const translateBtn = createDropdownBtn(t('ai.toolbar_translate'), [ { label: '🇬🇧 English', action: () => action('translate', { target_lang: 'English' }) }, { label: '🇨🇳 Chinese', action: () => action('translate', { target_lang: 'Chinese' }) }, { label: '🇯🇵 Japanese', action: () => action('translate', { target_lang: 'Japanese' }) }, @@ -470,17 +541,18 @@ export async function createAIToolbar(container, getEditorView) { ], t('ai.hint_translate')); // ── Generate menu ── - const genBtn = createDropdownBtn(t('ai.generate'), [ - { label: 'ℹ️ Explain this', action: () => action('explain', {}, 'append') }, - { label: '📝 Summarize', action: () => action('summarize', {}, 'append') }, - { label: '✏️ Continue writing', action: () => action('continue', {}, 'append') }, + const genBtn = createDropdownBtn(t('ai.toolbar_generate'), [ + { label: 'ℹ️ ' + t('ai.explain'), action: () => action('explain', {}, 'append') }, + { label: '📝 ' + t('ai.summarize'), action: () => action('summarize', {}, 'append') }, + { label: '✏️ ' + t('ai.continue'), action: () => action('continue', {}, 'append') }, ], t('ai.hint_generate')); // ── Custom Rewrite ── const rewriteBtn = document.createElement('button'); rewriteBtn.className = 'ai-toolbar-btn'; - rewriteBtn.innerHTML = t('ai.rewrite'); + rewriteBtn.innerHTML = t('ai.toolbar_rewrite'); rewriteBtn.title = t('ai.rewrite_title'); + rewriteBtn.setAttribute('aria-label', t('ai.rewrite_title')); Object.assign(rewriteBtn.style, { fontSize: '0.7rem', background: 'transparent', @@ -494,24 +566,31 @@ export async function createAIToolbar(container, getEditorView) { const v = ev(); if (!v) return; const text = getSelection(v); - const instruction = prompt(t('ai.rewrite_prompt'), ''); + if (!text.trim()) { + showToast(t('ai.select_text'), 'warning'); + return; + } + const instruction = await _promptRewriteInstruction(); if (!instruction) return; + rewriteBtn.disabled = true; try { const result = await aiAction('rewrite', text, { instruction }); replaceSelection(v, result); showToast(t('ai.text_rewritten'), 'success'); } catch (e) { showToast('AI: ' + (String(e.message || e).includes('401') ? t('ai.error_invalid_key') : e.message), 'error'); + } finally { + rewriteBtn.disabled = false; } }); // ── Toolbox menu ── - const toolboxBtn = createDropdownBtn(t('ai.toolbox'), [ - { label: '📋 Convert to list', action: () => action('to-list') }, - { label: '📊 Convert to table', action: () => action('to-table') }, - { label: '⚙️ Generate frontmatter', action: () => action('frontmatter', {}, 'before') }, - { label: '🔷 Convert to canvas', action: () => action('to-canvas', {}, 'append') }, - ], 'Outils de conversion'); + const toolboxBtn = createDropdownBtn(t('ai.toolbar_toolbox'), [ + { label: '📋 ' + t('ai.to_list'), action: () => action('to-list') }, + { label: '📊 ' + t('ai.to_table'), action: () => action('to-table') }, + { label: '⚙️ ' + t('ai.frontmatter'), action: () => action('frontmatter', {}, 'before') }, + { label: '🔷 ' + t('ai.to_canvas'), action: () => action('to-canvas', {}, 'append') }, + ], t('ai.toolbox_hint')); toolbar.appendChild(aiBtn); toolbar.appendChild(createSeparator()); @@ -577,9 +656,10 @@ export async function createAIToolbar(container, getEditorView) { el.id = 'ai-processing-indicator'; el.className = 'ai-processing-indicator'; el.innerHTML = ' AI...'; - el.title = 'Traitement AI en cours'; + el.title = t('ai.processing'); + el.setAttribute('role', 'status'); const header = document.querySelector('#editor-modal .editor-header'); - if (header) header.appendChild(el); + (header || toolbar).appendChild(el); } el.style.display = 'inline-flex'; } else if (el) { @@ -587,14 +667,8 @@ export async function createAIToolbar(container, getEditorView) { } } // ── Keyboard shortcut Ctrl+J for inline completion ── - document.addEventListener('keydown', (e) => { - if (e.ctrlKey && e.key === 'j') { - const modal = document.getElementById('editor-modal'); - if (!modal || !modal.classList.contains('active')) return; - e.preventDefault(); - aiBtn.click(); - } - }); + _activeAiBtn = aiBtn; + _bindAiShortcut(); return toolbar; } @@ -604,6 +678,8 @@ function createDropdownBtn(text, items, tooltip = '') { btn.className = 'ai-toolbar-btn'; btn.innerHTML = text + ' ▾'; btn.title = tooltip || text; + btn.setAttribute('aria-haspopup', 'true'); + btn.setAttribute('aria-expanded', 'false'); Object.assign(btn.style, { position: 'relative', fontSize: '0.7rem', @@ -637,4 +713,5 @@ export { _writePicker, PICKER_STORAGE_KEY, _buildPickerUI as buildAIPickerUI, + _promptRewriteInstruction, }; diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 39caefb..dbeb07e 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -2,6 +2,7 @@ import { t } from './i18n.js'; import { buildAIPickerUI } from './ai.js'; import { safeCreateIcons } from './utils.js'; +import { api, AuthManager } from './auth.js'; class BooksLM { constructor() { @@ -58,15 +59,14 @@ class BooksLM { }); this._isOpen = true; - // Load context + // Load context (authenticated — `api` injects the bearer token and + // transparently refreshes it once on 401). try { - const resp = await fetch('/api/ai/bookslm/context', { + const data = await api('/api/ai/bookslm/context', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ vault, directory }) + body: JSON.stringify({ vault, directory }), }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); this._contextFiles = data.files || []; this._updateStatus(data); this._showSuggestions(); @@ -241,10 +241,11 @@ class BooksLM { if (data && data.files) { const count = data.files.length; const chars = data.total_chars || 0; - const pct = Math.min(100, Math.round((chars / 100000) * 100)); + const maxChars = data.max_total_chars || 200000; + const pct = Math.min(100, Math.round((chars / maxChars) * 100)); status.innerHTML = ` ${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })} -
+
`; } else if (this._isLoading) { status.textContent = '⏳ ...'; @@ -330,6 +331,12 @@ class BooksLM { const sugEl = this._panel.querySelector('.bookslm-suggestions'); if (sugEl) sugEl.innerHTML = ''; + // Snapshot the prior turns BEFORE appending the new user message so the + // backend receives the correct `message` + `conversation_history` split. + const history = this._messages + .filter((m) => m.content && m.content.trim()) + .map((m) => ({ role: m.role, content: m.content })); + // Add user message this._messages.push({ role: 'user', content: text }); this._renderMessages(); @@ -356,33 +363,70 @@ class BooksLM { model = picker.model || null; } catch { /* */ } - const resp = await fetch('/api/ai/bookslm/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - vault: this._vault, - directory: this._directory, - // The backend expects `message` + `conversation_history`, - // not a generic `messages` array. Convert before sending. - message: this._messages.length - ? this._messages[this._messages.length - 1].content - : '', - conversation_history: this._messages - .slice(0, -1) - .filter((m) => m.content && m.content.trim()) - .map((m) => ({ role: m.role, content: m.content })), - context_files: this._contextFiles.map((f) => f.path || f), - provider, - model, - }), - signal: this._abortCtrl.signal - }); + const payload = { + vault: this._vault, + directory: this._directory, + message: text, + conversation_history: history, + context_files: this._contextFiles.map((f) => f.path || f), + provider, + model, + }; - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + // Authenticated streaming request (raw fetch so we can read the SSE + // body; `api()` would consume the JSON response instead). + const doFetch = () => { + const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) }; + return fetch('/api/ai/bookslm/chat', { + method: 'POST', + headers, + credentials: 'include', + body: JSON.stringify(payload), + signal: this._abortCtrl.signal, + }); + }; + let resp = await doFetch(); + if (resp.status === 401 && AuthManager._authEnabled) { + await AuthManager.refreshAccessToken(); + resp = await doFetch(); + } + if (!resp.ok) { + let detail = `HTTP ${resp.status}`; + try { detail = (await resp.json()).detail || detail; } catch { /* */ } + throw new Error(detail); + } const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; + let currentEvent = 'message'; + + const handleLine = (line) => { + if (line.startsWith('event:')) { + currentEvent = line.slice(6).trim(); + return; + } + if (!line.startsWith('data:')) return; + const raw = line.slice(5).trim(); + if (!raw || raw === '[DONE]') return; + let data; + try { + data = JSON.parse(raw); + } catch { + assistantMsg.content += raw; + return; + } + if (currentEvent === 'error' || data.error) { + const msg = data.error || t('bookslm.error'); + assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg; + return; + } + // The backend streams `{token: "..."}`; accept `content` too for + // forward compatibility. + const chunk = data.token || data.content || ''; + if (chunk) assistantMsg.content += chunk; + if (data.sources) assistantMsg.sources = data.sources; + }; while (true) { const { done, value } = await reader.read(); @@ -392,25 +436,10 @@ class BooksLM { const lines = buffer.split('\n'); buffer = lines.pop() || ''; - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const payload = line.slice(6); - if (payload === '[DONE]') continue; - try { - const data = JSON.parse(payload); - if (data.content) { - assistantMsg.content += data.content; - } - if (data.sources) { - assistantMsg.sources = data.sources; - } - } catch { - // Raw text token - assistantMsg.content += payload; - } - this._renderMessages(); - } + for (const line of lines) handleLine(line); + this._renderMessages(); } + if (buffer) handleLine(buffer); // Extract sources from context files if mentioned if (!assistantMsg.sources.length) { diff --git a/frontend/js/ui.js b/frontend/js/ui.js index c50c4e4..512d768 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -216,6 +216,15 @@ export function initHeaderMenu() { menuDropdown.addEventListener("click", (e) => { e.stopPropagation(); }); + + // API documentation (#72) — opens the OpenAPI/Swagger page in a new tab. + const apiRow = document.getElementById("api-docs-menu-row"); + if (apiRow) { + apiRow.addEventListener("click", () => { + closeHeaderMenu(); + window.open("/docs", "_blank", "noopener"); + }); + } } export function closeHeaderMenu() { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 1fdc560..487e64e 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -87,6 +87,7 @@ "ai.error_invalid_key": "Invalid API key", "ai.explain": "Explain", "ai.fix_spelling": "Fix spelling", + "ai.fix_spelling_hint": "Fixes spelling and grammar", "ai.frontmatter": "Generate frontmatter", "ai.generate": "Generate", "ai.hint_add_details": "Adds details", @@ -113,16 +114,20 @@ "ai.rewrite": "💬 Rewrite", "ai.rewrite_done": "AI: text rewritten", "ai.rewrite_prompt": "Rewrite instruction:", + "ai.rewrite_placeholder": "e.g. make the tone more formal and add a conclusion", "ai.rewrite_title": "Rewrite text according to your instructions", "ai.select_text": "AI: select text to process", "ai.shorter": "Shorter", + "ai.shorter_hint": "Makes the text more concise", "ai.simplify": "Simplify", + "ai.simplify_hint": "Simplifies the language", "ai.summarize": "Summarize", "ai.text_processed": "AI: text processed ✓", "ai.text_rewritten": "AI: text rewritten", "ai.to_canvas": "Convert to Canvas", "ai.to_list": "Convert to list", "ai.to_table": "Convert to table", + "ai.tone_hint": "Changes the text tone", "ai.toolbar_edit": "Edit", "ai.toolbar_generate": "Generate", "ai.toolbar_rewrite": "Rewrite", @@ -130,6 +135,7 @@ "ai.toolbar_toolbox": "Toolbox", "ai.toolbar_translate": "Translate", "ai.toolbox": "🧰 Toolbox", + "ai.toolbox_hint": "Conversion tools", "ai.translate_to": "Translate to {lang}", "auth.cannot_delete_self": "Cannot delete your own account", "auth.logged_out": "Signed out", @@ -624,6 +630,8 @@ "header.menu_about_desc": "Version, credits & info", "header.menu_admin": "Administration", "header.menu_admin_desc": "Manage users", + "header.menu_api": "API", + "header.menu_api_desc": "OpenAPI documentation", "header.menu_config": "Settings", "header.menu_config_desc": "Application settings", "header.menu_help": "User guide", @@ -1597,6 +1605,7 @@ "bookslm.export": "Export conversation", "bookslm.toggle_sidebar": "Hide/Show AI sidebar", "bookslm.copy": "Copy", + "bookslm.error": "AI service error", "bookslm.regenerate": "Regenerate", "bookslm.suggestion_summary": "Summarize this directory", "bookslm.suggestion_themes": "What are the main themes?", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index ddba688..d6df39e 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -87,6 +87,7 @@ "ai.error_invalid_key": "clé API invalide", "ai.explain": "Expliquer", "ai.fix_spelling": "Corriger l'orthographe", + "ai.fix_spelling_hint": "Corrige les fautes", "ai.frontmatter": "Générer frontmatter", "ai.generate": "Générer", "ai.hint_add_details": "Ajoute des détails", @@ -113,16 +114,20 @@ "ai.rewrite": "💬 Réécrire", "ai.rewrite_done": "AI: texte réécrit", "ai.rewrite_prompt": "Instruction de réécriture :", + "ai.rewrite_placeholder": "Ex. : rends le ton plus formel et ajoute une conclusion", "ai.rewrite_title": "Réécrit le texte selon vos instructions", "ai.select_text": "AI: sélectionnez du texte à traiter", "ai.shorter": "Raccourcir", + "ai.shorter_hint": "Rend le texte plus concis", "ai.simplify": "Simplifier", + "ai.simplify_hint": "Simplifie le langage", "ai.summarize": "Résumer", "ai.text_processed": "AI: texte traité ✓", "ai.text_rewritten": "AI: texte réécrit", "ai.to_canvas": "Convertir en Canvas", "ai.to_list": "Convertir en liste", "ai.to_table": "Convertir en tableau", + "ai.tone_hint": "Change le ton du texte", "ai.toolbar_edit": "Éditer", "ai.toolbar_generate": "Générer", "ai.toolbar_rewrite": "Réécrire", @@ -130,6 +135,7 @@ "ai.toolbar_toolbox": "Toolbox", "ai.toolbar_translate": "Traduire", "ai.toolbox": "🧰 Boîte", + "ai.toolbox_hint": "Outils de conversion", "ai.translate_to": "Traduire en {lang}", "auth.cannot_delete_self": "Impossible de supprimer son propre compte", "auth.logged_out": "Déconnecté", @@ -624,6 +630,8 @@ "header.menu_about_desc": "Version, crédits & info", "header.menu_admin": "Administration", "header.menu_admin_desc": "Gérer les utilisateurs", + "header.menu_api": "API", + "header.menu_api_desc": "Documentation OpenAPI", "header.menu_config": "Configurations", "header.menu_config_desc": "Paramètres de l'application", "header.menu_help": "Guide d'utilisation", @@ -1597,6 +1605,7 @@ "bookslm.export": "Exporter la conversation", "bookslm.toggle_sidebar": "Masquer/Afficher la sidebar AI", "bookslm.copy": "Copier", + "bookslm.error": "Erreur du service AI", "bookslm.regenerate": "Régénérer", "bookslm.suggestion_summary": "Résume ce répertoire", "bookslm.suggestion_themes": "Quels sont les thèmes principaux ?", diff --git a/frontend/style.css b/frontend/style.css index 316e7d8..798e985 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -2675,6 +2675,88 @@ select { 50% { opacity: 0.6; } } +/* AI custom-rewrite modal — replaces the browser prompt() for a consistent, + theme-aware and keyboard-accessible experience. */ +.ai-modal-overlay { + position: fixed; + inset: 0; + z-index: 10050; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(2px); +} + +.ai-modal { + width: 100%; + max-width: 460px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 18px 18px 14px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); +} + +.ai-modal-title { + margin: 0 0 4px; + font-size: 0.95rem; + color: var(--text-primary); +} + +.ai-modal-hint { + margin: 0 0 10px; + font-size: 0.78rem; + color: var(--text-muted); +} + +.ai-modal-input { + width: 100%; + resize: vertical; + min-height: 72px; + padding: 8px 10px; + font-family: inherit; + font-size: 0.85rem; + color: var(--text-primary); + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 6px; +} + +.ai-modal-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.25); +} + +.ai-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} + +.ai-modal-btn { + padding: 6px 14px; + font-size: 0.8rem; + border-radius: 6px; + cursor: pointer; + border: 1px solid var(--border); + background: transparent; + color: var(--text-primary); +} + +.ai-modal-btn:hover { background: var(--bg-hover); } + +.ai-modal-btn.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.ai-modal-btn.primary:hover { filter: brightness(1.08); } + .editor-title { font-family: "JetBrains Mono", monospace; font-size: 0.9rem; diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs new file mode 100644 index 0000000..dce2229 --- /dev/null +++ b/tests/frontend/ai.test.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * ObsiGate - JSDOM integration tests for the AI tooling (#72 / AI UX). + * + * Covers the regressions fixed for the AI editor toolbar and BooksLM: + * - custom-rewrite modal replaces window.prompt() + * - Ctrl/Cmd+J inline-completion shortcut is bound exactly once + * - BooksLM sends the user message (not the empty placeholder) and parses + * the `{token}` SSE frames streamed by the backend + * - BooksLM uses the authenticated request helper + * + * Usage: node tests/frontend/ai.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js"); + +// ── JSDOM bootstrap ──────────────────────────────────────────────────────── +const dom = new JSDOM( + ` + + +
+
+ +`, + { url: "http://localhost/", pretendToBeVisual: true } +); + +const w = dom.window; +globalThis.window = w; +globalThis.document = w.document; +globalThis.HTMLElement = w.HTMLElement; +globalThis.Element = w.Element; +globalThis.Node = w.Node; +globalThis.Event = w.Event; +globalThis.CustomEvent = w.CustomEvent; +globalThis.KeyboardEvent = w.KeyboardEvent; +globalThis.MessageEvent = w.MessageEvent; +globalThis.localStorage = w.localStorage; +globalThis.sessionStorage = w.sessionStorage; +Object.defineProperty(globalThis, "navigator", { + value: w.navigator, + configurable: true, + writable: true, +}); +globalThis.MutationObserver = w.MutationObserver; +globalThis.getComputedStyle = w.getComputedStyle.bind(w); +globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); +globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + +// ── Test harness ─────────────────────────────────────────────────────────── +let testCount = 0; +let passCount = 0; + +async function test(name, fn) { + testCount++; + try { + await fn(); + console.log(` \u2713 ${name}`); + passCount++; + } catch (e) { + console.log(` \u2717 ${name}`); + console.log(` ${e.message}`); + } +} + +function freshToolbarContainer() { + const container = document.createElement("div"); + container.id = "ai-toolbar-container"; + document.body.appendChild(container); + return container; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ── Main ─────────────────────────────────────────────────────────────────── +async function main() { + // A minimal fetch mock: AI status reports one available provider and + // inline-complete counts how many times it was invoked. + let inlineCompleteCalls = 0; + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("/api/ai/status")) { + return { ok: true, status: 200, json: async () => ({ configured: true, providers: { deepseek: { available: true, model: "deepseek-chat" } } }) }; + } + if (u.includes("/api/config/ai-models")) { + return { ok: true, status: 200, json: async () => ({ models: ["deepseek-chat"], source: "fallback" }) }; + } + if (u.includes("/api/ai/inline-complete")) { + inlineCompleteCalls++; + return { ok: true, status: 200, json: async () => ({ result: "complété", provider: "deepseek" }) }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }; + + const ai = await import(pathToFileURL(path.join(JS_DIR, "ai.js")).href); + const bookslmMod = await import(pathToFileURL(path.join(JS_DIR, "bookslm.js")).href); + const { BooksLM } = bookslmMod; + + // ── 1. Rewrite modal resolves with the typed instruction ── + await test("rewrite modal returns the typed instruction on confirm", async () => { + const promise = ai._promptRewriteInstruction(); + const overlay = document.querySelector(".ai-modal-overlay"); + assert.ok(overlay, "modal overlay should be rendered"); + const input = overlay.querySelector(".ai-modal-input"); + input.value = "Rends le ton plus formel"; + overlay.querySelector(".ai-modal-ok").click(); + const result = await promise; + assert.equal(result, "Rends le ton plus formel"); + assert.equal(document.querySelector(".ai-modal-overlay"), null, "overlay removed"); + }); + + // ── 2. Rewrite modal cancels on Escape ── + await test("rewrite modal returns null on Escape", async () => { + const promise = ai._promptRewriteInstruction(); + document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + const result = await promise; + assert.equal(result, null); + }); + + // ── 3. Ctrl+J inline completion fires exactly once after multiple toolbar builds ── + await test("Ctrl+J shortcut is bound once across toolbar re-creations", async () => { + const view = { + state: { + selection: { main: { from: 0, to: 5 } }, + doc: { toString: () => "hello", sliceString: () => "hello" }, + }, + dispatch: () => {}, + }; + inlineCompleteCalls = 0; + await ai.createAIToolbar(freshToolbarContainer(), () => view); + await ai.createAIToolbar(freshToolbarContainer(), () => view); + + document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "j", ctrlKey: true, bubbles: true })); + await sleep(20); + assert.equal(inlineCompleteCalls, 1, `expected 1 inline-complete call, got ${inlineCompleteCalls}`); + }); + + // ── 4. BooksLM sends the user message and parses {token} SSE frames ── + await test("BooksLM streams assistant tokens and sends the user message", async () => { + const sse = + 'event: message\ndata: {"token":"Bonjour le monde","provider":"deepseek","model":"deepseek-chat"}\n\n' + + "event: done\ndata: {}\n\n"; + + let capturedBody = null; + const encoder = new TextEncoder(); + const chunks = [encoder.encode(sse)]; + const reader = { + read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }), + }; + globalThis.fetch = async (url, opts) => { + if (String(url).includes("/api/ai/bookslm/chat")) { + capturedBody = JSON.parse(opts.body); + return { ok: true, status: 200, body: { getReader: () => reader } }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }; + + const panel = document.createElement("div"); + panel.innerHTML = ` +
+
+ + `; + document.body.appendChild(panel); + + const b = new BooksLM(); + b._panel = panel; + b._vault = "TestVault"; + b._directory = "notes"; + b._contextFiles = [{ path: "notes/a.md" }]; + panel.querySelector("textarea").value = "Résume ce dossier"; + + await b._sendMessage(); + + const assistant = b._messages[b._messages.length - 1]; + assert.equal(assistant.role, "assistant"); + assert.equal(assistant.content, "Bonjour le monde", "assistant token should be rendered"); + assert.equal(capturedBody.message, "Résume ce dossier", "current user message must be sent"); + assert.equal(capturedBody.vault, "TestVault"); + assert.deepEqual(capturedBody.conversation_history, [], "history excludes the current message"); + panel.remove(); + }); + + // ── 5. Source guards: no window.prompt() and authenticated BooksLM ── + await test("ai.js no longer uses window.prompt()", async () => { + const { readFileSync } = await import("node:fs"); + const raw = readFileSync(path.join(JS_DIR, "ai.js"), "utf-8"); + const code = raw.replace(/\/\*[\s\S]*?\*\//g, "").split("\n").map((l) => l.replace(/\/\/.*$/, "")).join("\n"); + assert.ok(!/\bprompt\s*\(/.test(code), "ai.js should not call prompt()"); + assert.ok(code.includes("_promptRewriteInstruction"), "rewrite modal helper present"); + }); + + await test("bookslm.js uses the authenticated API helper", async () => { + const { readFileSync } = await import("node:fs"); + const src = readFileSync(path.join(JS_DIR, "bookslm.js"), "utf-8"); + assert.ok(/import\s*\{[^}]*AuthManager[^}]*\}\s*from\s*['"]\.\/auth\.js['"]/.test(src), "AuthManager imported"); + assert.ok(src.includes("data.token"), "SSE token field parsed"); + }); + + // ── 6. Every t() key used by the AI tooling exists in FR and EN ── + await test("AI/BooksLM i18n keys exist in fr.json and en.json", async () => { + const { readFileSync } = await import("node:fs"); + const localesDir = path.resolve(JS_DIR, "..", "locales"); + const fr = JSON.parse(readFileSync(path.join(localesDir, "fr.json"), "utf-8")); + const en = JSON.parse(readFileSync(path.join(localesDir, "en.json"), "utf-8")); + + const keys = new Set(); + for (const file of ["ai.js", "bookslm.js"]) { + const src = readFileSync(path.join(JS_DIR, file), "utf-8"); + for (const m of src.matchAll(/\bt\(\s*['"]([a-z0-9_.]+)['"]/g)) { + keys.add(m[1]); + } + } + assert.ok(keys.size > 20, "should discover AI translation keys"); + const missingFr = [...keys].filter((k) => !(k in fr)); + const missingEn = [...keys].filter((k) => !(k in en)); + assert.deepEqual(missingFr, [], `missing FR keys: ${missingFr.join(", ")}`); + assert.deepEqual(missingEn, [], `missing EN keys: ${missingEn.join(", ")}`); + }); + + // ── Summary ── + console.log(`\n${passCount}/${testCount} tests passed`); + if (passCount !== testCount) { + process.exit(1); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/tests/test_openapi.py b/tests/test_openapi.py new file mode 100644 index 0000000..0d10ba7 --- /dev/null +++ b/tests/test_openapi.py @@ -0,0 +1,243 @@ +"""Tests for the enriched OpenAPI 3.1 documentation (#72). + +These tests validate the *documentation contract*: the schema is valid +OpenAPI 3.1, every operation is tagged, examples/security/error responses are +present, the representative endpoints expose response schemas, and the +interactive documentation pages are reachable. +""" + +import json + +import pytest + +from backend.main import app +from backend.openapi_docs import TAGS_METADATA, canonical_tag, tag_for_path + + +@pytest.fixture(scope="module") +def schema() -> dict: + """The enriched OpenAPI schema (cached by FastAPI).""" + return app.openapi() + + +def _operations(schema: dict): + for path, ops in schema.get("paths", {}).items(): + for method, op in ops.items(): + if isinstance(op, dict): + yield path, method, op + + +# --------------------------------------------------------------------------- +# Schema validity & metadata +# --------------------------------------------------------------------------- + + +def test_openapi_is_version_3_1(schema): + assert schema["openapi"] == "3.1.0" + + +def test_info_metadata(schema): + info = schema["info"] + assert info["title"] == "ObsiGate API" + assert info.get("description") + assert info.get("contact") + assert info.get("license") + + +def test_servers_and_external_docs(schema): + assert schema.get("servers") + assert schema.get("externalDocs", {}).get("url") + + +# --------------------------------------------------------------------------- +# Tags +# --------------------------------------------------------------------------- + + +def test_tags_metadata_declared(schema): + declared = [t["name"] for t in schema["tags"]] + assert declared == [t["name"] for t in TAGS_METADATA] + assert "Files" in declared + assert "Search" in declared + assert "AI" in declared + assert "Backups" in declared + assert "Auth" in declared + + +def test_every_operation_has_a_tag(schema): + untagged = [(m, p) for p, m, op in _operations(schema) if not op.get("tags")] + assert untagged == [] + + +def test_used_tags_are_declared(schema): + declared = {t["name"] for t in schema["tags"]} + used = {tag for _, _, op in _operations(schema) for tag in op.get("tags", [])} + assert used <= declared, f"undeclared tags: {used - declared}" + + +def test_router_tags_are_canonicalised(schema): + # Routers declare lowercase tags (e.g. tags=["auth"]); the enrichment must + # map them to the documented, capitalised names. + auth_ops = [op for p, _, op in _operations(schema) if p.startswith("/api/auth")] + assert auth_ops + assert all(op["tags"] == ["Auth"] for op in auth_ops) + + +@pytest.mark.parametrize( + "path,expected", + [ + ("/api/file/{vault_name}/pdf/stream", "PDF"), + ("/api/file/{vault_name}/backups", "Backups"), + ("/api/file/{vault_name}", "Files"), + ("/api/search/advanced", "Search"), + ("/api/config/ai-models", "AI"), + ("/api/ai/bookslm/chat", "BooksLM"), + ("/api/vaults/status", "Vaults"), + ("/api/recent", "Bookmarks"), + ("/api/webhooks", "Webhooks"), + ("/s/{token}", "Sharing"), + ("/api/conflicts", "Conflicts"), + ("/api/health", "System"), + ], +) +def test_tag_for_path_rules(path, expected): + assert tag_for_path(path) == expected + + +def test_canonical_tag_helper(): + assert canonical_tag("auth") == "Auth" + assert canonical_tag("booksLM") == "BooksLM" + assert canonical_tag("unknown") == "unknown" + + +# --------------------------------------------------------------------------- +# Security, errors & examples +# --------------------------------------------------------------------------- + + +def test_security_schemes(schema): + schemes = schema["components"]["securitySchemes"] + assert schemes["bearerAuth"]["scheme"] == "bearer" + assert schemes["cookieAuth"]["in"] == "cookie" + + +def test_common_error_responses_documented(schema): + # Most authenticated operations document the standard error envelope. + with_401 = [op for _, _, op in _operations(schema) if "401" in op.get("responses", {})] + with_404 = [op for _, _, op in _operations(schema) if "404" in op.get("responses", {})] + assert len(with_401) > 50 + assert len(with_404) > 30 + + +def test_key_endpoint_examples_present(schema): + improve = schema["paths"]["/api/ai/improve"]["post"] + example = improve["requestBody"]["content"]["application/json"]["example"] + assert example["provider"] == "deepseek" + assert improve["responses"]["200"]["content"]["application/json"]["example"]["result"] + + create = schema["paths"]["/api/file/{vault_name}"]["post"] + assert "content" in create["requestBody"]["content"]["application/json"]["example"] + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + +_RESPONSE_MODEL_PATHS = [ + ("/api/recent", "get"), + ("/api/bookmarks", "get"), + ("/api/bookmarks/toggle", "post"), + ("/api/saved-searches", "get"), + ("/api/saved-searches", "post"), + ("/api/file/{vault_name}/backups", "get"), + ("/api/file/{vault_name}/diff", "get"), + ("/api/file/{vault_name}/restore", "post"), + ("/api/file/{vault_name}/backlinks", "get"), + ("/api/file/{vault_name}/pdf/info", "get"), + ("/api/search/replace", "post"), + ("/api/index/reload/{vault_name}", "get"), + ("/api/vaults/add", "post"), + ("/api/vaults/status", "get"), + ("/api/attachments/stats", "get"), + ("/api/vaults/{vault_name}/settings", "get"), + ("/api/vault/{vault_name}/files", "get"), + ("/api/vaults/settings/all", "get"), + ("/api/backups", "get"), + ("/api/backups/content", "get"), + ("/api/config", "get"), + ("/api/config/ai-models", "get"), + ("/api/diagnostics", "get"), + ("/api/dashboard", "get"), + ("/api/webhooks", "get"), + ("/api/shares", "get"), + ("/api/conflicts", "get"), + ("/api/conflicts/resolve", "post"), +] + + +@pytest.mark.parametrize("path,method", _RESPONSE_MODEL_PATHS) +def test_json_response_schema_present(schema, path, method): + op = schema["paths"][path][method] + content = op["responses"]["200"].get("content", {}) + assert "application/json" in content, f"{method.upper()} {path} has no JSON response schema" + assert "schema" in content["application/json"] + + +def test_binary_endpoints_do_not_claim_json(schema): + for path in ["/api/file/{vault_name}/download", "/api/file/{vault_name}/pdf/stream"]: + op = schema["paths"][path]["get"] + content = op["responses"]["200"].get("content", {}) + assert "application/json" not in content + + +def test_error_response_component(schema): + assert "ErrorResponse" in schema["components"]["schemas"] + + +# --------------------------------------------------------------------------- +# Interactive documentation endpoints +# --------------------------------------------------------------------------- + + +def test_api_landing_page(client): + resp = client.get("/api") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + body = resp.text + assert "/docs" in body + assert "/redoc" in body + assert "/openapi.json" in body + + +def test_openapi_json_endpoint(client): + resp = client.get("/openapi.json") + assert resp.status_code == 200 + data = resp.json() + assert data["openapi"] == "3.1.0" + assert data["info"]["title"] == "ObsiGate API" + + +def test_swagger_and_redoc_reachable(client): + assert client.get("/docs").status_code == 200 + assert client.get("/redoc").status_code == 200 + + +def test_schema_is_json_serialisable(schema): + # Guards against non-serialisable objects leaking into the schema. + json.dumps(schema) + + +# --------------------------------------------------------------------------- +# BooksLM context limits (used by the UI progress bar) +# --------------------------------------------------------------------------- + + +def test_bookslm_context_exposes_limits(test_vault_dir): + from backend.bookslm import collect_directory_context + + from pathlib import Path + + ctx = collect_directory_context(Path(test_vault_dir), "") + assert "max_total_chars" in ctx + assert "max_files" in ctx + assert ctx["max_total_chars"] > 0