refactor: #85 T5 extrait le domaine search vers backend/routers + executor partage (comportement inchange)
This commit is contained in:
+12
-1
@@ -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).
|
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
|
> **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
|
## [2.27.5] — 2026-09-26
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -30,6 +34,13 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### Modifié
|
### 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`.**
|
- **#85 (T4) — extraction du domaine `backups` hors du monolithe `backend/main.py`.**
|
||||||
Les 9 routes (`/api/file/{vault}/backups|diff|restore`, `/api/backups`,
|
Les 9 routes (`/api/file/{vault}/backups|diff|restore`, `/api/backups`,
|
||||||
`/delete`, `/purge`, `/content`, `/compress`, `/auto`) sont servies par le
|
`/delete`, `/purge`, `/content`, `/compress`, `/auto`) sont servies par le
|
||||||
|
|||||||
+3
-3
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
+22
-470
@@ -8,9 +8,7 @@ import secrets
|
|||||||
import shutil
|
import shutil
|
||||||
import string
|
import string
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from functools import partial
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
@@ -45,7 +43,6 @@ from backend.indexer import (
|
|||||||
handle_file_move,
|
handle_file_move,
|
||||||
index,
|
index,
|
||||||
parse_markdown_file,
|
parse_markdown_file,
|
||||||
reload_index,
|
|
||||||
remove_single_file,
|
remove_single_file,
|
||||||
remove_vault_from_index,
|
remove_vault_from_index,
|
||||||
update_single_file,
|
update_single_file,
|
||||||
@@ -76,7 +73,6 @@ from backend.schemas import (
|
|||||||
DiagnosticsResponse,
|
DiagnosticsResponse,
|
||||||
PdfInfoResponse,
|
PdfInfoResponse,
|
||||||
RecentResponse,
|
RecentResponse,
|
||||||
ReplaceResponse,
|
|
||||||
SavedSearch,
|
SavedSearch,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
VaultActionResponse,
|
VaultActionResponse,
|
||||||
@@ -87,14 +83,11 @@ from backend.schemas import (
|
|||||||
)
|
)
|
||||||
from backend.search import (
|
from backend.search import (
|
||||||
init_inverted_index,
|
init_inverted_index,
|
||||||
suggest_tags,
|
|
||||||
suggest_titles,
|
|
||||||
)
|
)
|
||||||
from backend.semantic_search import init_semantic_index
|
from backend.semantic_search import init_semantic_index
|
||||||
from backend.services.backups import get_backup_dir as service_get_backup_dir
|
from backend.services.backups import get_backup_dir as service_get_backup_dir
|
||||||
from backend.services.errors import ServiceError
|
from backend.services.errors import ServiceError
|
||||||
from backend.services.files import read_raw_file
|
from backend.services.files import read_raw_file
|
||||||
from backend.services.graph import get_graph as service_get_graph
|
|
||||||
from backend.services.mutations import (
|
from backend.services.mutations import (
|
||||||
batch_upload_files as service_batch_upload_files,
|
batch_upload_files as service_batch_upload_files,
|
||||||
)
|
)
|
||||||
@@ -125,13 +118,8 @@ from backend.services.mutations import (
|
|||||||
from backend.services.mutations import (
|
from backend.services.mutations import (
|
||||||
rename_file as service_rename_file,
|
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.recent import humanize_mtime, list_recent
|
||||||
from backend.services.sanitizer import sanitize_html
|
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 (
|
from backend.services.vaults import (
|
||||||
browse_directory,
|
browse_directory,
|
||||||
list_accessible_vaults,
|
list_accessible_vaults,
|
||||||
@@ -234,154 +222,8 @@ class FileDeleteResponse(BaseModel):
|
|||||||
path: str = Field(description="Relative file path within the vault")
|
path: str = Field(description="Relative file path within the vault")
|
||||||
|
|
||||||
|
|
||||||
class SearchResultItem(BaseModel):
|
# Search / suggest / graph models : voir backend.schemas (#85 T5)
|
||||||
"""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 <mark> 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")
|
|
||||||
|
|
||||||
|
|
||||||
class DirectoryCreateRequest(BaseModel):
|
class DirectoryCreateRequest(BaseModel):
|
||||||
"""Request to create a new directory."""
|
"""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
|
from backend.sse import sse_manager
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -510,9 +357,7 @@ from backend.sse import sse_manager
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
from backend.watcher import VaultWatcher
|
from backend.watcher import VaultWatcher
|
||||||
|
|
||||||
# Thread pool for offloading CPU-bound search from the event loop.
|
# File watcher handle (the search thread pool lives in backend.search_executor).
|
||||||
# Sized to 2 workers so concurrent searches don't starve other requests.
|
|
||||||
_search_executor: ThreadPoolExecutor | None = None
|
|
||||||
_vault_watcher: VaultWatcher | None = None
|
_vault_watcher: VaultWatcher | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -694,8 +539,10 @@ def _guard_insecure_auth() -> None:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan: build index on startup, cleanup on shutdown."""
|
"""Application lifespan: build index on startup, cleanup on shutdown."""
|
||||||
global _search_executor, _vault_watcher
|
global _vault_watcher
|
||||||
_search_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="search")
|
# 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.
|
# BUG-037: refuse to expose an unauthenticated instance on a public bind.
|
||||||
_guard_insecure_auth()
|
_guard_insecure_auth()
|
||||||
@@ -716,9 +563,9 @@ async def lifespan(app: FastAPI):
|
|||||||
# The inverted index rebuild is CPU-bound (tokenization, indexing) and
|
# The inverted index rebuild is CPU-bound (tokenization, indexing) and
|
||||||
# would freeze HTTP responses if run in the async event loop.
|
# would freeze HTTP responses if run in the async event loop.
|
||||||
loop = asyncio.get_running_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.
|
# 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
|
# BUG-040: extract the PDF text deferred during the scan now that the
|
||||||
# index and inverted index are queryable (keeps startup non-blocking).
|
# index and inverted index are queryable (keeps startup non-blocking).
|
||||||
@@ -774,8 +621,7 @@ async def lifespan(app: FastAPI):
|
|||||||
if _vault_watcher:
|
if _vault_watcher:
|
||||||
await _vault_watcher.stop()
|
await _vault_watcher.stop()
|
||||||
_vault_watcher = None
|
_vault_watcher = None
|
||||||
_search_executor.shutdown(wait=False)
|
shutdown_search_executor()
|
||||||
_search_executor = None
|
|
||||||
|
|
||||||
|
|
||||||
from backend.version import get_version
|
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.export import ExportError, export_epub, export_html, export_md_bundle
|
||||||
from backend.routers.backups import router as backups_router
|
from backend.routers.backups import router as backups_router
|
||||||
from backend.routers.health import router as health_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.sharing import router as sharing_router
|
||||||
from backend.routers.webhooks import router as webhooks_router
|
from backend.routers.webhooks import router as webhooks_router
|
||||||
from backend.saved_searches import delete_saved, get_saved, save_search
|
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(bookslm_router)
|
||||||
app.include_router(skills_router)
|
app.include_router(skills_router)
|
||||||
app.include_router(health_router) # ROADMAP #85 T1 — System / health
|
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(backups_router) # ROADMAP #85 T4 — Backups
|
||||||
app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks
|
app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks
|
||||||
app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing
|
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(
|
# Search / suggest / graph / index-reload : voir backend.routers.search (#85 T5)
|
||||||
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, <date, date..date)"),
|
|
||||||
modified: str | None = Query(None, description="Modified date filter (>date, <date, date..date, <Nd)"),
|
|
||||||
size: str | None = Query(None, description="Size filter (>size, <size, size..size, e.g. >1MB, <10KB)"),
|
|
||||||
semantic: bool = Query(False, description="Fuse TF-IDF with semantic embeddings (RRF)"),
|
|
||||||
current_user=Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
|
|
||||||
|
|
||||||
Supports advanced query operators:
|
|
||||||
- ``tag:<name>`` or ``#<name>`` — filter by tag
|
|
||||||
- ``vault:<name>`` — filter by vault
|
|
||||||
- ``title:<text>`` — filter by title substring
|
|
||||||
- ``path:<text>`` — filter by path substring
|
|
||||||
- ``ext:<type>`` — 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 ``<mark>``-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))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -3769,8 +3321,8 @@ async def api_diagnostics(current_user=Depends(require_admin)):
|
|||||||
},
|
},
|
||||||
"config": _load_config(),
|
"config": _load_config(),
|
||||||
"search_executor": {
|
"search_executor": {
|
||||||
"active": _search_executor is not None,
|
"active": get_search_executor() is not None,
|
||||||
"max_workers": _search_executor._max_workers if _search_executor else 0,
|
"max_workers": get_search_executor()._max_workers if get_search_executor() else 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, <date, date..date)"),
|
||||||
|
modified: str | None = Query(None, description="Modified date filter (>date, <date, date..date, <Nd)"),
|
||||||
|
size: str | None = Query(None, description="Size filter (>size, <size, size..size, e.g. >1MB, <10KB)"),
|
||||||
|
semantic: bool = Query(False, description="Fuse TF-IDF with semantic embeddings (RRF)"),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
|
||||||
|
|
||||||
|
Supports advanced query operators:
|
||||||
|
- ``tag:<name>`` or ``#<name>`` — filter by tag
|
||||||
|
- ``vault:<name>`` — filter by vault
|
||||||
|
- ``title:<text>`` — filter by title substring
|
||||||
|
- ``path:<text>`` — filter by path substring
|
||||||
|
- ``ext:<type>`` — 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 ``<mark>``-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))
|
||||||
@@ -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")
|
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 <mark> 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
|
# PDF
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.27.5"
|
version = "2.27.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.27.5"
|
version = "2.27.6"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.27.5",
|
"version": "2.27.6",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# 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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **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
|
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
|
||||||
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
|
- **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.
|
- **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 :**
|
- **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
|
- [ ] 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)
|
- [ ] 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)
|
- [ ] 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)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
Reference in New Issue
Block a user