From 3b0927a8c9991f244a80b1eaf43d5073b70657eb Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 14:46:51 -0400 Subject: [PATCH] refactor: #85 T8 extrait vaults-history-conflicts vers backend/routers (comportement inchange) --- CHANGELOG.md | 12 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/main.py | 320 +++-------------------------------- backend/routers/conflicts.py | 73 ++++++++ backend/routers/history.py | 160 ++++++++++++++++++ backend/routers/vaults.py | 107 ++++++++++++ backend/schemas.py | 22 +++ backend/watcher_state.py | 28 +++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 6 +- package.json | 2 +- tests/test_api_main.py | 10 +- 16 files changed, 446 insertions(+), 314 deletions(-) create mode 100644 backend/routers/conflicts.py create mode 100644 backend/routers/history.py create mode 100644 backend/routers/vaults.py create mode 100644 backend/watcher_state.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e1704e1..e4a4981 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.10**. +> [Unreleased](#unreleased). La dernière version livrée est **2.27.11**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.27.11] — 2026-09-26 + +--- + ## [2.27.10] — 2026-09-26 --- @@ -50,6 +54,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Modifié +- **#85 (T8) — extraction vaults/history/conflicts hors du monolithe `backend/main.py`.** + 13 routes servies par `backend/routers/vaults.py`, `history.py` et + `conflicts.py` ; `VaultInfo`/`BookmarkToggleRequest` dans `schemas.py`, + handle watcher partagé dans `backend/watcher_state.py`. + `tests/test_api_main.py` importe `humanize_mtime` depuis son module + canonique (`services.recent`). - **#85 (T7) — extraction du domaine `config` hors du monolithe `backend/main.py`.** `/api/config`, ai-keys (get/post/delete/test), tool-keys (×3), ai-models, diagnostics et dashboard sont servis par `backend/routers/config.py` diff --git a/README.fr.md b/README.fr.md index b1ce35d..07ae461 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.10-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.11-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.10). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.11). --- -*Projet : ObsiGate | Version : 2.27.10 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.27.11 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 4e44e87..0a3dd11 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.10-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.11-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.10). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.11). --- -*Project: ObsiGate | Version: 2.27.10 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.27.11 | Last updated: September 2026* diff --git a/VERSION b/VERSION index fba70a0..273dc5a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.27.10 +2.27.11 diff --git a/backend/main.py b/backend/main.py index 42efebe..e267998 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,35 +5,25 @@ import logging import os import re import secrets -import shutil import string from contextlib import asynccontextmanager from pathlib import Path -import frontmatter import mistune -from fastapi import Body, Depends, FastAPI, HTTPException, Query, Request, WebSocket +from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from starlette.middleware.base import BaseHTTPMiddleware from backend.collab import authenticate_websocket, collab_manager -from backend.history import ( - get_bookmarks, - toggle_bookmark, -) from backend.image_processor import preprocess_images from backend.indexer import ( - add_vault_to_index, build_index, find_file_in_index, - get_conflicts, get_vault_data, handle_file_move, - index, remove_single_file, - remove_vault_from_index, update_single_file, ) from backend.openapi_docs import ( @@ -42,29 +32,13 @@ from backend.openapi_docs import ( enrich_openapi_schema, render_api_landing, ) -from backend.schemas import ( - BookmarksResponse, - BookmarkToggleResponse, - ConflictResolveResponse, - ConflictsResponse, - RecentResponse, - SavedSearch, - StatusResponse, - VaultActionResponse, - VaultsStatusResponse, - VaultStatsResponse, -) from backend.search import ( init_inverted_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.errors import ServiceError -from backend.services.recent import humanize_mtime, list_recent from backend.services.sanitizer import sanitize_html -from backend.services.vaults import ( - list_accessible_vaults, -) logging.basicConfig( level=logging.INFO, @@ -74,16 +48,9 @@ logger = logging.getLogger("obsigate") # --------------------------------------------------------------------------- -# Pydantic response models +# Pydantic response models : voir backend.schemas (vaults/history : #85 T8) # --------------------------------------------------------------------------- -class VaultInfo(BaseModel): - """Summary information about a configured vault.""" - name: str = Field(description="Display name of the vault") - file_count: int = Field(description="Number of indexed files") - tag_count: int = Field(description="Number of unique tags") - type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)") - # Filesystem mutation + search / suggest / graph models : voir backend.schemas (#85 T5, T6b) @@ -124,9 +91,9 @@ from backend.sse import sse_manager # Application lifespan (replaces deprecated on_event) # --------------------------------------------------------------------------- from backend.watcher import VaultWatcher +from backend.watcher_state import get_watcher, set_watcher -# File watcher handle (the search thread pool lives in backend.search_executor). -_vault_watcher: VaultWatcher | None = None +# File watcher : handle partagé via backend.watcher_state (ROADMAP #85 T8). async def _on_vault_change(events: list): @@ -307,7 +274,6 @@ def _guard_insecure_auth() -> None: @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan: build index on startup, cleanup on shutdown.""" - 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() @@ -356,15 +322,14 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"Plugin scan failed for vault '{vault_name}': {e}") - # Start file watcher + # Start file watcher (handle partagé : voir backend.watcher_state) config = _load_config() watcher_enabled = config.get("watcher_enabled", True) if watcher_enabled: use_polling = config.get("watcher_use_polling", False) polling_interval = config.get("watcher_polling_interval", 5.0) debounce = config.get("watcher_debounce", 2.0) - global _vault_watcher - _vault_watcher = VaultWatcher( + watcher = VaultWatcher( on_file_change=_on_vault_change, debounce_seconds=debounce, use_polling=use_polling, @@ -372,7 +337,8 @@ async def lifespan(app: FastAPI): ) from backend.indexer import vault_config vaults_to_watch = {name: cfg["path"] for name, cfg in vault_config.items()} - await _vault_watcher.start(vaults_to_watch) + await watcher.start(vaults_to_watch) + set_watcher(watcher) logger.info("File watcher started in background.") else: logger.info("File watcher disabled by configuration.") @@ -386,9 +352,10 @@ async def lifespan(app: FastAPI): # Shutdown await collab_manager.stop() - if _vault_watcher: - await _vault_watcher.stop() - _vault_watcher = None + watcher = get_watcher() + if watcher: + await watcher.stop() + set_watcher(None) shutdown_search_executor() @@ -464,7 +431,6 @@ app.add_middleware(SecurityHeadersMiddleware) # Auth router # Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c). from backend.ai_routes import router as ai_router -from backend.audit import log_file_delete from backend.auth.middleware import ( check_vault_access, require_admin, @@ -475,14 +441,16 @@ from backend.bookslm_routes import router as bookslm_router from backend.routers.backups import router as backups_router from backend.routers.config import _load_config from backend.routers.config import router as config_router +from backend.routers.conflicts import router as conflicts_router from backend.routers.files_media import router as files_media_router from backend.routers.files_read import router as files_read_router from backend.routers.files_write import router as files_write_router from backend.routers.health import router as health_router +from backend.routers.history import router as history_router from backend.routers.search import router as search_router from backend.routers.sharing import router as sharing_router +from backend.routers.vaults import router as vaults_router from backend.routers.webhooks import router as webhooks_router -from backend.saved_searches import delete_saved, get_saved, save_search from backend.secret_redactor import redact_file_content from backend.skills_routes import router as skills_router @@ -491,14 +459,17 @@ 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(history_router) # ROADMAP #85 T8 — History app.include_router(search_router) # ROADMAP #85 T5 — Search app.include_router(backups_router) # ROADMAP #85 T4 — Backups +app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts app.include_router(config_router) # ROADMAP #85 T7 — Config app.include_router(files_read_router) # ROADMAP #85 T6a — Files read app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export app.include_router(files_write_router) # ROADMAP #85 T6b — Files write app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing +app.include_router(vaults_router) # ROADMAP #85 T8 — Vaults # Admin Dashboard endpoints (system stats, audit logs, backups, stream) try: @@ -792,139 +763,14 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non # --------------------------------------------------------------------------- -@app.get("/api/vaults", response_model=list[VaultInfo]) -async def api_vaults(current_user=Depends(require_auth)): - """List configured vaults the user has access to. - - Returns: - List of vault summary objects filtered by user permissions. - """ - return list_accessible_vaults(current_user) +# --------------------------------------------------------------------------- +# Vaults : voir backend.routers.vaults (#85 T8) +# --------------------------------------------------------------------------- -@app.get("/api/recent", response_model=RecentResponse) -async def api_recent(limit: int | None = Query(None), vault: str | None = Query(None), mode: str | None = Query("opened"), current_user=Depends(require_auth)): - config = _load_config() - actual_limit = limit if limit is not None else config.get("recent_files_limit", 20) - - username = current_user.get("username") - user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) - - return list_recent( - username, - user_vaults, - vault=vault, - limit=actual_limit, - mode=mode or "opened", - ) - - -@app.get("/api/bookmarks", response_model=BookmarksResponse) -async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)): - username = current_user.get("username") - user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) - - if not username: - return {"files": []} - - history = get_bookmarks(username, vault_filter=vault) - files_resp = [] - for item in history: - v_name = item["vault"] - if "*" not in user_vaults and v_name not in user_vaults: - continue - - # Find in index to get metadata - f_idx = find_file_in_index(item["path"], v_name) - if f_idx: - files_resp.append({ - "path": f_idx["path"], - "title": f_idx.get("title") or item["path"].split("/")[-1], - "vault": v_name, - "mtime": item["bookmarked_at"], - "mtime_human": humanize_mtime(item["bookmarked_at"]), - "size_bytes": f_idx.get("size", 0), - "tags": [f"#{t}" for t in f_idx.get("tags", [])][:5], - "bookmarked": True - }) - else: - files_resp.append({ - "path": item["path"], - "title": item.get("title") or item["path"].split("/")[-1], - "vault": v_name, - "mtime": item["bookmarked_at"], - "mtime_human": humanize_mtime(item["bookmarked_at"]), - "tags": [], - "bookmarked": True - }) - return { - "files": files_resp, - "total": len(files_resp) - } - -class BookmarkToggleRequest(BaseModel): - vault: str - path: str - title: str | None = None - -@app.post("/api/bookmarks/toggle", response_model=BookmarkToggleResponse) -async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(require_auth)): - username = current_user.get("username") - if not username: - raise HTTPException(status_code=401, detail="Not authenticated") - - # Check vault access - if not check_vault_access(req.vault, current_user): - raise HTTPException(status_code=403, detail="Access denied to vault") - - is_now_bookmarked = toggle_bookmark(username, req.vault, req.path, req.title or "") - - # Update the file's YAML frontmatter: favoris: true/false - vault_data = get_vault_data(req.vault) - if vault_data: - file_path = _resolve_safe_path(Path(vault_data["path"]), req.path) - if file_path.exists() and file_path.suffix == ".md": - try: - raw = file_path.read_text(encoding="utf-8", errors="replace") - post = frontmatter.loads(raw) - if is_now_bookmarked: - post.metadata["favoris"] = True - elif "favoris" in post.metadata: - del post.metadata["favoris"] - new_raw = frontmatter.dumps(post) - _backup_file(file_path, req.vault, req.path) - file_path.write_text(new_raw, encoding="utf-8") - await update_single_file(req.vault, str(file_path)) - except Exception as e: - logger.warning(f"Failed to update favoris metadata on {req.vault}/{req.path}: {e}") - - return {"bookmarked": is_now_bookmarked} - - -@app.get("/api/saved-searches", response_model=list[SavedSearch]) -async def api_saved_searches(current_user=Depends(require_auth)): - username = current_user.get("username") - if not username: - raise HTTPException(401) - return get_saved(username) - - -@app.post("/api/saved-searches", response_model=SavedSearch) -async def api_save_search(body: dict = Body(...), current_user=Depends(require_auth)): - username = current_user.get("username") - if not username: - raise HTTPException(401) - return save_search(username, body) - - -@app.delete("/api/saved-searches/{search_id}", response_model=StatusResponse) -async def api_delete_saved_search(search_id: str, current_user=Depends(require_auth)): - username = current_user.get("username") - if not username: - raise HTTPException(401) - if not delete_saved(username, search_id): - raise HTTPException(404, "Not found") - return {"status": "deleted"} +# --------------------------------------------------------------------------- +# History (recent / bookmarks / saved-searches) : voir backend.routers.history (#85 T8) +# --------------------------------------------------------------------------- # --------------------------------------------------------------------------- @@ -1027,79 +873,9 @@ async def api_events(current_user=Depends(require_auth)): # --------------------------------------------------------------------------- -# Dynamic vault management endpoints +# Dynamic vault management endpoints : voir backend.routers.vaults (#85 T8) # --------------------------------------------------------------------------- -@app.post("/api/vaults/add", response_model=VaultStatsResponse) -async def api_add_vault(body: dict = Body(...), current_user=Depends(require_admin)): - """Add a new vault dynamically without restarting. - - Body: - name: Display name for the vault. - path: Absolute filesystem path to the vault directory. - """ - name = body.get("name", "").strip() - vault_path = body.get("path", "").strip() - - if not name or not vault_path: - raise HTTPException(status_code=400, detail="Both 'name' and 'path' are required") - - if name in index: - raise HTTPException(status_code=409, detail=f"Vault '{name}' already exists") - - if not Path(vault_path).exists(): - raise HTTPException(status_code=400, detail=f"Path does not exist: {vault_path}") - - stats = await add_vault_to_index(name, vault_path) - - # Start watching the new vault - if _vault_watcher: - await _vault_watcher.add_vault(name, vault_path) - - await sse_manager.broadcast("vault_added", {"vault": name, "stats": stats}) - return {"status": "ok", "vault": name, "stats": stats} - - -@app.delete("/api/vaults/{vault_name}", response_model=VaultActionResponse) -async def api_remove_vault(vault_name: str, current_user=Depends(require_admin)): - """Remove a vault from the index and stop watching it. - - Args: - vault_name: Name of the vault to remove. - """ - if vault_name not in index: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - - # Stop watching - if _vault_watcher: - await _vault_watcher.remove_vault(vault_name) - - await remove_vault_from_index(vault_name) - await sse_manager.broadcast("vault_removed", {"vault": vault_name}) - return {"status": "ok", "vault": vault_name} - - -@app.get("/api/vaults/status", response_model=VaultsStatusResponse) -async def api_vaults_status(current_user=Depends(require_auth)): - """Detailed status of all vaults including watcher state. - - Returns per-vault: file count, tag count, watching status, vault path. - """ - statuses = {} - for vname, vdata in index.items(): - watching = _vault_watcher is not None and vname in _vault_watcher.observers - statuses[vname] = { - "file_count": len(vdata.get("files", [])), - "tag_count": len(vdata.get("tags", {})), - "path": vdata.get("path", ""), - "watching": watching, - } - return { - "vaults": statuses, - "watcher_active": _vault_watcher is not None, - "sse_clients": sse_manager.client_count, - } - # --------------------------------------------------------------------------- # Image / media / attachments / vault-settings : voir backend.routers.files_media (#85 T6c) @@ -1159,53 +935,9 @@ async def api_vaults_status(current_user=Depends(require_auth)): # --------------------------------------------------------------------------- -# Syncthing conflict endpoints +# Syncthing conflict endpoints : voir backend.routers.conflicts (#85 T8) # --------------------------------------------------------------------------- -@app.get("/api/conflicts", response_model=ConflictsResponse) -async def api_conflicts(current_user=Depends(require_auth)): - """List sync-conflict files across accessible vaults.""" - user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) - all_conflicts = get_conflicts() - if "*" not in user_vaults: - all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults] - return {"conflicts": all_conflicts, "total": len(all_conflicts)} - - -@app.post("/api/conflicts/resolve", response_model=ConflictResolveResponse) -async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(require_auth)): - """Resolve a conflict: keep_local (delete conflict file) or keep_conflict (replace original).""" - vault_name = body.get("vault") - conflict_path = body.get("conflict_path") - original_path = body.get("original_path") - action = body.get("action") # "keep_local" or "keep_conflict" - # mypy: narrow down from dict values - assert isinstance(vault_name, str), "'vault' is required and must be a string" - assert isinstance(conflict_path, str), "'conflict_path' is required and must be a string" - assert isinstance(original_path, str), "'original_path' is required and must be a string" - if not check_vault_access(vault_name, current_user): - raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'") - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(404, "Vault not found") - vault_root = Path(vault_data["path"]) - conf_file = _resolve_safe_path(vault_root, conflict_path) - orig_file = _resolve_safe_path(vault_root, original_path) - if not conf_file.exists(): - raise HTTPException(404, "Conflict file not found") - try: - if action == "keep_conflict": - _backup_file(orig_file, vault_name, original_path) - shutil.copy2(conf_file, orig_file) - logger.info(f"Conflict resolved (keep_conflict): {conflict_path} → {original_path}") - conf_file.unlink() - await remove_single_file(vault_name, conflict_path) - log_file_delete(current_user["username"], vault_name, conflict_path) - await sse_manager.broadcast("file_deleted", {"vault": vault_name, "path": conflict_path}) - return {"status": "resolved", "action": action} - except Exception as e: - raise HTTPException(500, f"Error resolving conflict: {e!s}") - # --------------------------------------------------------------------------- # Real-time collaboration — WebSocket endpoint (ROADMAP #62) diff --git a/backend/routers/conflicts.py b/backend/routers/conflicts.py new file mode 100644 index 0000000..d48eae5 --- /dev/null +++ b/backend/routers/conflicts.py @@ -0,0 +1,73 @@ +"""Syncthing conflict endpoints (ROADMAP #85, tranche 8). + +Handlers déplacés depuis :mod:`backend.main` sans changement de +comportement : mêmes chemins (``/api/conflicts*``), mêmes modèles de +réponse, mêmes dépendances d'authentification. + +Adaptations strictement équivalentes : +- ``_resolve_safe_path`` / ``_backup_file`` → :mod:`backend.services.paths` + et :mod:`backend.services.backups` (pass-through). +""" + +import logging +import shutil +from pathlib import Path + +from fastapi import APIRouter, Body, Depends, HTTPException + +from backend.audit import log_file_delete +from backend.auth.middleware import check_vault_access, require_auth +from backend.indexer import get_conflicts, get_vault_data, remove_single_file +from backend.schemas import ConflictResolveResponse, ConflictsResponse +from backend.services.backups import create_backup +from backend.services.paths import resolve_safe_path +from backend.sse import sse_manager + +logger = logging.getLogger("obsigate") + +router = APIRouter(tags=["conflicts"]) + + +@router.get("/api/conflicts", response_model=ConflictsResponse) +async def api_conflicts(current_user=Depends(require_auth)): + """List sync-conflict files across accessible vaults.""" + user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) + all_conflicts = get_conflicts() + if "*" not in user_vaults: + all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults] + return {"conflicts": all_conflicts, "total": len(all_conflicts)} + + +@router.post("/api/conflicts/resolve", response_model=ConflictResolveResponse) +async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(require_auth)): + """Resolve a conflict: keep_local (delete conflict file) or keep_conflict (replace original).""" + vault_name = body.get("vault") + conflict_path = body.get("conflict_path") + original_path = body.get("original_path") + action = body.get("action") # "keep_local" or "keep_conflict" + # mypy: narrow down from dict values + assert isinstance(vault_name, str), "'vault' is required and must be a string" + assert isinstance(conflict_path, str), "'conflict_path' is required and must be a string" + assert isinstance(original_path, str), "'original_path' is required and must be a string" + if not check_vault_access(vault_name, current_user): + raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'") + vault_data = get_vault_data(vault_name) + if not vault_data: + raise HTTPException(404, "Vault not found") + vault_root = Path(vault_data["path"]) + conf_file = resolve_safe_path(vault_root, conflict_path) + orig_file = resolve_safe_path(vault_root, original_path) + if not conf_file.exists(): + raise HTTPException(404, "Conflict file not found") + try: + if action == "keep_conflict": + create_backup(orig_file, vault_name, original_path) + shutil.copy2(conf_file, orig_file) + logger.info(f"Conflict resolved (keep_conflict): {conflict_path} → {original_path}") + conf_file.unlink() + await remove_single_file(vault_name, conflict_path) + log_file_delete(current_user["username"], vault_name, conflict_path) + await sse_manager.broadcast("file_deleted", {"vault": vault_name, "path": conflict_path}) + return {"status": "resolved", "action": action} + except Exception as e: + raise HTTPException(500, f"Error resolving conflict: {e!s}") diff --git a/backend/routers/history.py b/backend/routers/history.py new file mode 100644 index 0000000..340a56d --- /dev/null +++ b/backend/routers/history.py @@ -0,0 +1,160 @@ +"""History endpoints — recent, bookmarks, saved searches (ROADMAP #85, tranche 8). + +Handlers déplacés depuis :mod:`backend.main` sans changement de +comportement : mêmes chemins, mêmes modèles (``BookmarkToggleRequest`` +déménagé dans :mod:`backend.schemas`), mêmes dépendances +d'authentification. + +Adaptations strictement équivalentes : +- ``_resolve_safe_path`` / ``_backup_file`` → :mod:`backend.services.paths` + et :mod:`backend.services.backups` (pass-through). +- ``_load_config`` vient de :mod:`backend.routers.config`. +""" + +import logging +from pathlib import Path + +import frontmatter +from fastapi import APIRouter, Body, Depends, HTTPException, Query + +from backend.auth.middleware import check_vault_access, require_auth +from backend.history import get_bookmarks, toggle_bookmark +from backend.indexer import find_file_in_index, get_vault_data, update_single_file +from backend.routers.config import _load_config +from backend.saved_searches import delete_saved, get_saved, save_search +from backend.schemas import ( + BookmarksResponse, + BookmarkToggleRequest, + BookmarkToggleResponse, + RecentResponse, + SavedSearch, + StatusResponse, +) +from backend.services.backups import create_backup +from backend.services.paths import resolve_safe_path +from backend.services.recent import humanize_mtime, list_recent + +logger = logging.getLogger("obsigate") + +router = APIRouter(tags=["Bookmarks"]) + + +@router.get("/api/recent", response_model=RecentResponse) +async def api_recent(limit: int | None = Query(None), vault: str | None = Query(None), mode: str | None = Query("opened"), current_user=Depends(require_auth)): + config = _load_config() + actual_limit = limit if limit is not None else config.get("recent_files_limit", 20) + + username = current_user.get("username") + user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) + + return list_recent( + username, + user_vaults, + vault=vault, + limit=actual_limit, + mode=mode or "opened", + ) + + +@router.get("/api/bookmarks", response_model=BookmarksResponse) +async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)): + username = current_user.get("username") + user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) + + if not username: + return {"files": []} + + history = get_bookmarks(username, vault_filter=vault) + files_resp = [] + for item in history: + v_name = item["vault"] + if "*" not in user_vaults and v_name not in user_vaults: + continue + + # Find in index to get metadata + f_idx = find_file_in_index(item["path"], v_name) + if f_idx: + files_resp.append({ + "path": f_idx["path"], + "title": f_idx.get("title") or item["path"].split("/")[-1], + "vault": v_name, + "mtime": item["bookmarked_at"], + "mtime_human": humanize_mtime(item["bookmarked_at"]), + "size_bytes": f_idx.get("size", 0), + "tags": [f"#{t}" for t in f_idx.get("tags", [])][:5], + "bookmarked": True + }) + else: + files_resp.append({ + "path": item["path"], + "title": item.get("title") or item["path"].split("/")[-1], + "vault": v_name, + "mtime": item["bookmarked_at"], + "mtime_human": humanize_mtime(item["bookmarked_at"]), + "tags": [], + "bookmarked": True + }) + return { + "files": files_resp, + "total": len(files_resp) + } + + +@router.post("/api/bookmarks/toggle", response_model=BookmarkToggleResponse) +async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(require_auth)): + username = current_user.get("username") + if not username: + raise HTTPException(status_code=401, detail="Not authenticated") + + # Check vault access + if not check_vault_access(req.vault, current_user): + raise HTTPException(status_code=403, detail="Access denied to vault") + + is_now_bookmarked = toggle_bookmark(username, req.vault, req.path, req.title or "") + + # Update the file's YAML frontmatter: favoris: true/false + vault_data = get_vault_data(req.vault) + if vault_data: + file_path = resolve_safe_path(Path(vault_data["path"]), req.path) + if file_path.exists() and file_path.suffix == ".md": + try: + raw = file_path.read_text(encoding="utf-8", errors="replace") + post = frontmatter.loads(raw) + if is_now_bookmarked: + post.metadata["favoris"] = True + elif "favoris" in post.metadata: + del post.metadata["favoris"] + new_raw = frontmatter.dumps(post) + create_backup(file_path, req.vault, req.path) + file_path.write_text(new_raw, encoding="utf-8") + await update_single_file(req.vault, str(file_path)) + except Exception as e: + logger.warning(f"Failed to update favoris metadata on {req.vault}/{req.path}: {e}") + + return {"bookmarked": is_now_bookmarked} + + +@router.get("/api/saved-searches", response_model=list[SavedSearch]) +async def api_saved_searches(current_user=Depends(require_auth)): + username = current_user.get("username") + if not username: + raise HTTPException(401) + return get_saved(username) + + +@router.post("/api/saved-searches", response_model=SavedSearch) +async def api_save_search(body: dict = Body(...), current_user=Depends(require_auth)): + username = current_user.get("username") + if not username: + raise HTTPException(401) + return save_search(username, body) + + +@router.delete("/api/saved-searches/{search_id}", response_model=StatusResponse) +async def api_delete_saved_search(search_id: str, current_user=Depends(require_auth)): + username = current_user.get("username") + if not username: + raise HTTPException(401) + if not delete_saved(username, search_id): + raise HTTPException(404, "Not found") + return {"status": "deleted"} diff --git a/backend/routers/vaults.py b/backend/routers/vaults.py new file mode 100644 index 0000000..da0164d --- /dev/null +++ b/backend/routers/vaults.py @@ -0,0 +1,107 @@ +"""Vault management endpoints (ROADMAP #85, tranche 8). + +Handlers déplacés depuis :mod:`backend.main` sans changement de +comportement : mêmes chemins (``/api/vaults*``), mêmes modèles de réponse +(``VaultInfo`` déménagé dans :mod:`backend.schemas`), mêmes dépendances +d'authentification. + +Le handle du file-watcher vit désormais dans :mod:`backend.watcher_state` +(partagé avec le lifespan de ``main``) au lieu du global de ``main``. +""" + +from pathlib import Path + +from fastapi import APIRouter, Body, Depends, HTTPException + +from backend.auth.middleware import require_admin, require_auth +from backend.indexer import add_vault_to_index, index, remove_vault_from_index +from backend.schemas import VaultActionResponse, VaultInfo, VaultsStatusResponse, VaultStatsResponse +from backend.services.vaults import list_accessible_vaults +from backend.sse import sse_manager +from backend.watcher_state import get_watcher + +router = APIRouter(tags=["vaults"]) + + +@router.get("/api/vaults", response_model=list[VaultInfo]) +async def api_vaults(current_user=Depends(require_auth)): + """List configured vaults the user has access to. + + Returns: + List of vault summary objects filtered by user permissions. + """ + return list_accessible_vaults(current_user) + + +@router.post("/api/vaults/add", response_model=VaultStatsResponse) +async def api_add_vault(body: dict = Body(...), current_user=Depends(require_admin)): + """Add a new vault dynamically without restarting. + + Body: + name: Display name for the vault. + path: Absolute filesystem path to the vault directory. + """ + name = body.get("name", "").strip() + vault_path = body.get("path", "").strip() + + if not name or not vault_path: + raise HTTPException(status_code=400, detail="Both 'name' and 'path' are required") + + if name in index: + raise HTTPException(status_code=409, detail=f"Vault '{name}' already exists") + + if not Path(vault_path).exists(): + raise HTTPException(status_code=400, detail=f"Path does not exist: {vault_path}") + + stats = await add_vault_to_index(name, vault_path) + + # Start watching the new vault + watcher = get_watcher() + if watcher: + await watcher.add_vault(name, vault_path) + + await sse_manager.broadcast("vault_added", {"vault": name, "stats": stats}) + return {"status": "ok", "vault": name, "stats": stats} + + +@router.delete("/api/vaults/{vault_name}", response_model=VaultActionResponse) +async def api_remove_vault(vault_name: str, current_user=Depends(require_admin)): + """Remove a vault from the index and stop watching it. + + Args: + vault_name: Name of the vault to remove. + """ + if vault_name not in index: + raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") + + # Stop watching + watcher = get_watcher() + if watcher: + await watcher.remove_vault(vault_name) + + await remove_vault_from_index(vault_name) + await sse_manager.broadcast("vault_removed", {"vault": vault_name}) + return {"status": "ok", "vault": vault_name} + + +@router.get("/api/vaults/status", response_model=VaultsStatusResponse) +async def api_vaults_status(current_user=Depends(require_auth)): + """Detailed status of all vaults including watcher state. + + Returns per-vault: file count, tag count, watching status, vault path. + """ + watcher = get_watcher() + statuses = {} + for vname, vdata in index.items(): + watching = watcher is not None and vname in watcher.observers + statuses[vname] = { + "file_count": len(vdata.get("files", [])), + "tag_count": len(vdata.get("tags", {})), + "path": vdata.get("path", ""), + "watching": watching, + } + return { + "vaults": statuses, + "watcher_active": watcher is not None, + "sse_clients": sse_manager.client_count, + } diff --git a/backend/schemas.py b/backend/schemas.py index 9a170d0..795cc84 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -411,6 +411,28 @@ class FileMoveResponse(BaseModel): item_type: str = Field(description="Type of item moved: 'file' or 'directory'") +# --------------------------------------------------------------------------- +# Vaults & history (#85 — extrait de backend.main, inchangé) +# --------------------------------------------------------------------------- + + +class VaultInfo(BaseModel): + """Summary information about a configured vault.""" + + name: str = Field(description="Display name of the vault") + file_count: int = Field(description="Number of indexed files") + tag_count: int = Field(description="Number of unique tags") + type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)") + + +class BookmarkToggleRequest(BaseModel): + """Request to toggle a bookmark on a file.""" + + vault: str + path: str + title: str | None = None + + # --------------------------------------------------------------------------- # Search / suggest / graph (#85 — extrait de backend.main, inchangé) # --------------------------------------------------------------------------- diff --git a/backend/watcher_state.py b/backend/watcher_state.py new file mode 100644 index 0000000..f247177 --- /dev/null +++ b/backend/watcher_state.py @@ -0,0 +1,28 @@ +"""Shared VaultWatcher handle (ROADMAP #85, tranche 8). + +Holder extrait de :mod:`backend.main` sans changement de comportement : le +lifespan de ``main`` y dépose l'instance (``set_watcher``) et l'y reprend à +l'extinction ; le router ``vaults`` la consulte via :func:`get_watcher` +(démarrage/arrêt de surveillance à l'ajout/retrait dynamique de vault, +état dans ``/api/vaults/status``). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backend.watcher import VaultWatcher + +_watcher: VaultWatcher | None = None + + +def get_watcher() -> VaultWatcher | None: + """Return the shared VaultWatcher instance (``None`` if disabled).""" + return _watcher + + +def set_watcher(watcher: VaultWatcher | None) -> None: + """Store (or clear) the shared VaultWatcher instance.""" + global _watcher + _watcher = watcher diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 5ab2335..018738d 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.27.10" +version = "2.27.11" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 73d3ba4..9f536ed 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.27.10" +version = "2.27.11" 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 a96cee2..eadc01c 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.10", + "version": "2.27.11", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d2b0355..1abda5b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.27.10 | **Dernière mise à jour :** 2026-09-26 +> **Version :** 2.27.11 | **Dernière mise à jour :** 2026-09-26 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -67,10 +67,10 @@ - **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend - **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).** -- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`). **T6b livrée (v2.27.8) :** mutations fichiers/dossiers (save, xlsx/save, delete, create, rename, move, directories ×3, batch-upload → `backend/routers/files_write.py`, 15 modèles → `schemas.py`). **T6c livrée (v2.27.9) :** media/pdf/export/guide (file/pdf, exports ×3, guide, pdf/stream|info, image, media+thumb, attachments ×2, vault settings ×3, vault files → `backend/routers/files_media.py`, Range helper → `helpers.py`). **T7 livrée (v2.27.10) :** config (app/ai-keys/tool-keys/ai-models/diagnostics/dashboard → `backend/routers/config.py`, `_FALLBACK_MODELS` + clés déplacés, `test_ai_models` réaligné). +- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`). **T6b livrée (v2.27.8) :** mutations fichiers/dossiers (save, xlsx/save, delete, create, rename, move, directories ×3, batch-upload → `backend/routers/files_write.py`, 15 modèles → `schemas.py`). **T6c livrée (v2.27.9) :** media/pdf/export/guide (file/pdf, exports ×3, guide, pdf/stream|info, image, media+thumb, attachments ×2, vault settings ×3, vault files → `backend/routers/files_media.py`, Range helper → `helpers.py`). **T7 livrée (v2.27.10) :** config (app/ai-keys/tool-keys/ai-models/diagnostics/dashboard → `backend/routers/config.py`, `_FALLBACK_MODELS` + clés déplacés, `test_ai_models` réaligné). **T8 livrée (v2.27.11) :** vaults + history + conflicts (→ `backend/routers/vaults.py|history.py|conflicts.py`, `VaultInfo`/`BookmarkToggleRequest` → `schemas.py`, watcher → `backend/watcher_state.py`). - **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe. - **Sous-tâches :** - - [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`), `files-write` ✅ (T6b, `backend/routers/files_write.py`), `files-media` ✅ (T6c, `backend/routers/files_media.py`), `config` ✅ (T7, `backend/routers/config.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer) + - [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`), `files-write` ✅ (T6b, `backend/routers/files_write.py`), `files-media` ✅ (T6c, `backend/routers/files_media.py`), `config` ✅ (T7, `backend/routers/config.py`), `vaults|history|conflicts` ✅ (T8, 3 routers + `watcher_state.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 06abc85..c7f8d33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.27.10", + "version": "2.27.11", "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": { diff --git a/tests/test_api_main.py b/tests/test_api_main.py index 61a6284..518dbee 100644 --- a/tests/test_api_main.py +++ b/tests/test_api_main.py @@ -589,30 +589,30 @@ class TestHumanizeMtime: pass def test_humanize_mtime_now(self): - from backend.main import humanize_mtime + from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main import time assert "instant" in humanize_mtime(time.time()) def test_humanize_mtime_minutes(self): - from backend.main import humanize_mtime + from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main import time result = humanize_mtime(time.time() - 120) assert "min" in result def test_humanize_mtime_hours(self): - from backend.main import humanize_mtime + from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main import time result = humanize_mtime(time.time() - 7200) assert "h" in result or "jour" in result def test_humanize_mtime_days(self): - from backend.main import humanize_mtime + from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main import time result = humanize_mtime(time.time() - 172800) # 2 days assert "j" in result def test_humanize_mtime_old(self): - from backend.main import humanize_mtime + from backend.services.recent import humanize_mtime # ROADMAP #85 T8 — ne plus passer par backend.main import time result = humanize_mtime(time.time() - 86400 * 30) assert "202" in result or result # Should show formatted date