diff --git a/CHANGELOG.md b/CHANGELOG.md index 6647d4b..c725163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.27.5**. +> [Unreleased](#unreleased). La dernière version livrée est **2.27.6**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.27.6] — 2026-09-26 + +--- + ## [2.27.5] — 2026-09-26 --- @@ -30,6 +34,13 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Modifié +- **#85 (T5) — extraction du domaine `search` hors du monolithe `backend/main.py`.** + Les 11 routes (`/api/search`, `/advanced`, `/replace`, `/tags`, + `/tree-search`, `/vault/{vault}/paths`, `/suggest`, `/tags/suggest`, + `/graph/{vault}`, `/index/reload`, `/index/reload/{vault}`) sont servies + par le nouveau `backend/routers/search.py` ; les modèles search dans + `schemas.py` et le pool de threads dans `backend/search_executor.py` + (même dimensionnement, même cycle de vie) — comportement inchangé. - **#85 (T4) — extraction du domaine `backups` hors du monolithe `backend/main.py`.** Les 9 routes (`/api/file/{vault}/backups|diff|restore`, `/api/backups`, `/delete`, `/purge`, `/content`, `/compress`, `/auto`) sont servies par le diff --git a/README.fr.md b/README.fr.md index 3dbc512..11f8310 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.27.5-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.6-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.5). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.6). --- -*Projet : ObsiGate | Version : 2.27.5 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.27.6 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 1483eaa..e2c3b36 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.27.5-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.6-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.5). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.6). --- -*Project: ObsiGate | Version: 2.27.5 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.27.6 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 474d182..9b38703 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.27.5 +2.27.6 diff --git a/backend/main.py b/backend/main.py index 4298c6a..7c366ba 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,9 +8,7 @@ import secrets import shutil import string import urllib.request -from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager -from functools import partial from pathlib import Path from typing import Any from urllib.parse import quote @@ -45,7 +43,6 @@ from backend.indexer import ( handle_file_move, index, parse_markdown_file, - reload_index, remove_single_file, remove_vault_from_index, update_single_file, @@ -76,7 +73,6 @@ from backend.schemas import ( DiagnosticsResponse, PdfInfoResponse, RecentResponse, - ReplaceResponse, SavedSearch, StatusResponse, VaultActionResponse, @@ -87,14 +83,11 @@ from backend.schemas import ( ) from backend.search import ( init_inverted_index, - suggest_tags, - suggest_titles, ) from backend.semantic_search import init_semantic_index from backend.services.backups import get_backup_dir as service_get_backup_dir from backend.services.errors import ServiceError from backend.services.files import read_raw_file -from backend.services.graph import get_graph as service_get_graph from backend.services.mutations import ( batch_upload_files as service_batch_upload_files, ) @@ -125,13 +118,8 @@ from backend.services.mutations import ( from backend.services.mutations import ( rename_file as service_rename_file, ) -from backend.services.mutations import ( - replace_in_files as service_replace_in_files, -) from backend.services.recent import humanize_mtime, list_recent from backend.services.sanitizer import sanitize_html -from backend.services.search import advanced_search_vaults, list_paths, search_paths, search_vaults -from backend.services.search import list_tags as service_list_tags from backend.services.vaults import ( browse_directory, list_accessible_vaults, @@ -234,154 +222,8 @@ class FileDeleteResponse(BaseModel): path: str = Field(description="Relative file path within the vault") -class SearchResultItem(BaseModel): - """A single search result.""" - vault: str = Field(description="Vault name") - path: str = Field(description="Relative file path") - title: str = Field(description="File title") - tags: list[str] = Field(description="File tags") - score: int = Field(description="Relevance score") - snippet: str = Field(description="Content excerpt with highlights") - modified: str = Field(description="ISO 8601 modification timestamp") - - -class SearchResponse(BaseModel): - """Full-text search response with optional pagination.""" - query: str = Field(description="Original search query") - vault_filter: str = Field(description="Vault filter applied ('all' or vault name)") - tag_filter: str | None = Field(default=None, description="Tag filter applied") - count: int = Field(description="Number of results in this response") - total: int = Field(default=0, description="Total results before pagination") - offset: int = Field(default=0, description="Current pagination offset") - limit: int = Field(default=200, description="Page size") - results: list[SearchResultItem] = Field(description="Search result items") - - -class TagsResponse(BaseModel): - """Tag aggregation response.""" - vault_filter: str | None = Field(default=None, description="Vault filter applied") - tags: dict[str, int] = Field(description="Tag name → count mapping") - - -class TreeSearchResult(BaseModel): - """A single tree search result item.""" - vault: str = Field(description="Vault name") - path: str = Field(description="Full relative path") - name: str = Field(description="File or directory name") - type: str = Field(description="'file' or 'directory'") - matched_path: str = Field(description="Path segment that matched the query") - - -class TreeSearchResponse(BaseModel): - """Tree search response with matching paths.""" - query: str = Field(description="Search query") - vault_filter: str = Field(description="Vault filter applied") - results: list[TreeSearchResult] = Field(description="Matching files and directories") - - -class VaultPathEntry(BaseModel): - """A single indexed path (file or directory) in a vault.""" - vault: str = Field(description="Vault name") - path: str = Field(description="Full relative path") - name: str = Field(description="File or directory name") - type: str = Field(description="'file' or 'directory'") - - -class VaultPathsResponse(BaseModel): - """Flat list of every indexed path in a vault (capped).""" - vault: str = Field(description="Vault name") - count: int = Field(description="Number of returned entries") - results: list[VaultPathEntry] = Field(description="Indexed files and directories") - - -class AdvancedSearchResultItem(BaseModel): - """A single advanced search result with highlighted snippet.""" - vault: str = Field(description="Vault name") - path: str = Field(description="Relative file path") - title: str = Field(description="File title") - tags: list[str] = Field(description="File tags") - score: float = Field(description="TF-IDF relevance score (or fused RRF score in semantic mode)") - semantic_score: float = Field(default=0.0, description="Cosine similarity from the semantic index (0 when unavailable)") - snippet: str = Field(description="Content excerpt with highlights") - modified: str = Field(description="ISO 8601 modification timestamp") - extension: str = Field(default="", description="File extension") - - -class SearchFacets(BaseModel): - """Faceted counts for search results.""" - tags: dict[str, int] = Field(default_factory=dict) - vaults: dict[str, int] = Field(default_factory=dict) - - -class AdvancedSearchResponse(BaseModel): - """Advanced search response with TF-IDF scoring, facets, and pagination.""" - results: list[AdvancedSearchResultItem] = Field(description="Search results") - total: int = Field(description="Total number of matching results") - offset: int = Field(description="Current pagination offset") - limit: int = Field(description="Page size") - facets: SearchFacets = Field(description="Faceted counts by tag and vault") - query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds") - semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready") - - -class TitleSuggestion(BaseModel): - """A file title suggestion for autocomplete.""" - vault: str = Field(description="Vault name") - path: str = Field(description="Relative file path") - title: str = Field(description="File title") - - -class SuggestResponse(BaseModel): - """Autocomplete suggestions for file titles.""" - query: str = Field(description="Original query string") - suggestions: list[TitleSuggestion] = Field(description="Matching file suggestions") - - -class TagSuggestion(BaseModel): - """A tag suggestion for autocomplete.""" - tag: str = Field(description="Tag name") - count: int = Field(description="Number of files with this tag") - - -class TagSuggestResponse(BaseModel): - """Autocomplete suggestions for tags.""" - query: str = Field(description="Original query string") - suggestions: list[TagSuggestion] = Field(description="Matching tag suggestions") - - -class GraphNode(BaseModel): - """A single node in the graph view.""" - id: str = Field(description="Unique node identifier") - name: str = Field(description="Display name") - type: str = Field(description="'vault', 'directory', or 'file'") - path: str = Field(description="Relative path within vault") - size: int = Field(default=0, description="File size in bytes") - tags: list[str] = Field(default_factory=list, description="Tags from frontmatter") - incoming_count: int = Field(default=0, description="Number of incoming wikilinks") - outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks") - - -class GraphEdge(BaseModel): - """An edge between two nodes in the graph view.""" - source: str = Field(description="Source node ID") - target: str = Field(description="Target node ID") - relation: str = Field(description="'parent', 'wikilink', or 'backlink'") - - -class GraphResponse(BaseModel): - """Graph data for a vault or directory.""" - vault: str = Field(description="Vault name") - path: str = Field(description="Root path for the graph") - scope: str = Field(default="directory", description="'directory' or 'full'") - nodes: list[GraphNode] = Field(description="Graph nodes (files and directories)") - edges: list[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)") - - -class ReloadResponse(BaseModel): - """Index reload confirmation with per-vault stats.""" - status: str = Field(description="Reload status ('ok' or 'error')") - vaults: dict[str, Any] = Field(description="Per-vault file counts after reload") - +# Search / suggest / graph models : voir backend.schemas (#85 T5) +# --------------------------------------------------------------------------- class DirectoryCreateRequest(BaseModel): """Request to create a new directory.""" @@ -503,6 +345,11 @@ class DiffRequest(BaseModel): # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- +from backend.search_executor import ( + get_search_executor, + init_search_executor, + shutdown_search_executor, +) from backend.sse import sse_manager # --------------------------------------------------------------------------- @@ -510,9 +357,7 @@ from backend.sse import sse_manager # --------------------------------------------------------------------------- from backend.watcher import VaultWatcher -# Thread pool for offloading CPU-bound search from the event loop. -# Sized to 2 workers so concurrent searches don't starve other requests. -_search_executor: ThreadPoolExecutor | None = None +# File watcher handle (the search thread pool lives in backend.search_executor). _vault_watcher: VaultWatcher | None = None @@ -694,8 +539,10 @@ def _guard_insecure_auth() -> None: @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan: build index on startup, cleanup on shutdown.""" - global _search_executor, _vault_watcher - _search_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="search") + global _vault_watcher + # Thread pool for offloading CPU-bound search from the event loop. + # Sized to 2 workers so concurrent searches don't starve other requests. + init_search_executor() # BUG-037: refuse to expose an unauthenticated instance on a public bind. _guard_insecure_auth() @@ -716,9 +563,9 @@ async def lifespan(app: FastAPI): # The inverted index rebuild is CPU-bound (tokenization, indexing) and # would freeze HTTP responses if run in the async event loop. loop = asyncio.get_running_loop() - await loop.run_in_executor(_search_executor, init_inverted_index) + await loop.run_in_executor(get_search_executor(), init_inverted_index) # Build the semantic (embedding) index in the same background thread pool. - await loop.run_in_executor(_search_executor, init_semantic_index) + await loop.run_in_executor(get_search_executor(), init_semantic_index) # BUG-040: extract the PDF text deferred during the scan now that the # index and inverted index are queryable (keeps startup non-blocking). @@ -774,8 +621,7 @@ async def lifespan(app: FastAPI): if _vault_watcher: await _vault_watcher.stop() _vault_watcher = None - _search_executor.shutdown(wait=False) - _search_executor = None + shutdown_search_executor() from backend.version import get_version @@ -872,6 +718,7 @@ from backend.bookslm_routes import router as bookslm_router from backend.export import ExportError, export_epub, export_html, export_md_bundle from backend.routers.backups import router as backups_router from backend.routers.health import router as health_router +from backend.routers.search import router as search_router from backend.routers.sharing import router as sharing_router from backend.routers.webhooks import router as webhooks_router from backend.saved_searches import delete_saved, get_saved, save_search @@ -884,6 +731,7 @@ app.include_router(ai_router) app.include_router(bookslm_router) app.include_router(skills_router) app.include_router(health_router) # ROADMAP #85 T1 — System / health +app.include_router(search_router) # ROADMAP #85 T5 — Search app.include_router(backups_router) # ROADMAP #85 T4 — Backups app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing @@ -2577,305 +2425,9 @@ async def api_pdf_info( } -@app.get("/api/search", response_model=SearchResponse) -async def api_search( - q: str = Query("", description="Search query"), - vault: str = Query("all", description="Vault filter"), - tag: str | None = Query(None, description="Tag filter"), - limit: int = Query(50, ge=1, le=200, description="Results per page"), - offset: int = Query(0, ge=0, description="Pagination offset"), - current_user=Depends(require_auth), -): - """Full-text search across vaults with relevance scoring. - - Supports combining free-text queries with tag filters. - Results are ranked by a multi-factor scoring algorithm. - Pagination via ``limit`` and ``offset`` (defaults preserve backward compat). - - Args: - q: Free-text search string. - vault: Vault name or ``"all"`` to search everywhere. - tag: Comma-separated tag names to require. - limit: Max results per page (1–200). - offset: Pagination offset. - - Returns: - ``SearchResponse`` with ranked results and snippets. - """ - loop = asyncio.get_event_loop() - # Fetch the full result set (capped at DEFAULT_SEARCH_LIMIT internally) and - # paginate in the shared service so routes and tools share the same logic. - return await loop.run_in_executor( - _search_executor, - partial(search_vaults, q, vault, tag, limit, offset), - ) - - -@app.get("/api/tags", response_model=TagsResponse) -async def api_tags(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)): - """Return all unique tags with occurrence counts. - - Args: - vault: Optional vault name to restrict tag aggregation. - - Returns: - ``TagsResponse`` with tags sorted by descending count. - """ - return {"vault_filter": vault, "tags": service_list_tags(vault)} - - -@app.get("/api/tree-search", response_model=TreeSearchResponse) -async def api_tree_search( - q: str = Query("", description="Search query"), - vault: str = Query("all", description="Vault filter"), - current_user=Depends(require_auth), -): - """Search for files and directories in the tree structure using pre-built index. - - Uses the in-memory path index for instant filtering without filesystem access. - - Args: - q: Search string to match against file/directory paths. - vault: Vault name or "all" to search everywhere. - - Returns: - ``TreeSearchResponse`` with matching paths. - """ - return search_paths(q, vault) - - -@app.get("/api/vault/{vault_name}/paths", response_model=VaultPathsResponse) -async def api_vault_paths( - vault_name: str, - limit: int = Query(5000, ge=1, le=20000, description="Maximum number of indexed paths to return"), - current_user=Depends(require_auth), -): - """Return a flat list of every indexed file and directory in a vault. - - Used by the AI assistant ``@`` mention menu to filter paths instantly on - the client (one request instead of one per keystroke). - - Args: - vault_name: Name of the vault. - limit: Maximum number of entries returned. - - Returns: - ``VaultPathsResponse`` with the vault's indexed paths. - """ - if not check_vault_access(vault_name, current_user): - raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - return list_paths(vault_name, limit=limit) - - -@app.get("/api/search/advanced", response_model=AdvancedSearchResponse) -async def api_advanced_search( - q: str = Query("", description="Advanced search query (supports tag:, vault:, title:, path:, ext: operators)"), - vault: str = Query("all", description="Vault filter"), - tag: str | None = Query(None, description="Comma-separated tag filter"), - limit: int = Query(50, ge=1, le=200, description="Results per page"), - offset: int = Query(0, ge=0, description="Pagination offset"), - sort: str = Query("relevance", description="Sort by 'relevance' or 'modified'"), - case_sensitive: bool = Query(False, description="Match case"), - whole_word: bool = Query(False, description="Match whole words only"), - regex: bool = Query(False, description="Treat query as regex"), - include_paths: str | None = Query(None, description="Comma-separated glob patterns to include"), - exclude_paths: str | None = Query(None, description="Comma-separated glob patterns to exclude"), - created: str | None = Query(None, description="Created date filter (>date, `` or ``#`` — filter by tag - - ``vault:`` — filter by vault - - ``title:`` — filter by title substring - - ``path:`` — filter by path substring - - ``ext:`` — filter by file extension - - ``created:>2024-01-01`` — filter by creation date - - ``modified:<7d`` or ``modified:2024-01-01..2024-06-01`` — filter by modification date - - ``size:>1MB`` or ``size:100KB..1MB`` — filter by file size - - Remaining text is scored using TF-IDF with accent normalization. - - Toggles: case_sensitive, whole_word, regex - - Path filters: include_paths, exclude_paths (glob patterns) - - ``semantic=true`` — fuse the TF-IDF ranking with the semantic (embedding) - ranking via Reciprocal Rank Fusion and expose ``semantic_score`` per result. - - Results include ````-highlighted snippets and faceted tag/vault counts. - """ - loop = asyncio.get_event_loop() - search_fn = partial(advanced_search_vaults, q, vault=vault, tag=tag, - limit=limit, offset=offset, sort=sort, - case_sensitive=case_sensitive, whole_word=whole_word, regex=regex, - include_paths=include_paths, exclude_paths=exclude_paths, - created=created, modified=modified, size=size, semantic=semantic) - try: - return await loop.run_in_executor(_search_executor, search_fn) - except ValueError as e: - raise HTTPException(400, str(e)) from e - - -@app.post("/api/search/replace", response_model=ReplaceResponse) -async def api_search_replace( - body: dict = Body(...), - current_user=Depends(require_auth), -): - """Find and replace across vault files.""" - query = body.get("query", "") - replacement = body.get("replacement", "") - vault_filter = body.get("vault", "all") - case_sensitive = body.get("case_sensitive", False) - whole_word = body.get("whole_word", False) - regex_mode = body.get("regex", False) - include_paths = body.get("include_paths") - exclude_paths = body.get("exclude_paths") - replace_all = body.get("replace_all", False) - dry_run = body.get("dry_run", not replace_all) - - if not query: - raise HTTPException(400, "Query is required") - - result = service_replace_in_files( - query, - replacement, - vault=vault_filter, - case_sensitive=case_sensitive, - whole_word=whole_word, - regex=regex_mode, - include_paths=include_paths, - exclude_paths=exclude_paths, - replace_all=replace_all, - dry_run=dry_run, - is_vault_allowed=lambda v: check_vault_access(v, current_user), - ) - - if dry_run: - return result - - # Side effects for applied replacements (audit + incremental index). - for match in result.get("replaced", []): - log_file_save(current_user["username"], match["vault"], match["path"], match.get("size", 0)) - vault_data = get_vault_data(match["vault"]) - if vault_data: - abs_path = str(Path(vault_data["path"]) / match["path"]) - await update_single_file(match["vault"], abs_path) - - return result - - -@app.get("/api/suggest", response_model=SuggestResponse) -async def api_suggest( - q: str = Query("", description="Prefix to search for in file titles"), - vault: str = Query("all", description="Vault filter"), - limit: int = Query(10, ge=1, le=50, description="Max suggestions"), - current_user=Depends(require_auth), -): - """Suggest file titles matching a prefix (accent-insensitive). - - Used for autocomplete in the search input. - - Args: - q: User-typed prefix (minimum 2 characters). - vault: Vault name or ``"all"``. - limit: Max number of suggestions. - - Returns: - ``SuggestResponse`` with matching file title suggestions. - """ - suggestions = suggest_titles(q, vault_filter=vault, limit=limit) - return {"query": q, "suggestions": suggestions} - - -@app.get("/api/tags/suggest", response_model=TagSuggestResponse) -async def api_tags_suggest( - q: str = Query("", description="Prefix to search for in tags"), - vault: str = Query("all", description="Vault filter"), - limit: int = Query(10, ge=1, le=50, description="Max suggestions"), - current_user=Depends(require_auth), -): - """Suggest tags matching a prefix (accent-insensitive). - - Used for autocomplete when typing ``tag:`` or ``#`` in the search input. - - Args: - q: User-typed prefix (with or without ``#``, minimum 2 characters). - vault: Vault name or ``"all"``. - limit: Max number of suggestions. - - Returns: - ``TagSuggestResponse`` with matching tag suggestions and counts. - """ - suggestions = suggest_tags(q, vault_filter=vault, limit=limit) - return {"query": q, "suggestions": suggestions} - - -@app.get("/api/index/reload", response_model=ReloadResponse) -async def api_reload(current_user=Depends(require_admin)): - """Force a full re-index of all configured vaults. - - Returns: - ``ReloadResponse`` with per-vault file and tag counts. - """ - stats = await reload_index() - await sse_manager.broadcast("index_reloaded", { - "vaults": list(stats.keys()), - "stats": stats, - }) - return {"status": "ok", "vaults": stats} - - -@app.get("/api/graph/{vault_name}", response_model=GraphResponse) -async def api_graph( - vault_name: str, - path: str = Query("", description="Relative path to focus on"), - depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"), - scope: str = Query("directory", description="'directory' (default) or 'full' for entire vault"), - tag: str = Query("", description="Filter: only show files with this tag"), - current_user=Depends(require_auth), -): - """Return graph data (nodes and edges) for a vault or directory. - - Nodes represent files and directories. Edges represent parent-child - relationships and wikilinks between markdown files. - - Args: - vault_name: Name of the vault. - path: Relative directory path to focus on (empty = root). - depth: Expansion depth (0 = only direct children, 1-3 = deeper). - scope: 'directory' for subtree, 'full' for entire vault. - tag: Optional tag filter (only files with this tag appear). - - Returns: - ``GraphResponse`` with nodes and edges. - """ - if not check_vault_access(vault_name, current_user): - raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - - return service_get_graph(vault_name, path=path, depth=depth, scope=scope, tag=tag) - - -@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. - - Args: - vault_name: Name of the vault to reindex. - - Returns: - Dict with vault statistics. - """ - try: - from backend.indexer import reload_single_vault - stats = await reload_single_vault(vault_name) - await sse_manager.broadcast("vault_reloaded", { - "vault": vault_name, - "stats": stats, - }) - return {"status": "ok", "vault": vault_name, "stats": stats} - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) +# --------------------------------------------------------------------------- +# Search / suggest / graph / index-reload : voir backend.routers.search (#85 T5) +# --------------------------------------------------------------------------- # --------------------------------------------------------------------------- @@ -3769,8 +3321,8 @@ async def api_diagnostics(current_user=Depends(require_admin)): }, "config": _load_config(), "search_executor": { - "active": _search_executor is not None, - "max_workers": _search_executor._max_workers if _search_executor else 0, + "active": get_search_executor() is not None, + "max_workers": get_search_executor()._max_workers if get_search_executor() else 0, }, } diff --git a/backend/routers/search.py b/backend/routers/search.py new file mode 100644 index 0000000..72927e5 --- /dev/null +++ b/backend/routers/search.py @@ -0,0 +1,353 @@ +"""Search, suggest, graph & index-reload endpoints (ROADMAP #85, tranche 5). + +Handlers déplacés depuis :mod:`backend.main` sans changement de +comportement : mêmes chemins, mêmes modèles de réponse (déménagés dans +:mod:`backend.schemas`), mêmes dépendances d'authentification. La logique +métier vit déjà dans :mod:`backend.services.search`, +:mod:`backend.search`, :mod:`backend.services.graph` et +:mod:`backend.services.mutations`. + +Adaptations strictement équivalentes : +- Le pool ``_search_executor`` de ``main`` vit désormais dans + :mod:`backend.search_executor` (même dimensionnement, même cycle de vie + géré par le lifespan de ``main``) : accès via + :func:`get_search_executor`. +""" + +import asyncio +import logging +from functools import partial +from pathlib import Path + +from fastapi import APIRouter, Body, Depends, HTTPException, Query + +from backend.audit import log_file_save +from backend.auth.middleware import check_vault_access, require_admin, require_auth +from backend.indexer import get_vault_data, reload_index, update_single_file +from backend.schemas import ( + AdvancedSearchResponse, + GraphResponse, + ReloadResponse, + ReplaceResponse, + SearchResponse, + SuggestResponse, + TagsResponse, + TagSuggestResponse, + TreeSearchResponse, + VaultPathsResponse, + VaultStatsResponse, +) +from backend.search import suggest_tags, suggest_titles +from backend.search_executor import get_search_executor +from backend.services.graph import get_graph as service_get_graph +from backend.services.mutations import ( + replace_in_files as service_replace_in_files, +) +from backend.services.search import advanced_search_vaults, list_paths, search_paths, search_vaults +from backend.services.search import list_tags as service_list_tags +from backend.sse import sse_manager + +logger = logging.getLogger("obsigate") + +router = APIRouter(tags=["search"]) + + +@router.get("/api/search", response_model=SearchResponse) +async def api_search( + q: str = Query("", description="Search query"), + vault: str = Query("all", description="Vault filter"), + tag: str | None = Query(None, description="Tag filter"), + limit: int = Query(50, ge=1, le=200, description="Results per page"), + offset: int = Query(0, ge=0, description="Pagination offset"), + current_user=Depends(require_auth), +): + """Full-text search across vaults with relevance scoring. + + Supports combining free-text queries with tag filters. + Results are ranked by a multi-factor scoring algorithm. + Pagination via ``limit`` and ``offset`` (defaults preserve backward compat). + + Args: + q: Free-text search string. + vault: Vault name or ``"all"`` to search everywhere. + tag: Comma-separated tag names to require. + limit: Max results per page (1–200). + offset: Pagination offset. + + Returns: + ``SearchResponse`` with ranked results and snippets. + """ + loop = asyncio.get_event_loop() + # Fetch the full result set (capped at DEFAULT_SEARCH_LIMIT internally) and + # paginate in the shared service so routes and tools share the same logic. + return await loop.run_in_executor( + get_search_executor(), + partial(search_vaults, q, vault, tag, limit, offset), + ) + + +@router.get("/api/tags", response_model=TagsResponse) +async def api_tags(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)): + """Return all unique tags with occurrence counts. + + Args: + vault: Optional vault name to restrict tag aggregation. + + Returns: + ``TagsResponse`` with tags sorted by descending count. + """ + return {"vault_filter": vault, "tags": service_list_tags(vault)} + + +@router.get("/api/tree-search", response_model=TreeSearchResponse) +async def api_tree_search( + q: str = Query("", description="Search query"), + vault: str = Query("all", description="Vault filter"), + current_user=Depends(require_auth), +): + """Search for files and directories in the tree structure using pre-built index. + + Uses the in-memory path index for instant filtering without filesystem access. + + Args: + q: Search string to match against file/directory paths. + vault: Vault name or "all" to search everywhere. + + Returns: + ``TreeSearchResponse`` with matching paths. + """ + return search_paths(q, vault) + + +@router.get("/api/vault/{vault_name}/paths", response_model=VaultPathsResponse) +async def api_vault_paths( + vault_name: str, + limit: int = Query(5000, ge=1, le=20000, description="Maximum number of indexed paths to return"), + current_user=Depends(require_auth), +): + """Return a flat list of every indexed file and directory in a vault. + + Used by the AI assistant ``@`` mention menu to filter paths instantly on + the client (one request instead of one per keystroke). + + Args: + vault_name: Name of the vault. + limit: Maximum number of entries returned. + + Returns: + ``VaultPathsResponse`` with the vault's indexed paths. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + return list_paths(vault_name, limit=limit) + + +@router.get("/api/search/advanced", response_model=AdvancedSearchResponse) +async def api_advanced_search( + q: str = Query("", description="Advanced search query (supports tag:, vault:, title:, path:, ext: operators)"), + vault: str = Query("all", description="Vault filter"), + tag: str | None = Query(None, description="Comma-separated tag filter"), + limit: int = Query(50, ge=1, le=200, description="Results per page"), + offset: int = Query(0, ge=0, description="Pagination offset"), + sort: str = Query("relevance", description="Sort by 'relevance' or 'modified'"), + case_sensitive: bool = Query(False, description="Match case"), + whole_word: bool = Query(False, description="Match whole words only"), + regex: bool = Query(False, description="Treat query as regex"), + include_paths: str | None = Query(None, description="Comma-separated glob patterns to include"), + exclude_paths: str | None = Query(None, description="Comma-separated glob patterns to exclude"), + created: str | None = Query(None, description="Created date filter (>date, `` or ``#`` — filter by tag + - ``vault:`` — filter by vault + - ``title:`` — filter by title substring + - ``path:`` — filter by path substring + - ``ext:`` — filter by file extension + - ``created:>2024-01-01`` — filter by creation date + - ``modified:<7d`` or ``modified:2024-01-01..2024-06-01`` — filter by modification date + - ``size:>1MB`` or ``size:100KB..1MB`` — filter by file size + - Remaining text is scored using TF-IDF with accent normalization. + - Toggles: case_sensitive, whole_word, regex + - Path filters: include_paths, exclude_paths (glob patterns) + - ``semantic=true`` — fuse the TF-IDF ranking with the semantic (embedding) + ranking via Reciprocal Rank Fusion and expose ``semantic_score`` per result. + + Results include ````-highlighted snippets and faceted tag/vault counts. + """ + loop = asyncio.get_event_loop() + search_fn = partial(advanced_search_vaults, q, vault=vault, tag=tag, + limit=limit, offset=offset, sort=sort, + case_sensitive=case_sensitive, whole_word=whole_word, regex=regex, + include_paths=include_paths, exclude_paths=exclude_paths, + created=created, modified=modified, size=size, semantic=semantic) + try: + return await loop.run_in_executor(get_search_executor(), search_fn) + except ValueError as e: + raise HTTPException(400, str(e)) from e + + +@router.post("/api/search/replace", response_model=ReplaceResponse) +async def api_search_replace( + body: dict = Body(...), + current_user=Depends(require_auth), +): + """Find and replace across vault files.""" + query = body.get("query", "") + replacement = body.get("replacement", "") + vault_filter = body.get("vault", "all") + case_sensitive = body.get("case_sensitive", False) + whole_word = body.get("whole_word", False) + regex_mode = body.get("regex", False) + include_paths = body.get("include_paths") + exclude_paths = body.get("exclude_paths") + replace_all = body.get("replace_all", False) + dry_run = body.get("dry_run", not replace_all) + + if not query: + raise HTTPException(400, "Query is required") + + result = service_replace_in_files( + query, + replacement, + vault=vault_filter, + case_sensitive=case_sensitive, + whole_word=whole_word, + regex=regex_mode, + include_paths=include_paths, + exclude_paths=exclude_paths, + replace_all=replace_all, + dry_run=dry_run, + is_vault_allowed=lambda v: check_vault_access(v, current_user), + ) + + if dry_run: + return result + + # Side effects for applied replacements (audit + incremental index). + for match in result.get("replaced", []): + log_file_save(current_user["username"], match["vault"], match["path"], match.get("size", 0)) + vault_data = get_vault_data(match["vault"]) + if vault_data: + abs_path = str(Path(vault_data["path"]) / match["path"]) + await update_single_file(match["vault"], abs_path) + + return result + + +@router.get("/api/suggest", response_model=SuggestResponse) +async def api_suggest( + q: str = Query("", description="Prefix to search for in file titles"), + vault: str = Query("all", description="Vault filter"), + limit: int = Query(10, ge=1, le=50, description="Max suggestions"), + current_user=Depends(require_auth), +): + """Suggest file titles matching a prefix (accent-insensitive). + + Used for autocomplete in the search input. + + Args: + q: User-typed prefix (minimum 2 characters). + vault: Vault name or ``"all"``. + limit: Max number of suggestions. + + Returns: + ``SuggestResponse`` with matching file title suggestions. + """ + suggestions = suggest_titles(q, vault_filter=vault, limit=limit) + return {"query": q, "suggestions": suggestions} + + +@router.get("/api/tags/suggest", response_model=TagSuggestResponse) +async def api_tags_suggest( + q: str = Query("", description="Prefix to search for in tags"), + vault: str = Query("all", description="Vault filter"), + limit: int = Query(10, ge=1, le=50, description="Max suggestions"), + current_user=Depends(require_auth), +): + """Suggest tags matching a prefix (accent-insensitive). + + Used for autocomplete when typing ``tag:`` or ``#`` in the search input. + + Args: + q: User-typed prefix (with or without ``#``, minimum 2 characters). + vault: Vault name or ``"all"``. + limit: Max number of suggestions. + + Returns: + ``TagSuggestResponse`` with matching tag suggestions and counts. + """ + suggestions = suggest_tags(q, vault_filter=vault, limit=limit) + return {"query": q, "suggestions": suggestions} + + +@router.get("/api/index/reload", response_model=ReloadResponse) +async def api_reload(current_user=Depends(require_admin)): + """Force a full re-index of all configured vaults. + + Returns: + ``ReloadResponse`` with per-vault file and tag counts. + """ + stats = await reload_index() + await sse_manager.broadcast("index_reloaded", { + "vaults": list(stats.keys()), + "stats": stats, + }) + return {"status": "ok", "vaults": stats} + + +@router.get("/api/graph/{vault_name}", response_model=GraphResponse) +async def api_graph( + vault_name: str, + path: str = Query("", description="Relative path to focus on"), + depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"), + scope: str = Query("directory", description="'directory' (default) or 'full' for entire vault"), + tag: str = Query("", description="Filter: only show files with this tag"), + current_user=Depends(require_auth), +): + """Return graph data (nodes and edges) for a vault or directory. + + Nodes represent files and directories. Edges represent parent-child + relationships and wikilinks between markdown files. + + Args: + vault_name: Name of the vault. + path: Relative directory path to focus on (empty = root). + depth: Expansion depth (0 = only direct children, 1-3 = deeper). + scope: 'directory' for subtree, 'full' for entire vault. + tag: Optional tag filter (only files with this tag appear). + + Returns: + ``GraphResponse`` with nodes and edges. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + return service_get_graph(vault_name, path=path, depth=depth, scope=scope, tag=tag) + + +@router.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. + + Args: + vault_name: Name of the vault to reindex. + + Returns: + Dict with vault statistics. + """ + try: + from backend.indexer import reload_single_vault + stats = await reload_single_vault(vault_name) + await sse_manager.broadcast("vault_reloaded", { + "vault": vault_name, + "stats": stats, + }) + return {"status": "ok", "vault": vault_name, "stats": stats} + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) diff --git a/backend/schemas.py b/backend/schemas.py index fce599d..19887a0 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -214,6 +214,178 @@ class RestoreResponse(BaseModel): current_backed_up: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any") +# --------------------------------------------------------------------------- +# Search / suggest / graph (#85 — extrait de backend.main, inchangé) +# --------------------------------------------------------------------------- + + +class SearchResultItem(BaseModel): + """A single search result.""" + + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path") + title: str = Field(description="File title") + tags: list[str] = Field(description="File tags") + score: int = Field(description="Relevance score") + snippet: str = Field(description="Content excerpt with highlights") + modified: str = Field(description="ISO 8601 modification timestamp") + + +class SearchResponse(BaseModel): + """Full-text search response with optional pagination.""" + + query: str = Field(description="Original search query") + vault_filter: str = Field(description="Vault filter applied ('all' or vault name)") + tag_filter: str | None = Field(default=None, description="Tag filter applied") + count: int = Field(description="Number of results in this response") + total: int = Field(default=0, description="Total results before pagination") + offset: int = Field(default=0, description="Current pagination offset") + limit: int = Field(default=200, description="Page size") + results: list[SearchResultItem] = Field(description="Search result items") + + +class TagsResponse(BaseModel): + """Tag aggregation response.""" + + vault_filter: str | None = Field(default=None, description="Vault filter applied") + tags: dict[str, int] = Field(description="Tag name → count mapping") + + +class TreeSearchResult(BaseModel): + """A single tree search result item.""" + + vault: str = Field(description="Vault name") + path: str = Field(description="Full relative path") + name: str = Field(description="File or directory name") + type: str = Field(description="'file' or 'directory'") + matched_path: str = Field(description="Path segment that matched the query") + + +class TreeSearchResponse(BaseModel): + """Tree search response with matching paths.""" + + query: str = Field(description="Search query") + vault_filter: str = Field(description="Vault filter applied") + results: list[TreeSearchResult] = Field(description="Matching files and directories") + + +class VaultPathEntry(BaseModel): + """A single indexed path (file or directory) in a vault.""" + + vault: str = Field(description="Vault name") + path: str = Field(description="Full relative path") + name: str = Field(description="File or directory name") + type: str = Field(description="'file' or 'directory'") + + +class VaultPathsResponse(BaseModel): + """Flat list of every indexed path in a vault (capped).""" + + vault: str = Field(description="Vault name") + count: int = Field(description="Number of returned entries") + results: list[VaultPathEntry] = Field(description="Indexed files and directories") + + +class AdvancedSearchResultItem(BaseModel): + """A single advanced search result with highlighted snippet.""" + + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path") + title: str = Field(description="File title") + tags: list[str] = Field(description="File tags") + score: float = Field(description="TF-IDF relevance score (or fused RRF score in semantic mode)") + semantic_score: float = Field(default=0.0, description="Cosine similarity from the semantic index (0 when unavailable)") + snippet: str = Field(description="Content excerpt with highlights") + modified: str = Field(description="ISO 8601 modification timestamp") + extension: str = Field(default="", description="File extension") + + +class SearchFacets(BaseModel): + """Faceted counts for search results.""" + + tags: dict[str, int] = Field(default_factory=dict) + vaults: dict[str, int] = Field(default_factory=dict) + + +class AdvancedSearchResponse(BaseModel): + """Advanced search response with TF-IDF scoring, facets, and pagination.""" + + results: list[AdvancedSearchResultItem] = Field(description="Search results") + total: int = Field(description="Total number of matching results") + offset: int = Field(description="Current pagination offset") + limit: int = Field(description="Page size") + facets: SearchFacets = Field(description="Faceted counts by tag and vault") + query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds") + semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready") + + +class TitleSuggestion(BaseModel): + """A file title suggestion for autocomplete.""" + + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path") + title: str = Field(description="File title") + + +class SuggestResponse(BaseModel): + """Autocomplete suggestions for file titles.""" + + query: str = Field(description="Original query string") + suggestions: list[TitleSuggestion] = Field(description="Matching file suggestions") + + +class TagSuggestion(BaseModel): + """A tag suggestion for autocomplete.""" + + tag: str = Field(description="Tag name") + count: int = Field(description="Number of files with this tag") + + +class TagSuggestResponse(BaseModel): + """Autocomplete suggestions for tags.""" + + query: str = Field(description="Original query string") + suggestions: list[TagSuggestion] = Field(description="Matching tag suggestions") + + +class GraphNode(BaseModel): + """A single node in the graph view.""" + + id: str = Field(description="Unique node identifier") + name: str = Field(description="Display name") + type: str = Field(description="'vault', 'directory', or 'file'") + path: str = Field(description="Relative path within vault") + size: int = Field(default=0, description="File size in bytes") + tags: list[str] = Field(default_factory=list, description="Tags from frontmatter") + incoming_count: int = Field(default=0, description="Number of incoming wikilinks") + outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks") + + +class GraphEdge(BaseModel): + """An edge between two nodes in the graph view.""" + + source: str = Field(description="Source node ID") + target: str = Field(description="Target node ID") + relation: str = Field(description="'parent', 'wikilink', or 'backlink'") + + +class GraphResponse(BaseModel): + """Graph data for a vault or directory.""" + + vault: str = Field(description="Vault name") + path: str = Field(description="Root path for the graph") + scope: str = Field(default="directory", description="'directory' or 'full'") + nodes: list[GraphNode] = Field(description="Graph nodes (files and directories)") + edges: list[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)") + + +class ReloadResponse(BaseModel): + """Index reload confirmation with per-vault stats.""" + + status: str = Field(description="Reload status ('ok' or 'error')") + vaults: dict[str, Any] = Field(description="Per-vault file counts after reload") + + # --------------------------------------------------------------------------- # PDF # --------------------------------------------------------------------------- diff --git a/backend/search_executor.py b/backend/search_executor.py new file mode 100644 index 0000000..19f016e --- /dev/null +++ b/backend/search_executor.py @@ -0,0 +1,35 @@ +"""Shared thread pool for CPU-bound search (ROADMAP #85, tranche 5). + +Holder extrait de :mod:`backend.main` sans changement de comportement : +un seul pool (2 workers, préfixe ``"search"``) créé au démarrage et arrêté +à l'extinction par le lifespan de ``main``. Les routers et les endpoints +restants y accèdent via :func:`get_search_executor` au lieu du global de +``main`` (plus d'import circulaire potentiel). +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor + +_executor: ThreadPoolExecutor | None = None + + +def init_search_executor(max_workers: int = 2) -> ThreadPoolExecutor: + """Create (or reuse) the shared search thread pool.""" + global _executor + if _executor is None: + _executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="search") + return _executor + + +def shutdown_search_executor() -> None: + """Stop the shared search thread pool (best-effort, non-blocking).""" + global _executor + if _executor is not None: + _executor.shutdown(wait=False) + _executor = None + + +def get_search_executor() -> ThreadPoolExecutor | None: + """Return the shared search thread pool (``None`` before startup).""" + return _executor diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 996a4f6..d70b5ab 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.27.5" +version = "2.27.6" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index c7e0c41..f7a534e 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.27.5" +version = "2.27.6" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 175c17e..589ce02 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.27.5", + "version": "2.27.6", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 813beed..16a3b96 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.27.5 | **Dernière mise à jour :** 2026-09-26 +> **Version :** 2.27.6 | **Dernière mise à jour :** 2026-09-26 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -67,10 +67,10 @@ - **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend - **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).** -- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). +- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). - **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe. - **Sous-tâches :** - - [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer) + - [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer) - [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés - [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process) - [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test) diff --git a/package.json b/package.json index 23210b0..d38b5fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.27.5", + "version": "2.27.6", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": {