diff --git a/backend/ai.py b/backend/ai.py index 7401cba..0afd54d 100644 --- a/backend/ai.py +++ b/backend/ai.py @@ -4,12 +4,13 @@ Supports: DeepSeek, OpenRouter, Google Gemini. Configured via environment variables. """ -import os import json import logging +import os from pathlib import Path +from typing import Literal + import httpx -from typing import Optional, Literal logger = logging.getLogger("obsigate.ai") @@ -29,7 +30,7 @@ def _read_ai_keys() -> dict: def get_ai_key(env_name: str) -> str: """Get AI key: stored file first, then .env fallback.""" keys = _read_ai_keys() - if env_name in keys and keys[env_name]: + if keys.get(env_name): return keys[env_name] return os.getenv(env_name, "").strip() @@ -67,7 +68,7 @@ PROVIDERS = _load_provider_keys() DEFAULT_PROVIDER: ProviderName = os.getenv("AI_DEFAULT_PROVIDER", "deepseek") # type: ignore -def _get_provider_config(provider: Optional[ProviderName] = None) -> dict: +def _get_provider_config(provider: ProviderName | None = None) -> dict: """Get provider config, falling back to default if requested provider unavailable.""" p = provider or DEFAULT_PROVIDER if p not in PROVIDERS: @@ -83,7 +84,7 @@ def _get_provider_config(provider: Optional[ProviderName] = None) -> dict: return {"name": p, **cfg} -async def _call_deepseek_openrouter(prompt: str, system: str, provider: Optional[ProviderName] = None, +async def _call_deepseek_openrouter(prompt: str, system: str, provider: ProviderName | None = None, temperature: float = 0.7, max_tokens: int = 2048) -> str: """Call OpenAI-compatible API (DeepSeek, OpenRouter).""" cfg = _get_provider_config(provider) @@ -133,7 +134,7 @@ async def _call_gemini(prompt: str, system: str, temperature: float = 0.7, max_t return data["candidates"][0]["content"]["parts"][0]["text"].strip() -async def ai_complete(prompt: str, provider: Optional[ProviderName] = None) -> str: +async def ai_complete(prompt: str, provider: ProviderName | None = None) -> str: """Generic AI completion. Routes to appropriate provider.""" cfg = _get_provider_config(provider) if cfg["name"] == "gemini": @@ -148,7 +149,7 @@ Your responses should be direct and concise. When editing text, return ONLY the no explanations or markdown fences.""" -async def ai_improve_writing(text: str, provider: Optional[ProviderName] = None) -> str: +async def ai_improve_writing(text: str, provider: ProviderName | None = None) -> str: """Improve writing quality while preserving meaning.""" return await _call_deepseek_openrouter( f"Improve the following text. Fix grammar, clarity, and flow. Preserve the original language and meaning.\n\n{text}", @@ -156,7 +157,7 @@ async def ai_improve_writing(text: str, provider: Optional[ProviderName] = None) ) -async def ai_fix_spelling(text: str, provider: Optional[ProviderName] = None) -> str: +async def ai_fix_spelling(text: str, provider: ProviderName | None = None) -> str: """Fix spelling and grammar errors.""" return await _call_deepseek_openrouter( f"Fix all spelling and grammar errors in this text. Return only the corrected text.\n\n{text}", @@ -164,7 +165,7 @@ async def ai_fix_spelling(text: str, provider: Optional[ProviderName] = None) -> ) -async def ai_make_shorter(text: str, provider: Optional[ProviderName] = None) -> str: +async def ai_make_shorter(text: str, provider: ProviderName | None = None) -> str: """Make text more concise.""" return await _call_deepseek_openrouter( f"Make this text shorter and more concise while preserving the key information.\n\n{text}", @@ -172,7 +173,7 @@ async def ai_make_shorter(text: str, provider: Optional[ProviderName] = None) -> ) -async def ai_make_longer(text: str, provider: Optional[ProviderName] = None) -> str: +async def ai_make_longer(text: str, provider: ProviderName | None = None) -> str: """Expand text with more detail.""" return await _call_deepseek_openrouter( f"Expand this text with more detail, examples, or explanation while keeping the same tone.\n\n{text}", @@ -180,7 +181,7 @@ async def ai_make_longer(text: str, provider: Optional[ProviderName] = None) -> ) -async def ai_simplify(text: str, provider: Optional[ProviderName] = None) -> str: +async def ai_simplify(text: str, provider: ProviderName | None = None) -> str: """Simplify language.""" return await _call_deepseek_openrouter( f"Simplify this text. Use clearer, more straightforward language. Avoid jargon.\n\n{text}", @@ -188,7 +189,7 @@ async def ai_simplify(text: str, provider: Optional[ProviderName] = None) -> str ) -async def ai_change_tone(text: str, tone: str, provider: Optional[ProviderName] = None) -> str: +async def ai_change_tone(text: str, tone: str, provider: ProviderName | None = None) -> str: """Change the tone of the text.""" return await _call_deepseek_openrouter( f"Rewrite this text in a {tone} tone. Preserve the original meaning.\n\n{text}", @@ -196,7 +197,7 @@ async def ai_change_tone(text: str, tone: str, provider: Optional[ProviderName] ) -async def ai_translate(text: str, target_lang: str, provider: Optional[ProviderName] = None) -> str: +async def ai_translate(text: str, target_lang: str, provider: ProviderName | None = None) -> str: """Translate text to target language.""" # Gemini is better at translation if provider is None and PROVIDERS["gemini"]["api_key"]: @@ -212,7 +213,7 @@ async def ai_translate(text: str, target_lang: str, provider: Optional[ProviderN ) -async def ai_explain(text: str, provider: Optional[ProviderName] = None) -> str: +async def ai_explain(text: str, provider: ProviderName | None = None) -> str: """Explain the selected text.""" return await _call_deepseek_openrouter( f"Explain the following text clearly and concisely:\n\n{text}", @@ -220,7 +221,7 @@ async def ai_explain(text: str, provider: Optional[ProviderName] = None) -> str: ) -async def ai_summarize(text: str, provider: Optional[ProviderName] = None) -> str: +async def ai_summarize(text: str, provider: ProviderName | None = None) -> str: """Summarize the selected text.""" return await _call_deepseek_openrouter( f"Summarize the following text concisely:\n\n{text}", @@ -228,7 +229,7 @@ async def ai_summarize(text: str, provider: Optional[ProviderName] = None) -> st ) -async def ai_continue_writing(text: str, provider: Optional[ProviderName] = None) -> str: +async def ai_continue_writing(text: str, provider: ProviderName | None = None) -> str: """Continue writing from the selected text.""" return await _call_deepseek_openrouter( f"Continue writing from where this text leaves off. Match the style and tone:\n\n{text}", @@ -236,7 +237,7 @@ async def ai_continue_writing(text: str, provider: Optional[ProviderName] = None ) -async def ai_custom_rewrite(text: str, instruction: str, provider: Optional[ProviderName] = None) -> str: +async def ai_custom_rewrite(text: str, instruction: str, provider: ProviderName | None = None) -> str: """Rewrite text based on a custom instruction.""" return await _call_deepseek_openrouter( f"Rewrite the following text according to this instruction: {instruction}\n\n{text}", @@ -244,7 +245,7 @@ async def ai_custom_rewrite(text: str, instruction: str, provider: Optional[Prov ) -async def ai_convert_to_list(text: str, provider: Optional[ProviderName] = None) -> str: +async def ai_convert_to_list(text: str, provider: ProviderName | None = None) -> str: """Convert paragraph text to a markdown list.""" return await _call_deepseek_openrouter( f"Convert this text into a well-organized markdown bullet list. Extract key points.\n\n{text}", @@ -252,7 +253,7 @@ async def ai_convert_to_list(text: str, provider: Optional[ProviderName] = None) ) -async def ai_convert_to_table(text: str, provider: Optional[ProviderName] = None) -> str: +async def ai_convert_to_table(text: str, provider: ProviderName | None = None) -> str: """Convert text to a markdown table.""" return await _call_deepseek_openrouter( f"Convert this information into a markdown table. Choose appropriate columns.\n\n{text}", @@ -260,7 +261,7 @@ async def ai_convert_to_table(text: str, provider: Optional[ProviderName] = None ) -async def ai_generate_frontmatter(text: str, provider: Optional[ProviderName] = None) -> str: +async def ai_generate_frontmatter(text: str, provider: ProviderName | None = None) -> str: """Generate YAML frontmatter for a markdown document.""" return await _call_deepseek_openrouter( f"Generate YAML frontmatter for this markdown document. Include: titre, tags (as list), catégorie, statut, date. Return ONLY the YAML between --- markers.\n\n{text}", @@ -268,7 +269,7 @@ async def ai_generate_frontmatter(text: str, provider: Optional[ProviderName] = ) -async def ai_inline_complete(text: str, provider: Optional[ProviderName] = None) -> str: +async def ai_inline_complete(text: str, provider: ProviderName | None = None) -> str: """Inline completion — suggest continuation.""" return await _call_deepseek_openrouter( f"Complete this text naturally. Return only the completion (just the new text, no repetition):\n\n{text}", @@ -276,7 +277,7 @@ async def ai_inline_complete(text: str, provider: Optional[ProviderName] = None) ) -async def ai_convert_to_canvas(text: str, provider: Optional[ProviderName] = None) -> str: +async def ai_convert_to_canvas(text: str, provider: ProviderName | None = None) -> str: """Convert text to a Mermaid diagram or canvas representation.""" return await _call_deepseek_openrouter( f"Convert this content into a Mermaid.js diagram if applicable, or a structured outline. Choose the best format.\n\n{text}", diff --git a/backend/ai_routes.py b/backend/ai_routes.py index 4c941dc..d3a82b9 100644 --- a/backend/ai_routes.py +++ b/backend/ai_routes.py @@ -1,16 +1,29 @@ """ObsiGate AI — API routes for AI-powered editor features.""" +import logging + from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field -from typing import Optional -import logging from backend.ai import ( - ai_improve_writing, ai_fix_spelling, ai_make_shorter, ai_make_longer, - ai_simplify, ai_change_tone, ai_translate, ai_explain, ai_summarize, - ai_continue_writing, ai_custom_rewrite, ai_convert_to_list, - ai_convert_to_table, ai_generate_frontmatter, ai_inline_complete, - ai_convert_to_canvas, DEFAULT_PROVIDER, PROVIDERS, + DEFAULT_PROVIDER, + PROVIDERS, + ai_change_tone, + ai_continue_writing, + ai_convert_to_canvas, + ai_convert_to_list, + ai_convert_to_table, + ai_custom_rewrite, + ai_explain, + ai_fix_spelling, + ai_generate_frontmatter, + ai_improve_writing, + ai_inline_complete, + ai_make_longer, + ai_make_shorter, + ai_simplify, + ai_summarize, + ai_translate, ) logger = logging.getLogger("obsigate.ai_routes") @@ -81,10 +94,10 @@ async def api_status(): class AIRequest(BaseModel): text: str = Field(..., description="Input text to process", min_length=1) - instruction: Optional[str] = Field(None, description="Custom instruction for rewrite") - target_lang: Optional[str] = Field(None, description="Target language for translation") - tone: Optional[str] = Field(None, description="Target tone (professional, casual, etc.)") - provider: Optional[str] = Field(None, description="AI provider override") + instruction: str | None = Field(None, description="Custom instruction for rewrite") + target_lang: str | None = Field(None, description="Target language for translation") + tone: str | None = Field(None, description="Target tone (professional, casual, etc.)") + provider: str | None = Field(None, description="AI provider override") class AIResponse(BaseModel): @@ -101,7 +114,7 @@ async def _handle(action, request: AIRequest): raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"AI error: {e}") - raise HTTPException(status_code=500, detail=f"AI service error: {str(e)}") + raise HTTPException(status_code=500, detail=f"AI service error: {e!s}") @router.post("/improve", response_model=AIResponse) diff --git a/backend/attachment_indexer.py b/backend/attachment_indexer.py index 6953796..6c32c72 100644 --- a/backend/attachment_indexer.py +++ b/backend/attachment_indexer.py @@ -1,8 +1,8 @@ import asyncio import logging -from pathlib import Path -from typing import Any, Dict, List, Optional import threading +from pathlib import Path +from typing import Any logger = logging.getLogger("obsigate.attachment_indexer") @@ -10,16 +10,16 @@ logger = logging.getLogger("obsigate.attachment_indexer") IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"} # Global attachment index: {vault_name: {filename_lower: [absolute_path, ...]}} -attachment_index: Dict[str, Dict[str, List[Path]]] = {} +attachment_index: dict[str, dict[str, list[Path]]] = {} # Resolution cache: {(vault_name, filename): resolved_path} -_resolution_cache: Dict[tuple, Optional[Path]] = {} +_resolution_cache: dict[tuple, Path | None] = {} # Thread-safe lock for index updates _attachment_lock = threading.Lock() -def clear_resolution_cache(vault_name: Optional[str] = None) -> None: +def clear_resolution_cache(vault_name: str | None = None) -> None: """Clear the resolution cache for a specific vault or all vaults. Args: @@ -29,12 +29,12 @@ def clear_resolution_cache(vault_name: Optional[str] = None) -> None: if vault_name is None: _resolution_cache.clear() else: - keys_to_remove = [k for k in _resolution_cache.keys() if k[0] == vault_name] + keys_to_remove = [k for k in _resolution_cache if k[0] == vault_name] for key in keys_to_remove: del _resolution_cache[key] -def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict | None = None) -> Dict[str, List[Path]]: +def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict | None = None) -> dict[str, list[Path]]: """Synchronously scan a vault directory for image attachments. Walks the vault tree and builds a filename -> absolute path mapping @@ -49,7 +49,7 @@ def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict | Dict mapping lowercase filenames to lists of absolute paths. """ vault_root = Path(vault_path) - index: Dict[str, List[Path]] = {} + index: dict[str, list[Path]] = {} if not vault_root.exists(): logger.warning(f"Vault path does not exist for attachment scan: {vault_path}") @@ -84,7 +84,7 @@ def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict | return index -async def build_attachment_index(vault_config: Dict[str, Dict[str, Any]]) -> None: +async def build_attachment_index(vault_config: dict[str, dict[str, Any]]) -> None: """Build the attachment index for all configured vaults. Runs vault scans concurrently in a thread pool, then performs @@ -101,9 +101,9 @@ async def build_attachment_index(vault_config: Dict[str, Dict[str, Any]]) -> Non return loop = asyncio.get_event_loop() - new_index: Dict[str, Dict[str, List[Path]]] = {} + new_index: dict[str, dict[str, list[Path]]] = {} - tasks: list[tuple[str, asyncio.Future[Dict[str, List[Path]]]]] = [] + tasks: list[tuple[str, asyncio.Future[dict[str, list[Path]]]]] = [] for name, config in vault_config.items(): vault_path = config.get("path") if not vault_path: @@ -159,9 +159,9 @@ def resolve_image_path( image_src: str, vault_name: str, vault_root: Path, - current_file_path: Optional[Path] = None, - attachments_path: Optional[str] = None -) -> Optional[Path]: + current_file_path: Path | None = None, + attachments_path: str | None = None +) -> Path | None: """Resolve an image source path using multi-strategy resolution. Applies 7 resolution strategies in priority order: @@ -193,8 +193,8 @@ def resolve_image_path( resolved = None # Strategy 1: Absolute path - if src_path.is_absolute(): - if src_path.exists() and src_path.is_file(): + if src_path.is_absolute() and src_path.exists() and src_path.is_file(): + resolved = src_path logger.debug(f"Image resolved via strategy 1 (absolute): {image_src}") @@ -279,7 +279,7 @@ def resolve_image_path( return resolved -def get_attachment_stats(vault_name: Optional[str] = None) -> Dict[str, int]: +def get_attachment_stats(vault_name: str | None = None) -> dict[str, int]: """Get attachment statistics for a vault or all vaults. Args: diff --git a/backend/audit.py b/backend/audit.py index b3b5124..dfe9879 100644 --- a/backend/audit.py +++ b/backend/audit.py @@ -8,11 +8,10 @@ Format: JSON lines (one JSON object per line) for easy parsing. """ import json -import os import logging +import os from datetime import datetime, timezone from pathlib import Path -from typing import Optional logger = logging.getLogger("obsigate.audit") @@ -51,7 +50,7 @@ def log_file_save( vault_name: str, file_path: str, size: int, - ip: Optional[str] = None, + ip: str | None = None, ): """Log a file save (PUT) operation.""" _write_entry({ @@ -69,7 +68,7 @@ def log_file_delete( username: str, vault_name: str, file_path: str, - ip: Optional[str] = None, + ip: str | None = None, ): """Log a file delete operation.""" _write_entry({ @@ -85,7 +84,7 @@ def log_file_delete( def log_config_change( username: str, changes: dict, - ip: Optional[str] = None, + ip: str | None = None, ): """Log a configuration change.""" _write_entry({ @@ -97,7 +96,7 @@ def log_config_change( }) -def log_vault_add(username: str, vault_name: str, vault_path: str, ip: Optional[str] = None): +def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | None = None): """Log a vault addition.""" _write_entry({ "timestamp": datetime.now(timezone.utc).isoformat(), @@ -109,7 +108,7 @@ def log_vault_add(username: str, vault_name: str, vault_path: str, ip: Optional[ }) -def log_vault_remove(username: str, vault_name: str, ip: Optional[str] = None): +def log_vault_remove(username: str, vault_name: str, ip: str | None = None): """Log a vault removal.""" _write_entry({ "timestamp": datetime.now(timezone.utc).isoformat(), @@ -120,7 +119,7 @@ def log_vault_remove(username: str, vault_name: str, ip: Optional[str] = None): }) -def get_recent_entries(limit: int = 100, action: Optional[str] = None) -> list: +def get_recent_entries(limit: int = 100, action: str | None = None) -> list: """Read the most recent audit log entries. Args: diff --git a/backend/auth/jwt_handler.py b/backend/auth/jwt_handler.py index 0714d9b..65c152f 100644 --- a/backend/auth/jwt_handler.py +++ b/backend/auth/jwt_handler.py @@ -4,14 +4,14 @@ # Revoked token JTIs persisted to data/revoked_tokens.json. import json +import logging import os import secrets -import uuid import time -import logging +import uuid from pathlib import Path -from jose import jwt, JWTError -from typing import Optional + +from jose import JWTError, jwt logger = logging.getLogger("obsigate.auth.jwt") @@ -76,7 +76,7 @@ def create_refresh_token(username: str) -> tuple: return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM), jti -def decode_token(token: str) -> Optional[dict]: +def decode_token(token: str) -> dict | None: """Decode and validate a JWT. Returns None if invalid/expired.""" try: return jwt.decode(token, get_secret_key(), algorithms=[ALGORITHM]) diff --git a/backend/auth/middleware.py b/backend/auth/middleware.py index fe16422..86600d7 100644 --- a/backend/auth/middleware.py +++ b/backend/auth/middleware.py @@ -2,11 +2,11 @@ # FastAPI dependencies for authentication and authorization. # Reads JWT from Authorization header OR access_token cookie. -import os import logging -from fastapi import Request, HTTPException, Depends -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from typing import Optional +import os + +from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from .jwt_handler import decode_token from .user_store import get_user @@ -26,8 +26,8 @@ def is_auth_enabled() -> bool: def get_current_user( request: Request, - credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), -) -> Optional[dict]: + credentials: HTTPAuthorizationCredentials | None = Depends(security), +) -> dict | None: """Extract and validate the current user from JWT. Reads token from Authorization header first, falls back to access_token cookie. diff --git a/backend/auth/password.py b/backend/auth/password.py index 819f654..76e303b 100644 --- a/backend/auth/password.py +++ b/backend/auth/password.py @@ -3,7 +3,7 @@ # Parameters: time_cost=2, memory_cost=64MB, parallelism=2 from argon2 import PasswordHasher -from argon2.exceptions import VerifyMismatchError, VerificationError +from argon2.exceptions import VerificationError, VerifyMismatchError ph = PasswordHasher( time_cost=2, diff --git a/backend/auth/router.py b/backend/auth/router.py index 09ac891..0922558 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -2,24 +2,37 @@ # All /api/auth/* endpoints: login, logout, refresh, me, change-password, # and admin user CRUD. -import re import logging -from fastapi import APIRouter, HTTPException, Response, Request, Depends +import re + +from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel, validator -from typing import List, Optional + +from backend.ratelimit import is_rate_limited +from backend.ratelimit import record_failure as rl_record_failure +from backend.ratelimit import record_success as rl_record_success -from .user_store import ( - get_user, get_all_users, create_user, update_user, delete_user, - record_login_success, record_login_failure, is_locked, has_users, -) from .jwt_handler import ( - create_access_token, create_refresh_token, decode_token, - revoke_token, is_token_revoked, ACCESS_TOKEN_EXPIRE_SECONDS, + create_access_token, + create_refresh_token, + decode_token, + is_token_revoked, + revoke_token, +) +from .middleware import is_auth_enabled, require_admin, require_auth +from .password import hash_password, verify_password +from .user_store import ( + create_user, + delete_user, + get_all_users, + get_user, + has_users, + is_locked, + record_login_failure, + record_login_success, + update_user, ) -from .middleware import require_auth, require_admin, is_auth_enabled -from .password import verify_password, hash_password -from backend.ratelimit import is_rate_limited, record_failure as rl_record_failure, record_success as rl_record_success logger = logging.getLogger("obsigate.auth.router") @@ -48,9 +61,9 @@ class ChangePasswordRequest(BaseModel): class CreateUserRequest(BaseModel): username: str password: str - display_name: Optional[str] = None + display_name: str | None = None role: str = "user" - vaults: List[str] = [] + vaults: list[str] = [] @validator("username") def username_valid(cls, v): @@ -66,11 +79,11 @@ class CreateUserRequest(BaseModel): class UpdateUserRequest(BaseModel): - display_name: Optional[str] = None - vaults: Optional[List[str]] = None - active: Optional[bool] = None - password: Optional[str] = None - role: Optional[str] = None + display_name: str | None = None + vaults: list[str] | None = None + active: bool | None = None + password: str | None = None + role: str | None = None # ── Public endpoints ────────────────────────────────────────────────── @@ -249,8 +262,8 @@ async def get_me(current_user=Depends(require_auth)): class UpdateMeRequest(BaseModel): """Fields the user can update on their own profile.""" - display_name: Optional[str] = None - language: Optional[str] = None + display_name: str | None = None + language: str | None = None @router.patch("/me") diff --git a/backend/auth/user_store.py b/backend/auth/user_store.py index 8e1b7b3..12c0b94 100644 --- a/backend/auth/user_store.py +++ b/backend/auth/user_store.py @@ -4,12 +4,11 @@ # via tmp+rename to prevent corruption on crash. import json -import uuid import logging import shutil +import uuid +from datetime import datetime, timedelta, timezone from pathlib import Path -from datetime import datetime, timezone, timedelta -from typing import Optional, List from .password import hash_password @@ -54,12 +53,12 @@ def has_users() -> bool: return bool(_read()["users"]) -def get_user(username: str) -> Optional[dict]: +def get_user(username: str) -> dict | None: """Get a user by username. Returns None if not found.""" return _read()["users"].get(username) -def get_all_users() -> List[dict]: +def get_all_users() -> list[dict]: """Get all users WITHOUT password_hash (safe for API responses).""" users = _read()["users"] return [ @@ -72,8 +71,8 @@ def create_user( username: str, password: str, role: str = "user", - vaults: Optional[List[str]] = None, - display_name: Optional[str] = None, + vaults: list[str] | None = None, + display_name: str | None = None, ) -> dict: """Create a new user. Raises ValueError if username already taken.""" data = _read() diff --git a/backend/create_admin.py b/backend/create_admin.py index bb41668..e4c7c4f 100644 --- a/backend/create_admin.py +++ b/backend/create_admin.py @@ -11,14 +11,15 @@ Docker usage: docker exec obsigate python backend/create_admin.py list """ -import sys import argparse +import sys # Add parent directory to path for imports from pathlib import Path + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from backend.auth.user_store import create_user, get_all_users, delete_user +from backend.auth.user_store import create_user, delete_user, get_all_users def main(): diff --git a/backend/history.py b/backend/history.py index c6f3404..c64e083 100644 --- a/backend/history.py +++ b/backend/history.py @@ -1,10 +1,10 @@ # backend/history.py import json -import time import logging import shutil +import time from pathlib import Path -from typing import List, Dict, Any, Optional +from typing import Any logger = logging.getLogger("obsigate.history") @@ -18,7 +18,7 @@ def _get_bookmarks_file(username: str) -> Path: HISTORY_DIR.mkdir(parents=True, exist_ok=True) return HISTORY_DIR / f"{username}_bookmarks.json" -def _read_data(file: Path) -> List[Dict[str, Any]]: +def _read_data(file: Path) -> list[dict[str, Any]]: if not file.exists(): return [] try: @@ -27,7 +27,7 @@ def _read_data(file: Path) -> List[Dict[str, Any]]: logger.error(f"Failed to read data from {file.name}: {e}") return [] -def _write_data(file: Path, data: List[Dict[str, Any]]): +def _write_data(file: Path, data: list[dict[str, Any]]): try: tmp = file.with_suffix(".tmp") tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") @@ -58,7 +58,7 @@ def record_open(username: str, vault: str, path: str, title: str = ""): _write_data(_get_history_file(username), history) -def get_recent_opened(username: str, vault_filter: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]: +def get_recent_opened(username: str, vault_filter: str | None = None, limit: int = 20) -> list[dict[str, Any]]: """Get the most recently opened files for a user.""" if not username: return [] @@ -105,7 +105,7 @@ def toggle_bookmark(username: str, vault: str, path: str, title: str = ""): _write_data(b_file, bookmarks) return True -def get_bookmarks(username: str, vault_filter: Optional[str] = None) -> List[Dict[str, Any]]: +def get_bookmarks(username: str, vault_filter: str | None = None) -> list[dict[str, Any]]: """Get the bookmarks for a user.""" if not username: return [] diff --git a/backend/image_processor.py b/backend/image_processor.py index a754423..b9b2ac2 100644 --- a/backend/image_processor.py +++ b/backend/image_processor.py @@ -1,8 +1,7 @@ -import re import logging -from pathlib import Path -from typing import Optional +import re from html import escape as html_escape +from pathlib import Path from backend.attachment_indexer import resolve_image_path @@ -13,8 +12,8 @@ def preprocess_images( content: str, vault_name: str, vault_root: Path, - current_file_path: Optional[Path] = None, - attachments_path: Optional[str] = None + current_file_path: Path | None = None, + attachments_path: str | None = None ) -> str: """Preprocess markdown content to transform all Obsidian image syntaxes. @@ -55,8 +54,8 @@ def _process_html_img_in_link( content: str, vault_name: str, vault_root: Path, - current_file_path: Optional[Path], - attachments_path: Optional[str] + current_file_path: Path | None, + attachments_path: str | None ) -> str: """Process [](url) syntax. @@ -119,8 +118,8 @@ def _process_wikilink_embeds( content: str, vault_name: str, vault_root: Path, - current_file_path: Optional[Path], - attachments_path: Optional[str] + current_file_path: Path | None, + attachments_path: str | None ) -> str: """Process ![[image]] and ![[path/to/image]] wiki-link embeds. @@ -158,8 +157,8 @@ def _process_standard_images( content: str, vault_name: str, vault_root: Path, - current_file_path: Optional[Path], - attachments_path: Optional[str] + current_file_path: Path | None, + attachments_path: str | None ) -> str: """Process ![alt](path) standard markdown images. diff --git a/backend/indexer.py b/backend/indexer.py index e5d00df..e493608 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -1,21 +1,22 @@ -import os import asyncio import logging +import os import re import threading -from pathlib import Path +from collections.abc import Callable from datetime import datetime, timezone -from typing import Any, Callable, Dict, List, Optional +from pathlib import Path +from typing import Any import frontmatter logger = logging.getLogger("obsigate.indexer") # Global in-memory index -index: Dict[str, Dict[str, Any]] = {} +index: dict[str, dict[str, Any]] = {} # Vault config: {name: {path, attachmentsPath, scanAttachmentsOnStartup}} -vault_config: Dict[str, Dict[str, Any]] = {} +vault_config: dict[str, dict[str, Any]] = {} # Thread-safe lock for index updates _index_lock = threading.Lock() @@ -41,13 +42,13 @@ def set_index_change_hook(hook): _on_index_change = hook # O(1) lookup table for wikilink resolution: {filename_lower: [{vault, path}, ...]} -_file_lookup: Dict[str, List[Dict[str, str]]] = {} +_file_lookup: dict[str, list[dict[str, str]]] = {} # Backlink index: {vault_name: {relative_path: [{vault, path, title}, ...]}} -_backlink_index: Dict[str, Dict[str, List[Dict[str, str]]]] = {} +_backlink_index: dict[str, dict[str, list[dict[str, str]]]] = {} # O(1) path index for tree filtering: {vault_name: [{path, name, type}, ...]} -path_index: Dict[str, List[Dict[str, str]]] = {} +path_index: dict[str, list[dict[str, str]]] = {} # Maximum content size stored per file for in-memory search (bytes) SEARCH_CONTENT_LIMIT = 100_000 @@ -68,10 +69,10 @@ SUPPORTED_EXTENSIONS = { # Ignored directories (configurable via OBSIGATE_IGNORED_DIRS env var) _DEFAULT_IGNORED = {'.obsidian', '.trash', '.git', '__pycache__', 'node_modules', '.obsigate-backup'} _env_ignored = os.environ.get("OBSIGATE_IGNORED_DIRS", "") -IGNORED_DIRS = set(d.strip() for d in _env_ignored.split(",") if d.strip()) if _env_ignored else _DEFAULT_IGNORED.copy() +IGNORED_DIRS = {d.strip() for d in _env_ignored.split(",") if d.strip()} if _env_ignored else _DEFAULT_IGNORED.copy() -def load_vault_config() -> Dict[str, Dict[str, Any]]: +def load_vault_config() -> dict[str, dict[str, Any]]: """Read VAULT_N_* and DIR_N_* env vars and return vault configuration. Scans environment variables ``VAULT_1_NAME``/``VAULT_1_PATH``, @@ -89,7 +90,7 @@ def load_vault_config() -> Dict[str, Dict[str, Any]]: - scanAttachmentsOnStartup: boolean (default True) - type: "VAULT" or "DIR" """ - vaults: Dict[str, Dict[str, Any]] = {} + vaults: dict[str, dict[str, Any]] = {} n = 1 while True: name = os.environ.get(f"VAULT_{n}_NAME") @@ -135,7 +136,7 @@ _CODE_BLOCK_RE = re.compile(r'```[\s\S]*?```', re.MULTILINE) _INLINE_CODE_RE = re.compile(r'`[^`]+`') -def _extract_tags(post: frontmatter.Post) -> List[str]: +def _extract_tags(post: frontmatter.Post) -> list[str]: """Extract tags from frontmatter metadata. @@ -158,7 +159,7 @@ def _extract_tags(post: frontmatter.Post) -> List[str]: return tags -def _extract_inline_tags(content: str) -> List[str]: +def _extract_inline_tags(content: str) -> list[str]: """Extract inline #tag patterns from markdown content. Strips fenced and inline code blocks before scanning to avoid @@ -215,10 +216,10 @@ def parse_markdown_file(raw: str) -> frontmatter.Post: match = re.match(r"^---\s*\r?\n.*?\r?\n---\s*\r?\n?", raw, flags=re.DOTALL) if match: content = raw[match.end():] - return frontmatter.Post(content, **{}) + return frontmatter.Post(content) -def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | None = None) -> dict[str, Any]: """Synchronously scan a single vault directory and build file index. Walks the vault tree, reads supported files, extracts metadata @@ -236,9 +237,9 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str, Dict with keys ``files`` (list), ``tags`` (counter dict), ``path`` (str), ``paths`` (list). """ vault_root = Path(vault_path) - files: List[Dict[str, Any]] = [] - tag_counts: Dict[str, int] = {} - paths: List[Dict[str, str]] = [] + files: list[dict[str, Any]] = [] + tag_counts: dict[str, int] = {} + paths: list[dict[str, str]] = [] if not vault_root.exists(): logger.warning(f"Vault path does not exist: {vault_path}") @@ -283,15 +284,15 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str, # PDF handling — special path (binary, uses pdf_reader) if ext == ".pdf": - from backend.pdf_reader import extract_pdf_text, extract_pdf_metadata + from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text raw = extract_pdf_text(fpath, max_chars=100000) pdf_meta = extract_pdf_metadata(fpath) title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ") content_preview = raw[:200].strip() - tags: List[str] = [] + tags: list[str] = [] else: raw = fpath.read_text(encoding="utf-8", errors="replace") - tags: List[str] = [] + tags: list[str] = [] title = fpath.stem.replace("-", " ").replace("_", " ") content_preview = raw[:200].strip() @@ -365,13 +366,13 @@ async def build_index(progress_callback=None) -> None: loop = asyncio.get_event_loop() - async def _process_vault(name: str, config: Dict[str, Any]): + async def _process_vault(name: str, config: dict[str, Any]): vault_path = config["path"] vault_data = await loop.run_in_executor(None, _scan_vault, name, vault_path, config) vault_data["config"] = config # Build lookup entries for the new vault - new_lookup_entries: Dict[str, List[Dict[str, str]]] = {} + new_lookup_entries: dict[str, list[dict[str, str]]] = {} for f in vault_data["files"]: entry = {"vault": name, "path": f["path"]} fname = f["path"].rsplit("/", 1)[-1].lower() @@ -419,7 +420,7 @@ async def build_index(progress_callback=None) -> None: await progress_callback("complete", {"total_vaults": len(vault_config), "total_files": total_files}) -async def reload_index() -> Dict[str, Any]: +async def reload_index() -> dict[str, Any]: """Force a full re-index of all vaults and return per-vault statistics. Returns: @@ -432,7 +433,7 @@ async def reload_index() -> Dict[str, Any]: return stats -async def reload_single_vault(vault_name: str) -> Dict[str, Any]: +async def reload_single_vault(vault_name: str) -> dict[str, Any]: """Force a re-index of a single vault and return its statistics. Args: @@ -464,7 +465,7 @@ async def reload_single_vault(vault_name: str) -> Dict[str, Any]: vault_data["config"] = config # Build lookup entries for the vault - new_lookup_entries: Dict[str, List[Dict[str, str]]] = {} + new_lookup_entries: dict[str, list[dict[str, str]]] = {} for f in vault_data["files"]: entry = {"vault": vault_name, "path": f["path"]} fname = f["path"].rsplit("/", 1)[-1].lower() @@ -495,12 +496,12 @@ async def reload_single_vault(vault_name: str) -> Dict[str, Any]: return stats -def get_vault_names() -> List[str]: +def get_vault_names() -> list[str]: """Return the list of all indexed vault names.""" return list(index.keys()) -def get_vault_data(vault_name: str) -> Optional[Dict[str, Any]]: +def get_vault_data(vault_name: str) -> dict[str, Any] | None: """Return the full index data for a vault, or ``None`` if not found.""" return index.get(vault_name) @@ -513,7 +514,7 @@ def _get_async_lock() -> asyncio.Lock: return _async_index_lock -def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, vault_cfg: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: +def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, vault_cfg: dict[str, Any] | None = None) -> dict[str, Any] | None: """Synchronously read and parse a single file for indexing. All files are indexed, including hidden files (starting with '.'). @@ -545,7 +546,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() raw = fpath.read_text(encoding="utf-8", errors="replace") - tags: List[str] = [] + tags: list[str] = [] title = fpath.stem.replace("-", " ").replace("_", " ") content_preview = raw[:200].strip() @@ -575,7 +576,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va return None -def _remove_file_from_structures(vault_name: str, rel_path: str) -> Optional[Dict[str, Any]]: +def _remove_file_from_structures(vault_name: str, rel_path: str) -> dict[str, Any] | None: """Remove a file from all index structures. Returns removed file info or None. Must be called under _index_lock or _async_index_lock. @@ -650,7 +651,7 @@ def _ensure_parent_dirs_in_path_index(vault_name: str, rel_path: str, existing: }) -def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]): +def _add_file_to_structures(vault_name: str, file_info: dict[str, Any]): """Add a file entry to all index structures. Must be called under _index_lock or _async_index_lock. @@ -695,7 +696,7 @@ def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]): _on_index_change('add', vault_name, file_info["path"], file_info) # type: ignore[misc] -async def update_single_file(vault_name: str, abs_file_path: str) -> Optional[Dict[str, Any]]: +async def update_single_file(vault_name: str, abs_file_path: str) -> dict[str, Any] | None: """Re-index a single file without full rebuild. Reads the file, removes the old entry if present, inserts the new one. @@ -742,7 +743,7 @@ async def update_single_file(vault_name: str, abs_file_path: str) -> Optional[Di return file_info -async def remove_single_file(vault_name: str, abs_file_path: str) -> Optional[Dict[str, Any]]: +async def remove_single_file(vault_name: str, abs_file_path: str) -> dict[str, Any] | None: """Remove a single file from the index. Args: @@ -774,7 +775,7 @@ async def remove_single_file(vault_name: str, abs_file_path: str) -> Optional[Di return removed -async def handle_file_move(vault_name: str, src_abs: str, dest_abs: str) -> Optional[Dict[str, Any]]: +async def handle_file_move(vault_name: str, src_abs: str, dest_abs: str) -> dict[str, Any] | None: """Handle a file move/rename by removing old entry and indexing new location. Args: @@ -823,7 +824,7 @@ async def remove_vault_from_index(vault_name: str): logger.info(f"Removed vault '{vault_name}' from index") -async def add_vault_to_index(vault_name: str, vault_path: str) -> Dict[str, Any]: +async def add_vault_to_index(vault_name: str, vault_path: str) -> dict[str, Any]: """Add a new vault to the index dynamically. Args: @@ -846,7 +847,7 @@ async def add_vault_to_index(vault_name: str, vault_path: str) -> Dict[str, Any] vault_data["config"] = vault_config[vault_name] # Build lookup entries for the new vault - new_lookup_entries: Dict[str, List[Dict[str, str]]] = {} + new_lookup_entries: dict[str, list[dict[str, str]]] = {} for f in vault_data["files"]: entry = {"vault": vault_name, "path": f["path"]} fname = f["path"].rsplit("/", 1)[-1].lower() @@ -871,7 +872,7 @@ async def add_vault_to_index(vault_name: str, vault_path: str) -> Dict[str, Any] return stats -def find_file_in_index(link_target: str, current_vault: str) -> Optional[Dict[str, str]]: +def find_file_in_index(link_target: str, current_vault: str) -> dict[str, str] | None: """Find a file matching a wikilink target using O(1) lookup table. Searches by filename first, then by full relative path. @@ -943,7 +944,7 @@ def _extract_wikilinks_for_backlinks( }) -def get_backlinks(vault_name: str, file_path: str) -> List[Dict[str, str]]: +def get_backlinks(vault_name: str, file_path: str) -> list[dict[str, str]]: """Get all files that link to the given file via wikilinks. Searches across all vaults for backlinks pointing to the target file. @@ -961,7 +962,7 @@ def get_backlinks(vault_name: str, file_path: str) -> List[Dict[str, str]]: target_key += ".md" results = [] - for vname, vindex in _backlink_index.items(): + for vindex in _backlink_index.values(): bl = vindex.get(target_key, []) results.extend(bl) return results diff --git a/backend/main.py b/backend/main.py index 1748507..d810b38 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,10 +1,10 @@ import asyncio -import json as _json -import os -import re import html as html_mod +import json as _json import logging import mimetypes +import os +import re import secrets import shutil import string @@ -15,43 +15,59 @@ from contextlib import asynccontextmanager from datetime import datetime, timezone from functools import partial from pathlib import Path -from typing import Optional, List, Dict, Any +from typing import Any import frontmatter import mistune -from fastapi import FastAPI, HTTPException, Query, Body, Depends +from fastapi import Body, Depends, FastAPI, HTTPException, Query +from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse from fastapi.staticfiles import StaticFiles -from fastapi.responses import HTMLResponse, FileResponse, Response, StreamingResponse from pydantic import BaseModel, Field from starlette.middleware.base import BaseHTTPMiddleware +from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments +from backend.history import ( + get_bookmarks, + get_recent_opened, + is_bookmarked, + record_open, + remove_recent, + toggle_bookmark, + update_bookmarks_after_rename, + update_history_after_rename, +) +from backend.image_processor import preprocess_images from backend.indexer import ( + IGNORED_DIRS, + SUPPORTED_EXTENSIONS, + _extract_tags, + add_vault_to_index, build_index, - reload_index, - index, - path_index, - get_vault_data, find_file_in_index, get_backlinks, get_conflicts, - parse_markdown_file, - _extract_tags, - SUPPORTED_EXTENSIONS, - IGNORED_DIRS, - update_single_file, - remove_single_file, + get_vault_data, handle_file_move, + index, + parse_markdown_file, + path_index, + reload_index, + remove_single_file, remove_vault_from_index, - add_vault_to_index, + update_single_file, +) +from backend.search import ( + advanced_search, + get_all_tags, + init_inverted_index, + search, + suggest_tags, + suggest_titles, ) -from backend.search import search, get_all_tags, advanced_search, suggest_titles, suggest_tags, init_inverted_index -from backend.image_processor import preprocess_images -from backend.attachment_indexer import rescan_vault_attachments, get_attachment_stats from backend.vault_settings import ( get_vault_setting, update_vault_setting, ) -from backend.history import record_open, get_recent_opened, remove_recent, toggle_bookmark, get_bookmarks, is_bookmarked, update_bookmarks_after_rename, update_history_after_rename logging.basicConfig( level=logging.INFO, @@ -77,16 +93,16 @@ class BrowseItem(BaseModel): name: str = Field(description="File or directory name") path: str = Field(description="Relative path within vault") type: str = Field(description="'file' or 'directory'") - children_count: Optional[int] = Field(default=None, description="Number of children (directories only)") - size: Optional[int] = Field(default=None, description="File size in bytes") - extension: Optional[str] = Field(default=None, description="File extension") + children_count: int | None = Field(default=None, description="Number of children (directories only)") + size: int | None = Field(default=None, description="File size in bytes") + extension: str | None = Field(default=None, description="File extension") class BrowseResponse(BaseModel): """Paginated directory listing for a vault.""" vault: str path: str - items: List[BrowseItem] + items: list[BrowseItem] class FileContentResponse(BaseModel): @@ -94,14 +110,14 @@ class FileContentResponse(BaseModel): vault: str = Field(description="Vault name") path: str = Field(description="Relative file path within the vault") title: str = Field(description="File title (from frontmatter or filename)") - tags: List[str] = Field(description="Extracted tags from frontmatter and inline #tags") - frontmatter: Dict[str, Any] = Field(description="YAML frontmatter as key-value dict") + tags: list[str] = Field(description="Extracted tags from frontmatter and inline #tags") + frontmatter: dict[str, Any] = Field(description="YAML frontmatter as key-value dict") html: str = Field(description="Rendered HTML content") raw_length: int = Field(description="Length of raw file content in characters") extension: str = Field(description="File extension (e.g. .md, .txt)") is_markdown: bool = Field(description="Whether the file is markdown") - unsupported: Optional[bool] = Field(default=False, description="True for binary/unsupported files") - size_bytes: Optional[int] = Field(default=None, description="File size in bytes (for unsupported files)") + unsupported: bool | None = Field(default=False, description="True for binary/unsupported files") + size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)") class FileRawResponse(BaseModel): @@ -131,7 +147,7 @@ class SearchResultItem(BaseModel): 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") + 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") @@ -141,18 +157,18 @@ 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: Optional[str] = Field(default=None, description="Tag filter applied") + 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") + results: list[SearchResultItem] = Field(description="Search result items") class TagsResponse(BaseModel): """Tag aggregation response.""" - vault_filter: Optional[str] = Field(default=None, description="Vault filter applied") - tags: Dict[str, int] = Field(description="Tag name → count mapping") + vault_filter: str | None = Field(default=None, description="Vault filter applied") + tags: dict[str, int] = Field(description="Tag name → count mapping") class TreeSearchResult(BaseModel): @@ -168,7 +184,7 @@ 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") + results: list[TreeSearchResult] = Field(description="Matching files and directories") class AdvancedSearchResultItem(BaseModel): @@ -176,7 +192,7 @@ class AdvancedSearchResultItem(BaseModel): 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") + tags: list[str] = Field(description="File tags") score: float = Field(description="TF-IDF relevance score") snippet: str = Field(description="Content excerpt with highlights") modified: str = Field(description="ISO 8601 modification timestamp") @@ -185,13 +201,13 @@ class AdvancedSearchResultItem(BaseModel): class SearchFacets(BaseModel): """Faceted counts for search results.""" - tags: Dict[str, int] = Field(default_factory=dict) - vaults: Dict[str, int] = Field(default_factory=dict) + 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") + 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") @@ -209,7 +225,7 @@ class TitleSuggestion(BaseModel): class SuggestResponse(BaseModel): """Autocomplete suggestions for file titles.""" query: str = Field(description="Original query string") - suggestions: List[TitleSuggestion] = Field(description="Matching file suggestions") + suggestions: list[TitleSuggestion] = Field(description="Matching file suggestions") class TagSuggestion(BaseModel): @@ -221,7 +237,7 @@ class TagSuggestion(BaseModel): class TagSuggestResponse(BaseModel): """Autocomplete suggestions for tags.""" query: str = Field(description="Original query string") - suggestions: List[TagSuggestion] = Field(description="Matching tag suggestions") + suggestions: list[TagSuggestion] = Field(description="Matching tag suggestions") class GraphNode(BaseModel): @@ -231,7 +247,7 @@ class GraphNode(BaseModel): 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") + 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") @@ -248,14 +264,14 @@ class GraphResponse(BaseModel): 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)") + 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") + vaults: dict[str, Any] = Field(description="Per-vault file counts after reload") class HealthResponse(BaseModel): @@ -353,7 +369,7 @@ class BackupListResponse(BaseModel): class DiffRequest(BaseModel): """Request parameters for generating a diff.""" version: int = Field(description="Timestamp of the backup version to compare") - compare_with: Optional[int] = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.") + compare_with: int | None = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.") class DiffResponse(BaseModel): @@ -361,7 +377,7 @@ class DiffResponse(BaseModel): vault: str = Field(description="Vault name") path: str = Field(description="Relative file path") version: int = Field(description="Backup version timestamp (left/old side)") - compare_with: Optional[int] = Field(default=None, description="Other backup version or null for current file (right/new side)") + compare_with: int | None = Field(default=None, description="Other backup version or null for current file (right/new side)") diff: str = Field(description="Unified diff (empty if no changes)") @@ -376,7 +392,7 @@ class RestoreResponse(BaseModel): vault: str = Field(description="Vault name") path: str = Field(description="Relative file path") restored_from: int = Field(description="Timestamp of the backup used") - current_backed_up: Optional[int] = 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") # --------------------------------------------------------------------------- @@ -388,7 +404,7 @@ class SSEManager: """Manages SSE client connections and broadcasts events.""" def __init__(self): - self._clients: List[asyncio.Queue] = [] + self._clients: list[asyncio.Queue] = [] async def connect(self) -> asyncio.Queue: """Register a new SSE client and return its message queue.""" @@ -406,7 +422,7 @@ class SSEManager: async def broadcast(self, event_type: str, data: dict): """Send an event to all connected SSE clients.""" message = _json.dumps(data, ensure_ascii=False) - dead: List[asyncio.Queue] = [] + dead: list[asyncio.Queue] = [] for q in self._clients: try: q.put_nowait({"event": event_type, "data": message}) @@ -427,12 +443,12 @@ sse_manager = SSEManager() # Application lifespan (replaces deprecated on_event) # --------------------------------------------------------------------------- -from backend.watcher import VaultWatcher # noqa: E402 +from backend.watcher import VaultWatcher # Thread pool for offloading CPU-bound search from the event loop. # Sized to 2 workers so concurrent searches don't starve other requests. -_search_executor: Optional[ThreadPoolExecutor] = None -_vault_watcher: Optional[VaultWatcher] = None +_search_executor: ThreadPoolExecutor | None = None +_vault_watcher: VaultWatcher | None = None async def _on_vault_change(events: list): @@ -493,7 +509,7 @@ def bootstrap_admin(): Only runs when auth is enabled and no users.json exists yet. """ from backend.auth.middleware import is_auth_enabled - from backend.auth.user_store import has_users, create_user + from backend.auth.user_store import create_user, has_users if not is_auth_enabled(): return @@ -621,8 +637,9 @@ app = FastAPI(title="ObsiGate", version="1.4.0", lifespan=lifespan) # GZip compression — reduces bandwidth by ~70% for text responses # Custom wrapper: skip compression for SSE streams (/api/events) -from fastapi.middleware.gzip import GZipMiddleware # noqa: E402 -from starlette.types import Scope, Receive, Send # noqa: E402 +from fastapi.middleware.gzip import GZipMiddleware +from starlette.types import Receive, Scope, Send + class SSESafeGZipMiddleware(GZipMiddleware): """GZip middleware that skips SSE (Server-Sent Events) streams. @@ -643,22 +660,40 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000) app.add_middleware(SecurityHeadersMiddleware) # Auth router -from backend.auth.router import router as auth_router # noqa: E402 -from backend.auth.middleware import require_auth, require_admin, check_vault_access # noqa: E402 -from backend.secret_redactor import redact_file_content # noqa: E402 -from backend.audit import log_file_save, log_file_delete # noqa: E402 +from backend.audit import log_file_delete, log_file_save +from backend.auth.middleware import ( + check_vault_access, + require_admin, + require_auth, +) +from backend.auth.router import router as auth_router +from backend.secret_redactor import redact_file_content + # Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere) try: - from backend.pdf_export import generate_pdf, build_pdf_html # noqa: E402 + from backend.pdf_export import build_pdf_html, generate_pdf except OSError: generate_pdf = None # type: ignore[assignment] build_pdf_html = None # type: ignore[assignment] import logging logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)") -from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares, update_shares_after_rename # noqa: E402 -from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks # noqa: E402 -from backend.saved_searches import get_saved, save_search, delete_saved # noqa: E402 -from backend.ai_routes import router as ai_router # noqa: E402 +from backend.ai_routes import router as ai_router +from backend.saved_searches import delete_saved, get_saved, save_search +from backend.share import ( + create_share, + get_share_by_token, + list_shares, + record_access, + revoke_share, + update_shares_after_rename, +) +from backend.webhooks import ( + create_webhook, + delete_webhook, + dispatch_webhooks, + get_webhooks, + update_webhook, +) app.include_router(auth_router) app.include_router(ai_router) @@ -696,7 +731,7 @@ def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path: vault_resolved = vault_root.resolve(strict=False) except Exception as e: logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}") - raise HTTPException(status_code=500, detail=f"Path resolution error: {str(e)}") + raise HTTPException(status_code=500, detail=f"Path resolution error: {e!s}") # Check if resolved path is within vault using string comparison (case-insensitive on Windows) try: @@ -767,7 +802,7 @@ def _check_vault_writable(vault_root: Path) -> bool: # Markdown rendering helpers (singleton renderer) # --------------------------------------------------------------------------- -import unicodedata # noqa: E402 +import unicodedata def _heading_slugify(text: str) -> str: @@ -815,7 +850,7 @@ def _add_heading_ids(html: str) -> str: Returns: HTML with heading IDs injected. """ - used_ids: Dict[str, int] = {} + used_ids: dict[str, int] = {} def _replace_heading(match): tag = match.group(1) @@ -871,7 +906,7 @@ def _convert_wikilinks(content: str, current_vault: str) -> str: return re.sub(pattern, _replace, content) -def _render_markdown(raw_md: str, vault_name: str, current_file_path: Optional[Path] = None) -> str: +def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | None = None) -> str: """Render a markdown string to HTML with wikilink and image support. Uses the cached singleton mistune renderer for performance. @@ -927,7 +962,7 @@ async def api_health(): } -@app.get("/api/vaults", response_model=List[VaultInfo]) +@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. @@ -962,7 +997,7 @@ def humanize_mtime(mtime: float) -> str: @app.get("/api/recent") -async def api_recent(limit: Optional[int] = Query(None), vault: Optional[str] = Query(None), mode: Optional[str] = Query("opened"), current_user=Depends(require_auth)): +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) @@ -1056,7 +1091,7 @@ async def api_recent(limit: Optional[int] = Query(None), vault: Optional[str] = @app.get("/api/bookmarks") -async def api_bookmarks(vault: Optional[str] = Query(None), current_user=Depends(require_auth)): +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", []) @@ -1101,7 +1136,7 @@ async def api_bookmarks(vault: Optional[str] = Query(None), current_user=Depends class BookmarkToggleRequest(BaseModel): vault: str path: str - title: Optional[str] = None + title: str | None = None @app.post("/api/bookmarks/toggle") async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(require_auth)): @@ -1283,10 +1318,10 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela raw = file_path.read_bytes().decode("utf-8", errors="replace") except Exception as e: logger.error(f"Error reading binary raw file {path}: {e}") - raise HTTPException(status_code=500, detail=f"Cannot read file: {str(e)}") + raise HTTPException(status_code=500, detail=f"Cannot read file: {e!s}") except Exception as e: logger.error(f"Unexpected error reading raw file {path}: {e}") - raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}") return {"vault": vault_name, "path": path, "raw": raw} @@ -1405,7 +1440,7 @@ async def api_file_save( raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only") except Exception as e: logger.error(f"Error saving file {vault_name}/{path}: {e}") - raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error saving file: {e!s}") @app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse) @@ -1468,7 +1503,7 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only") except Exception as e: logger.error(f"Error deleting file {vault_name}/{path}: {e}") - raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error deleting file: {e!s}") # --------------------------------------------------------------------------- @@ -1516,8 +1551,8 @@ async def api_directory_create( logger.info(f"Directory created: {vault_name}/{body.path}") # Update path_index with the new directory - from backend.indexer import path_index as _path_idx from backend.indexer import _index_lock + from backend.indexer import path_index as _path_idx with _index_lock: if vault_name not in _path_idx: _path_idx[vault_name] = [] @@ -1546,7 +1581,7 @@ async def api_directory_create( raise HTTPException(status_code=403, detail="Permission denied: cannot create directory") except Exception as e: logger.error(f"Error creating directory {vault_name}/{body.path}: {e}") - raise HTTPException(status_code=500, detail=f"Error creating directory: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error creating directory: {e!s}") @app.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse) @@ -1619,7 +1654,7 @@ async def api_directory_rename( raise HTTPException(status_code=403, detail="Permission denied: cannot rename directory") except Exception as e: logger.error(f"Error renaming directory {vault_name}/{body.path}: {e}") - raise HTTPException(status_code=500, detail=f"Error renaming directory: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error renaming directory: {e!s}") @app.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse) @@ -1681,7 +1716,7 @@ async def api_directory_delete( raise HTTPException(status_code=403, detail="Permission denied: cannot delete directory") except Exception as e: logger.error(f"Error deleting directory {vault_name}/{path}: {e}") - raise HTTPException(status_code=500, detail=f"Error deleting directory: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error deleting directory: {e!s}") # --------------------------------------------------------------------------- @@ -1751,7 +1786,7 @@ async def api_file_create( raise HTTPException(status_code=403, detail="Permission denied: cannot create file") except Exception as e: logger.error(f"Error creating file {vault_name}/{body.path}: {e}") - raise HTTPException(status_code=500, detail=f"Error creating file: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error creating file: {e!s}") @app.patch("/api/file/{vault_name}", response_model=FileRenameResponse) @@ -1833,7 +1868,7 @@ async def api_file_rename( raise HTTPException(status_code=403, detail="Permission denied: cannot rename file") except Exception as e: logger.error(f"Error renaming file {vault_name}/{body.path}: {e}") - raise HTTPException(status_code=500, detail=f"Error renaming file: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error renaming file: {e!s}") @app.post("/api/move/{vault_name}", response_model=FileMoveResponse) @@ -1945,7 +1980,7 @@ async def api_file_move( raise HTTPException(status_code=403, detail="Permission denied: cannot move item") except Exception as e: logger.error(f"Error moving item {vault_name}/{body.source_path}: {e}") - raise HTTPException(status_code=500, detail=f"Error moving item: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error moving item: {e!s}") # --------------------------------------------------------------------------- @@ -2055,7 +2090,7 @@ async def api_file_backups( backups = _list_backup_files(vault_name, path) except Exception as e: logger.error(f"Error listing backups for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Erreur lors de la lecture des backups: {str(e)}") + raise HTTPException(status_code=500, detail=f"Erreur lors de la lecture des backups: {e!s}") return {"vault": vault_name, "path": path, "backups": backups} @@ -2065,7 +2100,7 @@ async def api_file_diff( vault_name: str, path: str = Query(..., description="Relative path to file"), version: int = Query(..., description="Timestamp of the backup version (left/old side)"), - compare_with: Optional[int] = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."), + compare_with: int | None = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."), current_user=Depends(require_auth), ): """Generate a unified diff between a backup version and another version or the current file. @@ -2145,7 +2180,7 @@ async def api_file_diff( raise except Exception as e: logger.error(f"Error generating diff for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Erreur lors de la génération du diff: {str(e)}") + raise HTTPException(status_code=500, detail=f"Erreur lors de la génération du diff: {e!s}") @app.post("/api/file/{vault_name}/restore") @@ -2227,7 +2262,7 @@ async def api_file_restore( raise HTTPException(status_code=403, detail="Permission denied: cannot restore file") except Exception as e: logger.error(f"Error restoring file {vault_name}/{path}: {e}") - raise HTTPException(status_code=500, detail=f"Error restoring file: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error restoring file: {e!s}") @app.get("/api/file/{vault_name}/backlinks") @@ -2304,7 +2339,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative # === PDF: special handling before read_text (binary file) === if ext == ".pdf": try: - from backend.pdf_reader import extract_pdf_text, extract_pdf_metadata + from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text pdf_text = extract_pdf_text(file_path, max_chars=100000) pdf_meta = extract_pdf_metadata(file_path) size = file_path.stat().st_size @@ -2325,7 +2360,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative } except Exception as e: logger.error(f"PDF read error for {path}: {e}") - raise HTTPException(status_code=500, detail=f"Error reading PDF: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}") try: raw = file_path.read_text(encoding="utf-8", errors="replace") @@ -2350,7 +2385,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative } except Exception as e: logger.error(f"Unexpected error reading file {path}: {e}") - raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}") if ext == ".md": post = parse_markdown_file(raw) @@ -2414,7 +2449,7 @@ async def api_pdf_stream(vault_name: str, path: str = Query(...)): async def api_search( q: str = Query("", description="Search query"), vault: str = Query("all", description="Vault filter"), - tag: Optional[str] = Query(None, description="Tag 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), @@ -2451,7 +2486,7 @@ async def api_search( @app.get("/api/tags", response_model=TagsResponse) -async def api_tags(vault: Optional[str] = Query(None, description="Vault filter"), current_user=Depends(require_auth)): +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: @@ -2512,18 +2547,18 @@ async def api_tree_search( 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: Optional[str] = Query(None, description="Comma-separated tag 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: Optional[str] = Query(None, description="Comma-separated glob patterns to include"), - exclude_paths: Optional[str] = Query(None, description="Comma-separated glob patterns to exclude"), - created: Optional[str] = Query(None, description="Created date filter (>date, bytes: +def generate_pdf(html_content: str, title: str = "document", base_url: str | None = None) -> bytes: """Generate a PDF from HTML content. Args: diff --git a/backend/ratelimit.py b/backend/ratelimit.py index 4437476..e023a12 100644 --- a/backend/ratelimit.py +++ b/backend/ratelimit.py @@ -10,11 +10,10 @@ Configuration via environment variables: OBSIGATE_LOGIN_WINDOW_SECONDS Lockout window in seconds (default: 900 = 15min) """ +import logging import os import time -import logging from collections import defaultdict -from typing import Dict, Tuple logger = logging.getLogger("obsigate.ratelimit") @@ -23,7 +22,7 @@ MAX_ATTEMPTS = int(os.environ.get("OBSIGATE_LOGIN_MAX_ATTEMPTS", "10")) WINDOW_SECONDS = int(os.environ.get("OBSIGATE_LOGIN_WINDOW_SECONDS", "900")) # 15 min # --- In-memory store: {ip: [(timestamp, success_bool), ...]} --- -_ip_attempts: Dict[str, list] = defaultdict(list) +_ip_attempts: dict[str, list] = defaultdict(list) _last_cleanup = time.time() CLEANUP_INTERVAL = 60 # seconds @@ -45,7 +44,7 @@ def _cleanup_expired(): del _ip_attempts[ip] -def record_failure(ip: str) -> Tuple[int, int]: +def record_failure(ip: str) -> tuple[int, int]: """Record a failed login attempt from an IP. Returns: diff --git a/backend/saved_searches.py b/backend/saved_searches.py index 17eee00..4892c96 100644 --- a/backend/saved_searches.py +++ b/backend/saved_searches.py @@ -5,11 +5,11 @@ Stored in data/saved_searches.json per user. """ import json -import time import logging import shutil +import time from pathlib import Path -from typing import List, Dict, Any +from typing import Any logger = logging.getLogger("obsigate.saved_searches") @@ -21,7 +21,7 @@ def _get_file(username: str) -> Path: return DATA_DIR / f"{username}_saved_searches.json" -def _read(file: Path) -> List[Dict[str, Any]]: +def _read(file: Path) -> list[dict[str, Any]]: if not file.exists(): return [] try: @@ -30,13 +30,13 @@ def _read(file: Path) -> List[Dict[str, Any]]: return [] -def _write(file: Path, data: List[Dict[str, Any]]): +def _write(file: Path, data: list[dict[str, Any]]): tmp = file.with_suffix(".tmp") tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") shutil.move(str(tmp), str(file)) -def get_saved(username: str) -> List[Dict[str, Any]]: +def get_saved(username: str) -> list[dict[str, Any]]: return _read(_get_file(username)) diff --git a/backend/search.py b/backend/search.py index 9a0b085..9f8f6a9 100644 --- a/backend/search.py +++ b/backend/search.py @@ -1,13 +1,13 @@ -from sortedcontainers import SortedList import logging import math import re import time import unicodedata from collections import defaultdict -from typing import List, Dict, Any, Optional, Tuple +from typing import Any from snowballstemmer import stemmer as _snowball_stemmer +from sortedcontainers import SortedList from backend import indexer as _indexer from backend.indexer import index @@ -86,7 +86,7 @@ def normalize_text(text: str) -> str: return stripped.lower() -def tokenize(text: str) -> List[str]: +def tokenize(text: str) -> list[str]: """Split text into normalized tokens (accent-stripped, lowercased words). Args: @@ -101,7 +101,7 @@ def tokenize(text: str) -> List[str]: # --------------------------------------------------------------------------- # Tag filter helper (unchanged for backward compat) # --------------------------------------------------------------------------- -def _normalize_tag_filter(tag_filter: Optional[str]) -> List[str]: +def _normalize_tag_filter(tag_filter: str | None) -> list[str]: """Parse a comma-separated tag filter string into a clean list. Strips whitespace and leading ``#`` from each tag. @@ -154,7 +154,7 @@ def _extract_snippet(content: str, query: str, context_chars: int = SNIPPET_CONT def _extract_highlighted_snippet( content: str, - query_terms: List[str], + query_terms: list[str], context_chars: int = SNIPPET_CONTEXT_CHARS, max_highlights: int = MAX_SNIPPET_HIGHLIGHTS, ) -> str: @@ -256,14 +256,13 @@ def _extract_regex_snippet( parts.append(_escape_html(snippet[prev:m.start()])) parts.append(f"{_escape_html(snippet[m.start():m.end()])}") prev = m.end() - count += 1 if prev < len(snippet): parts.append(_escape_html(snippet[prev:])) return prefix + "".join(parts) + suffix -def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str: +def _highlight_terms(text: str, terms: list[str], max_highlights: int) -> str: """Wrap occurrences of *terms* in *text* with ```` tags. Uses accent-normalized comparison so diacritical variants are matched. @@ -282,7 +281,7 @@ def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str: norm = normalize_text(text) # Collect (start, end) spans for all term matches - spans: List[Tuple[int, int]] = [] + spans: list[tuple[int, int]] = [] for term in terms: idx = 0 while idx < len(norm): @@ -297,7 +296,7 @@ def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str: # Merge overlapping spans and limit count spans.sort() - merged: List[Tuple[int, int]] = [spans[0]] + merged: list[tuple[int, int]] = [spans[0]] for s, e in spans[1:]: if s <= merged[-1][1]: merged[-1] = (merged[-1][0], max(merged[-1][1], e)) @@ -306,7 +305,7 @@ def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str: merged = merged[:max_highlights] # Build result with highlights - parts: List[str] = [] + parts: list[str] = [] prev = 0 for s, e in merged: if s > prev: @@ -348,17 +347,17 @@ class InvertedIndex: """ def __init__(self) -> None: - self.word_index: Dict[str, Dict[str, int]] = defaultdict(dict) - self.title_index: Dict[str, List[str]] = defaultdict(list) - self.tag_norm_map: Dict[str, str] = {} - self.tag_prefix_index: Dict[str, List[str]] = defaultdict(list) - self.title_norm_map: Dict[str, List[Dict[str, str]]] = defaultdict(list) + self.word_index: dict[str, dict[str, int]] = defaultdict(dict) + self.title_index: dict[str, list[str]] = defaultdict(list) + self.tag_norm_map: dict[str, str] = {} + self.tag_prefix_index: dict[str, list[str]] = defaultdict(list) + self.title_norm_map: dict[str, list[dict[str, str]]] = defaultdict(list) self.doc_count: int = 0 - self.doc_info: Dict[str, Dict[str, Any]] = {} - self.doc_vault: Dict[str, str] = {} - self.vault_docs: Dict[str, set] = defaultdict(set) - self.tag_docs: Dict[str, set] = defaultdict(set) - self._sorted_tokens: "SortedList" = SortedList() + self.doc_info: dict[str, dict[str, Any]] = {} + self.doc_vault: dict[str, str] = {} + self.vault_docs: dict[str, set] = defaultdict(set) + self.tag_docs: dict[str, set] = defaultdict(set) + self._sorted_tokens: SortedList = SortedList() self._ready: bool = False # True after initial build def is_stale(self) -> bool: @@ -415,12 +414,12 @@ class InvertedIndex: content = file_info.get("content", "") full_text = (file_info.get("title", "") + " " + content) tokens = tokenize(full_text) - tf: Dict[str, int] = defaultdict(int) + tf: dict[str, int] = defaultdict(int) for token in tokens: tf[token] += 1 # Also index stemmed forms (French stemming) # Compute stem frequencies in one pass instead of O(S×T) - stem_freqs: Dict[str, int] = defaultdict(int) + stem_freqs: dict[str, int] = defaultdict(int) for token, freq in tf.items(): stemmed = stem_token(token) if stemmed != token: # only index stem if different @@ -489,12 +488,12 @@ class InvertedIndex: content = file_info.get("content", "") full_text = title + " " + content tokens = tokenize(full_text) - tf: Dict[str, int] = defaultdict(int) + tf: dict[str, int] = defaultdict(int) for token in tokens: if token: tf[token] += 1 # Also compute stems in one pass - stem_freqs: Dict[str, int] = defaultdict(int) + stem_freqs: dict[str, int] = defaultdict(int) for token, freq in tf.items(): stemmed = stem_token(token) if stemmed != token: @@ -615,7 +614,7 @@ class InvertedIndex: return 0.0 return tf * self.idf(term) - def get_prefix_tokens(self, prefix: str, max_expansions: int = 50) -> List[str]: + def get_prefix_tokens(self, prefix: str, max_expansions: int = 50) -> list[str]: """Get all tokens starting with *prefix* using binary search. Uses a pre-sorted token list for O(log V + k) lookup instead @@ -631,7 +630,7 @@ class InvertedIndex: if not prefix or not self._sorted_tokens: return [] lo = self._sorted_tokens.bisect_left(prefix) - results: List[str] = [] + results: list[str] = [] for i in range(lo, len(self._sorted_tokens)): if self._sorted_tokens[i].startswith(prefix): results.append(self._sorted_tokens[i]) @@ -688,9 +687,9 @@ def get_inverted_index() -> InvertedIndex: def search( query: str, vault_filter: str = "all", - tag_filter: Optional[str] = None, + tag_filter: str | None = None, limit: int = DEFAULT_SEARCH_LIMIT, -) -> List[Dict[str, Any]]: +) -> list[dict[str, Any]]: """Full-text search across indexed vaults with relevance scoring. Scoring heuristics (when a text query is provided): @@ -722,7 +721,7 @@ def search( return [] query_lower = query.lower() - results: List[Dict[str, Any]] = [] + results: list[dict[str, Any]] = [] for vault_name, vault_data in index.items(): if vault_filter != "all" and vault_name != vault_filter: @@ -786,7 +785,7 @@ def search( # --------------------------------------------------------------------------- # Advanced search with TF-IDF scoring # --------------------------------------------------------------------------- -def _parse_advanced_query(raw_query: str) -> Dict[str, Any]: +def _parse_advanced_query(raw_query: str) -> dict[str, Any]: """Parse an advanced query string into structured filters and free text. Supported operators: @@ -803,7 +802,7 @@ def _parse_advanced_query(raw_query: str) -> Dict[str, Any]: Returns: Dict with keys ``tags``, ``vault``, ``title``, ``path``, ``ext``, ``terms``. """ - parsed: Dict[str, Any] = { + parsed: dict[str, Any] = { "tags": [], "vault": None, "title": None, @@ -838,7 +837,7 @@ def _parse_advanced_query(raw_query: str) -> Dict[str, Any]: return parsed -def _split_query_tokens(raw: str) -> List[str]: +def _split_query_tokens(raw: str) -> list[str]: """Split a query string respecting quoted phrases. ``tag:"my tag" hello world`` → ``['tag:my tag', 'hello', 'world']`` @@ -849,7 +848,7 @@ def _split_query_tokens(raw: str) -> List[str]: Returns: List of token strings. """ - tokens: List[str] = [] + tokens: list[str] = [] i = 0 n = len(raw) while i < n: @@ -890,14 +889,14 @@ def _split_query_tokens(raw: str) -> List[str]: def _passes_search_filters( file_info: dict, - query_terms: List[str], - query_terms_raw: List[str], + query_terms: list[str], + query_terms_raw: list[str], raw_query: str, case_sensitive: bool, whole_word: bool, regex: bool, - include_paths: Optional[str], - exclude_paths: Optional[str], + include_paths: str | None, + exclude_paths: str | None, ) -> bool: """Post-filter a candidate by case-sensitive, whole-word, regex, and path filters.""" title = file_info.get("title", "") @@ -937,7 +936,7 @@ def _passes_search_filters( return _passes_path_filters(path, include_paths, exclude_paths) -def _passes_path_filters(path: str, include: Optional[str], exclude: Optional[str]) -> bool: +def _passes_path_filters(path: str, include: str | None, exclude: str | None) -> bool: """Check if a file path passes include/exclude glob patterns.""" import fnmatch if include: @@ -955,7 +954,7 @@ def _passes_path_filters(path: str, include: Optional[str], exclude: Optional[st # Date and size filter helpers # --------------------------------------------------------------------------- -def _parse_date_range(raw: Optional[str]) -> Optional[tuple]: +def _parse_date_range(raw: str | None) -> tuple | None: """Parse a date range filter string. Supported formats: @@ -1008,7 +1007,7 @@ def _parse_date_range(raw: Optional[str]) -> Optional[tuple]: return None -def _parse_date_to_ts(s: str) -> Optional[float]: +def _parse_date_to_ts(s: str) -> float | None: """Parse a date string to Unix timestamp. Supports YYYY-MM-DD.""" s = s.strip() for fmt in ('%Y-%m-%d', '%Y/%m/%d', '%d/%m/%Y'): @@ -1020,14 +1019,14 @@ def _parse_date_to_ts(s: str) -> Optional[float]: return None -def _matches_date_range(file_ts: Optional[float], date_range: tuple) -> bool: +def _matches_date_range(file_ts: float | None, date_range: tuple) -> bool: """Check if a file timestamp falls within the given range.""" if file_ts is None: return False return date_range[0] <= file_ts <= date_range[1] -def _parse_size_range(raw: Optional[str]) -> Optional[tuple]: +def _parse_size_range(raw: str | None) -> tuple | None: """Parse a size range filter string. Supported formats: @@ -1040,7 +1039,7 @@ def _parse_size_range(raw: Optional[str]) -> Optional[tuple]: return None raw = raw.strip() - def _parse_size(s: str) -> Optional[int]: + def _parse_size(s: str) -> int | None: s = s.strip().upper() mult = 1 if s.endswith('GB'): @@ -1052,10 +1051,7 @@ def _parse_size_range(raw: Optional[str]) -> Optional[tuple]: elif s.endswith('KB'): mult = 1024 s = s[:-2] - elif s.endswith('B'): - mult = 1 - s = s[:-1] - elif s.endswith('O'): + elif s.endswith('B') or s.endswith('O'): mult = 1 s = s[:-1] try: @@ -1094,19 +1090,19 @@ def _matches_size_range(file_size: int, size_range: tuple) -> bool: def advanced_search( query: str, vault_filter: str = "all", - tag_filter: Optional[str] = None, + tag_filter: str | None = None, limit: int = ADVANCED_SEARCH_DEFAULT_LIMIT, offset: int = 0, sort_by: str = "relevance", case_sensitive: bool = False, whole_word: bool = False, regex: bool = False, - include_paths: Optional[str] = None, - exclude_paths: Optional[str] = None, - created: Optional[str] = None, - modified: Optional[str] = None, - size: Optional[str] = None, -) -> Dict[str, Any]: + include_paths: str | None = None, + exclude_paths: str | None = None, + created: str | None = None, + modified: str | None = None, + size: str | None = None, +) -> dict[str, Any]: """Advanced full-text search with TF-IDF scoring, facets, and pagination. Uses the inverted index for O(k × postings) candidate retrieval instead @@ -1242,12 +1238,12 @@ def advanced_search( # ------------------------------------------------------------------ # Step 3: Score only the candidates (not all N documents) # ------------------------------------------------------------------ - scored_results: List[Tuple[float, Dict[str, Any]]] = [] - facet_tags: Dict[str, int] = defaultdict(int) - facet_vaults: Dict[str, int] = defaultdict(int) + scored_results: list[tuple[float, dict[str, Any]]] = [] + facet_tags: dict[str, int] = defaultdict(int) + facet_vaults: dict[str, int] = defaultdict(int) # Pre-compute prefix expansions once per term (avoid repeated binary search) - prefix_expansions: Dict[str, List[str]] = {} + prefix_expansions: dict[str, list[str]] = {} if has_terms: for term in query_terms: if len(term) >= MIN_PREFIX_LENGTH: @@ -1357,7 +1353,7 @@ def suggest_titles( prefix: str, vault_filter: str = "all", limit: int = SUGGEST_LIMIT, -) -> List[Dict[str, str]]: +) -> list[dict[str, str]]: """Suggest file titles matching a prefix (accent-insensitive). Args: @@ -1373,7 +1369,7 @@ def suggest_titles( inv = get_inverted_index() norm_prefix = normalize_text(prefix) - results: List[Dict[str, str]] = [] + results: list[dict[str, str]] = [] seen: set = set() for norm_title, entries in inv.title_norm_map.items(): @@ -1395,7 +1391,7 @@ def suggest_tags( prefix: str, vault_filter: str = "all", limit: int = SUGGEST_LIMIT, -) -> List[Dict[str, Any]]: +) -> list[dict[str, Any]]: """Suggest tags matching a prefix (accent-insensitive). Args: @@ -1413,7 +1409,7 @@ def suggest_tags( norm_prefix = normalize_text(prefix) all_tag_counts = get_all_tags(vault_filter) - matches: List[Dict[str, Any]] = [] + matches: list[dict[str, Any]] = [] for tag, count in all_tag_counts.items(): norm_tag = normalize_text(tag) if norm_prefix in norm_tag: @@ -1427,7 +1423,7 @@ def suggest_tags( # --------------------------------------------------------------------------- # Backward-compatible tag aggregation (unchanged API) # --------------------------------------------------------------------------- -def get_all_tags(vault_filter: Optional[str] = None) -> Dict[str, int]: +def get_all_tags(vault_filter: str | None = None) -> dict[str, int]: """Aggregate tag counts across vaults, sorted by descending count. Args: @@ -1436,7 +1432,7 @@ def get_all_tags(vault_filter: Optional[str] = None) -> Dict[str, int]: Returns: Dict mapping tag names to their total occurrence count. """ - merged: Dict[str, int] = {} + merged: dict[str, int] = {} for vault_name, vault_data in index.items(): if vault_filter and vault_filter != "all" and vault_name != vault_filter: continue diff --git a/backend/secret_redactor.py b/backend/secret_redactor.py index 407c12f..d6cf56e 100644 --- a/backend/secret_redactor.py +++ b/backend/secret_redactor.py @@ -13,8 +13,8 @@ Patterns detected: - Connection strings with passwords """ -import re import logging +import re logger = logging.getLogger("obsigate.redactor") diff --git a/backend/share.py b/backend/share.py index 311a85e..91cefd9 100644 --- a/backend/share.py +++ b/backend/share.py @@ -8,11 +8,10 @@ No authentication required for public share views. """ import json -import secrets -from pathlib import Path -from datetime import datetime, timezone, timedelta -from typing import Optional import logging +import secrets +from datetime import datetime, timedelta, timezone +from pathlib import Path logger = logging.getLogger("obsigate.share") @@ -39,7 +38,7 @@ def create_share( vault: str, path: str, created_by: str, - expires_in_hours: Optional[int] = None, + expires_in_hours: int | None = None, ) -> dict: """Create a new share token for a document.""" data = _read() @@ -66,7 +65,7 @@ def create_share( return share -def get_share_by_token(token: str) -> Optional[dict]: +def get_share_by_token(token: str) -> dict | None: """Look up a share by token. Returns None if expired or not found.""" data = _read() share = data["shares"].get(token) @@ -100,7 +99,7 @@ def revoke_share(share_id: str) -> bool: return False -def list_shares(vault_filter: Optional[str] = None) -> list: +def list_shares(vault_filter: str | None = None) -> list: """List all shares, optionally filtered by vault.""" data = _read() shares = list(data["shares"].values()) diff --git a/backend/vault_settings.py b/backend/vault_settings.py index 6986298..82aff0e 100644 --- a/backend/vault_settings.py +++ b/backend/vault_settings.py @@ -10,9 +10,9 @@ Current settings: import json import logging -from pathlib import Path -from typing import Dict, Any, Optional import threading +from pathlib import Path +from typing import Any logger = logging.getLogger("obsigate.vault_settings") @@ -21,10 +21,10 @@ _SETTINGS_PATH = _BASE_DIR / "data" / "vault_settings.json" _settings_lock = threading.RLock() # In-memory cache of vault settings -_vault_settings: Dict[str, Dict[str, Any]] = {} +_vault_settings: dict[str, dict[str, Any]] = {} -def load_vault_settings() -> Dict[str, Dict[str, Any]]: +def load_vault_settings() -> dict[str, dict[str, Any]]: """Load vault settings from disk. Returns: @@ -78,7 +78,7 @@ def save_vault_settings() -> None: raise -def get_vault_setting(vault_name: str) -> Optional[Dict[str, Any]]: +def get_vault_setting(vault_name: str) -> dict[str, Any] | None: """Get settings for a specific vault. Args: @@ -91,7 +91,7 @@ def get_vault_setting(vault_name: str) -> Optional[Dict[str, Any]]: return _vault_settings.get(vault_name) -def update_vault_setting(vault_name: str, settings: Dict[str, Any]) -> Dict[str, Any]: +def update_vault_setting(vault_name: str, settings: dict[str, Any]) -> dict[str, Any]: """Update settings for a specific vault. Args: @@ -128,7 +128,7 @@ def delete_vault_setting(vault_name: str) -> bool: return False -def get_all_vault_settings() -> Dict[str, Dict[str, Any]]: +def get_all_vault_settings() -> dict[str, dict[str, Any]]: """Get all vault settings. Returns: diff --git a/backend/watcher.py b/backend/watcher.py index bc640c4..673f587 100644 --- a/backend/watcher.py +++ b/backend/watcher.py @@ -1,13 +1,14 @@ import asyncio import logging import time +from collections.abc import Callable from pathlib import Path -from typing import Callable, Dict, List, Optional +from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer from watchdog.observers.polling import PollingObserver -from watchdog.events import FileSystemEventHandler -from backend.indexer import SUPPORTED_EXTENSIONS, IGNORED_DIRS + +from backend.indexer import IGNORED_DIRS, SUPPORTED_EXTENSIONS logger = logging.getLogger("obsigate.watcher") @@ -89,10 +90,10 @@ class VaultWatcher: self.polling_interval = polling_interval self.observers: dict[str, object] = {} self.event_queue: asyncio.Queue = asyncio.Queue() - self._processor_task: Optional[asyncio.Task] = None + self._processor_task: asyncio.Task | None = None self._running = False - async def start(self, vaults: Dict[str, str]): + async def start(self, vaults: dict[str, str]): """Démarrer la surveillance de toutes les vaults. Args: @@ -170,7 +171,7 @@ class VaultWatcher: Accumule les événements pendant debounce_seconds puis appelle le callback une seule fois avec la liste consolidée. """ - pending: Dict[str, dict] = {} + pending: dict[str, dict] = {} while self._running: try: @@ -194,7 +195,7 @@ class VaultWatcher: except Exception as e: logger.error(f"Event processor error: {e}") - async def _dispatch(self, events: List[dict]): + async def _dispatch(self, events: list[dict]): """Appeler le callback avec les événements consolidés.""" try: await self.on_file_change(events) diff --git a/backend/webhooks.py b/backend/webhooks.py index 51561ed..4a1c831 100644 --- a/backend/webhooks.py +++ b/backend/webhooks.py @@ -8,19 +8,17 @@ Events: file_created, file_deleted, file_modified, file_renamed, directory_created, directory_deleted, directory_renamed """ -import json -import hmac -import hashlib import asyncio +import hashlib +import hmac +import json import logging import uuid -from pathlib import Path from datetime import datetime, timezone -from typing import Optional, List +from pathlib import Path import aiohttp - logger = logging.getLogger("obsigate.webhooks") WEBHOOKS_FILE = Path("data/webhooks.json") @@ -51,7 +49,7 @@ def get_webhooks() -> list: return _read() -def create_webhook(name: str, url: str, events: List[str], secret: Optional[str] = None) -> dict: +def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict: webhooks = _read() wh = { "id": str(uuid.uuid4()), @@ -69,7 +67,7 @@ def create_webhook(name: str, url: str, events: List[str], secret: Optional[str] return wh -def update_webhook(wh_id: str, updates: dict) -> Optional[dict]: +def update_webhook(wh_id: str, updates: dict) -> dict | None: webhooks = _read() for wh in webhooks: if wh["id"] == wh_id: @@ -110,8 +108,7 @@ async def dispatch_webhooks(event_type: str, data: dict): headers["X-ObsiGate-Signature"] = f"sha256={sig}" timeout = aiohttp.ClientTimeout(total=5) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.post(wh["url"], data=body, headers=headers) as resp: + async with aiohttp.ClientSession(timeout=timeout) as session, session.post(wh["url"], data=body, headers=headers) as resp: if resp.status < 400: logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})") else: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6b23db5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[tool.ruff] +# Lines too long in docstrings are OK +line-length = 120 + +[tool.ruff.lint] +ignore = [ + "B008", # FastAPI Depends() in defaults (standard pattern) + "BLE001", # Blind exception catch (many intentional) + "PLW0602", # Global declaration (intentional) + "TRY201", # raise without exception name + "RUF059", # Unused unpacked variables + "S110", # try-except-pass (intentional) + "S112", # try-except-continue (intentional) + "G201", # logging.exception vs .error(exc_info=True) + "PIE810", # Multiple statically joined strings + "ASYNC210",# Blocking HTTP in async context + "DTZ007", # Naive datetime + "DTZ006", # Naive datetime + "EXE001", # Shebang on non-executable +] + +[tool.ruff.lint.per-file-ignores] +"backend/main.py" = ["F821"] # current_user from FastAPI middleware + +[tool.bandit] +skips = ["B101", "B105", "B308", "B311", "B314"] +asserts = "B101" +exclude_dirs = ["tests", ".venv"]