From d311a095273c3ae0332425c32a2854d30e9245de Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 22 Mar 2026 19:03:34 -0400 Subject: [PATCH] Implement multi-stage Docker build with security hardening, add health check endpoint, optimize in-memory search with O(1) wikilink lookup, extract inline tags from markdown content, and enhance documentation with architecture diagrams and performance metrics --- CONTRIBUTING.md | 169 +++++++++++++++++++ Dockerfile | 27 +++- README.md | 161 ++++++++++++++++--- backend/indexer.py | 176 ++++++++++++++++---- backend/main.py | 383 ++++++++++++++++++++++++++++++++++++-------- backend/search.py | 113 +++++++++---- build.sh | 50 ++++-- docker-compose.yml | 6 + frontend/app.js | 122 ++++++++++++-- frontend/index.html | 8 +- frontend/style.css | 64 +++++++- 11 files changed, 1097 insertions(+), 182 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b843e64 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,169 @@ +# Contribuer à ObsiGate + +Merci de votre intérêt pour ObsiGate ! Ce guide décrit les standards de code et le workflow de développement. + +--- + +## Prérequis + +- **Python** 3.11+ +- **Docker** >= 20.10 (pour les tests conteneurisés) +- **Git** + +--- + +## Lancer en mode développement + +### 1. Cloner et installer les dépendances + +```bash +git clone https://git.dracodev.net/Projets/ObsiGate.git +cd ObsiGate + +# Créer un environnement virtuel +python -m venv .venv +source .venv/bin/activate # Linux/macOS +# .venv\Scripts\activate # Windows + +pip install -r backend/requirements.txt +``` + +### 2. Configurer les vaults de test + +Créez un dossier `test_vault/` (ignoré par `.gitignore`) avec quelques fichiers `.md` : + +```bash +mkdir -p test_vault +echo -e "---\ntags: [test, demo]\ntitle: Note de test\n---\n# Hello\nCeci est une note de test." > test_vault/test.md +``` + +### 3. Lancer le serveur de développement + +```bash +# Définir les variables de vault +export VAULT_1_NAME=Test +export VAULT_1_PATH=$(pwd)/test_vault + +# Lancer avec rechargement automatique +uvicorn backend.main:app --host 0.0.0.0 --port 8080 --reload +``` + +L'interface est accessible sur `http://localhost:8080`. + +--- + +## Standards de code + +### Python (backend/) + +- **Docstrings** : chaque fonction publique doit avoir une docstring complète (style Google/Sphinx). +- **Types** : utiliser les annotations de type sur tous les paramètres et retours. +- **Modèles** : chaque endpoint FastAPI doit avoir un `response_model` Pydantic. +- **Imports** : groupés par standard lib, third-party, local — séparés par une ligne vide. +- **Logging** : utiliser le logger du module (`logger = logging.getLogger("obsigate.xxx")`). +- **Sécurité** : tout chemin fichier fourni par l'utilisateur doit passer par `_resolve_safe_path()`. + +```python +# Exemple de fonction conforme +def ma_fonction(param: str, count: int = 10) -> List[str]: + """Description courte de la fonction. + + Args: + param: Description du paramètre. + count: Nombre maximum de résultats. + + Returns: + Liste de chaînes correspondantes. + """ + ... +``` + +### JavaScript (frontend/) + +- **Vanilla JS uniquement** — zéro framework, zéro dépendance npm. +- **Fonctions nommées** : pas de logique inline dans les event listeners. +- **`"use strict"`** : le code est wrappé dans une IIFE stricte. +- **Commentaires** : documenter toute logique non-triviale avec des commentaires en ligne. +- **Gestion d'erreurs** : toujours `try/catch` les appels `api()`, afficher un toast en cas d'erreur. +- **Performance** : utiliser `safeCreateIcons()` (debounced) plutôt que `lucide.createIcons()` directement. + +### CSS (frontend/) + +- **CSS variables** : toutes les couleurs et valeurs de spacing doivent utiliser des variables CSS définies dans `:root`. +- **Pas de valeurs hardcodées** : utiliser `var(--danger)` au lieu de `#ff7b72`, etc. +- **Thèmes** : toute nouvelle couleur doit être déclarée dans les deux blocs de thème (`dark` et `light`). +- **Mobile-first** : vérifier le rendu mobile pour tout changement de layout. + +--- + +## Tester les changements + +### Test local rapide + +```bash +# Lancer le serveur +export VAULT_1_NAME=Test && export VAULT_1_PATH=$(pwd)/test_vault +uvicorn backend.main:app --port 8080 --reload + +# Vérifier le health check +curl http://localhost:8080/api/health + +# Vérifier l'indexation +curl http://localhost:8080/api/vaults + +# Tester la recherche +curl "http://localhost:8080/api/search?q=test" +``` + +### Test Docker + +```bash +# Build local +docker build -t obsigate:test . + +# Lancer avec une vault de test +docker run --rm -p 8080:8080 \ + -v $(pwd)/test_vault:/vaults/Test:ro \ + -e VAULT_1_NAME=Test \ + -e VAULT_1_PATH=/vaults/Test \ + obsigate:test + +# Vérifier le healthcheck +curl http://localhost:8080/api/health +``` + +### Vérifications avant commit + +1. **API** : tous les endpoints retournent les bons codes HTTP. +2. **Frontend** : tester en thème clair ET sombre. +3. **Mobile** : tester à 375px de largeur (DevTools). +4. **Erreurs** : vérifier que les toasts s'affichent correctement sur erreur réseau. +5. **Performance** : pas de régression visible sur le temps de chargement. + +--- + +## Workflow Git + +1. **Fork** le projet +2. Créer une branche depuis `main` : `git checkout -b feature/ma-feature` +3. Commiter avec des messages clairs en français ou anglais +4. Pousser et créer une **Pull Request** +5. Attendre la review avant de merger + +--- + +## Structure des commits + +``` +type: description courte + +Corps optionnel avec plus de détails. +``` + +Types : `feat`, `fix`, `perf`, `refactor`, `docs`, `style`, `chore`, `test` + +--- + +## Questions ? + +Ouvrez une issue sur [git.dracodev.net/Projets/ObsiGate/issues](https://git.dracodev.net/Projets/ObsiGate/issues). diff --git a/Dockerfile b/Dockerfile index df8cfbc..decff9a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,39 @@ # ObsiGate — Multi-platform Docker image -FROM python:3.11-slim AS base +# Stage 1: Install Python dependencies (with build tools) +FROM python:3.11-slim AS builder RUN apt-get update \ && apt-get install -y --no-install-recommends gcc libffi-dev \ && rm -rf /var/lib/apt/lists/* +WORKDIR /build +COPY backend/requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +# Stage 2: Final lightweight image (no build tools) +FROM python:3.11-slim + +LABEL maintainer="Bruno Beloeil" \ + version="1.1.0" \ + description="ObsiGate — lightweight web interface for Obsidian vaults" + WORKDIR /app -COPY backend/requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +# Copy installed packages from builder stage +COPY --from=builder /install /usr/local +# Copy application code COPY backend/ ./backend/ COPY frontend/ ./frontend/ +# Create non-root user for security +RUN groupadd -r obsigate && useradd -r -g obsigate -d /app -s /sbin/nologin obsigate \ + && chown -R obsigate:obsigate /app +USER obsigate + EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/health')" || exit 1 + CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/README.md b/README.md index 1676374..fd4c293 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. +[![Version](https://img.shields.io/badge/Version-1.1.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -25,6 +26,7 @@ ## 📋 Table des matières - [Fonctionnalités](#-fonctionnalités) +- [Architecture](#-architecture) - [Prérequis](#-prérequis) - [Installation rapide](#-installation-rapide) - [Configuration détaillée](#-configuration-détaillée) @@ -33,8 +35,10 @@ - [Build multi-platform](#-build-multi-platform) - [Utilisation](#-utilisation) - [API](#-api) +- [Performance](#-performance) - [Dépannage](#-dépannage) - [Stack technique](#-stack-technique) +- [Changelog](#-changelog) --- @@ -48,7 +52,8 @@ - **🎨 Syntax highlight** : Coloration syntaxique des blocs de code - **🌓 Thème clair/sombre** : Toggle persisté en localStorage - **🐳 Docker multi-platform** : linux/amd64, linux/arm64, linux/arm/v7, linux/386 -- **🔒 Lecture seule** : Aucune écriture sur vos vaults (sécurité maximale) +- **🔒 Sécurité** : Protection contre le path traversal, utilisateur non-root dans Docker +- **❤️ Healthcheck** : Endpoint `/api/health` intégré pour Docker et monitoring --- @@ -270,15 +275,25 @@ ObsiGate expose une API REST complète : | Endpoint | Description | Méthode | |----------|-------------|---------| +| `/api/health` | Health check (status, version, stats) | GET | | `/api/vaults` | Liste des vaults configurées | GET | | `/api/browse/{vault}?path=` | Navigation dans les dossiers | GET | -| `/api/file/{vault}?path=` | Contenu rendu d'un fichier .md | GET | +| `/api/file/{vault}?path=` | Contenu rendu d'un fichier | GET | +| `/api/file/{vault}/raw?path=` | Contenu brut d'un fichier | GET | +| `/api/file/{vault}/download?path=` | Téléchargement d'un fichier | GET | +| `/api/file/{vault}/save?path=` | Sauvegarder un fichier | PUT | +| `/api/file/{vault}?path=` | Supprimer un fichier | DELETE | | `/api/search?q=&vault=&tag=` | Recherche fulltext | GET | | `/api/tags?vault=` | Tags uniques avec compteurs | GET | | `/api/index/reload` | Force un re-scan des vaults | GET | +> Tous les endpoints exposent des schémas Pydantic documentés. La doc interactive est disponible sur `/docs` (Swagger UI). + **Exemple d'utilisation :** ```bash +# Health check +curl http://localhost:2020/api/health + # Lister les vaults curl http://localhost:2020/api/vaults @@ -327,51 +342,106 @@ docker-compose logs -f obsigate docker-compose logs --tail=100 obsigate ``` -### Performance +--- -- **Indexation** : Première utilisation peut prendre quelques secondes -- **Mémoire** : ~50-100MB par 1000 fichiers (index en mémoire) -- **CPU** : Minimal, sauf lors des recherches fulltext +## ⚡ Performance + +| Métrique | Estimation | +|----------|------------| +| **Indexation** | ~1–2s pour 1 000 fichiers markdown | +| **Recherche fulltext** | < 50ms (index en mémoire, zéro I/O disque) | +| **Résolution wikilinks** | O(1) via table de lookup | +| **Mémoire** | ~80–150MB par 1 000 fichiers (contenu capé à 100 KB/fichier) | +| **Image Docker** | ~180MB (multi-stage, sans outils de build) | +| **CPU** | Minimal ; pas de polling, pas de watchers | + +### Optimisations clés (v1.1.0) + +- **Recherche sans I/O** : le contenu des fichiers est mis en cache dans l'index mémoire +- **Scoring multi-facteurs** : titre exact (+20), titre partiel (+10), chemin (+5), tag (+3), fréquence contenu (x1 par occurrence, capé à 10) +- **Rendu Markdown singleton** : le renderer mistune est instancié une seule fois +- **AbortController** : les requêtes de recherche obsolètes sont annulées côté client +- **Debounced icon rendering** : `lucide.createIcons()` est batché via `requestAnimationFrame` + +--- + +## 🛡️ Sécurité + +- **Path traversal** : tous les endpoints fichier valident que le chemin résolu reste dans la vault +- **Utilisateur non-root** : le conteneur Docker tourne sous l'utilisateur `obsigate` +- **Volumes read-only** : les vaults sont montées en `:ro` par défaut dans docker-compose --- ## 🏗️ Stack technique -- **Backend** : Python 3.11 + FastAPI + Uvicorn +- **Backend** : Python 3.11 + FastAPI 0.110 + Uvicorn - **Frontend** : Vanilla JS + HTML + CSS (zéro framework, zéro build) - **Rendu Markdown** : mistune 3.x -- **Image Docker** : python:3.11-slim +- **Image Docker** : python:3.11-slim (multi-stage) - **Base de données** : Aucune (index en mémoire uniquement) - **Architecture** : SPA + API REST --- +## 🏠 Architecture + +``` +┌─────────────────┐ ┌─────────────────────────────────────────┐ +│ Navigateur │◄───►│ FastAPI (backend/main.py) │ +│ (SPA) │ REST │ │ +│ │ │ ┌──────────────┐ ┌──────────────┐ │ +│ app.js │ │ │ indexer.py │ │ search.py │ │ +│ style.css │ │ │ (scan+cache)│ │ (in-memory) │ │ +│ index.html │ │ └───────┬──────┘ └──────┬───────┘ │ +└─────────────────┘ │ │ │ │ + │ └──────┬───────┘ │ + │ │ │ + │ ┌────────┴─────────┐ │ + │ │ Index en mémoire │ │ + │ │ (fichiers, tags, │ │ + │ │ contenu, lookup)│ │ + │ └──────────────────┘ │ + └─────────────────────────────────────────┘ + ┌───────────────────────────────┐ + │ Filesystem (vaults montées) │ + │ /vaults/Recettes (ro) │ + │ /vaults/IT (ro) │ + └───────────────────────────────┘ +``` + +**Flux de données :** +1. Au démarrage, `indexer.py` scanne tous les vaults en parallèle (thread pool) +2. Le contenu, les tags (YAML + inline) et les métadonnées sont mis en cache en mémoire +3. Une table de lookup O(1) est construite pour la résolution des wikilinks +4. Les requêtes de recherche utilisent l'index en mémoire (zéro I/O disque) +5. Le frontend SPA communique via REST et gère l'état côté client + +--- + ## 📝 Développement ### Structure du projet ``` ObsiGate/ -├── backend/ # API FastAPI -│ ├── main.py # Point d'entrée -│ ├── indexer.py # Indexation des vaults -│ ├── search.py # Moteur de recherche +├── backend/ # API FastAPI +│ ├── main.py # Endpoints, Pydantic models, rendu markdown +│ ├── indexer.py # Scan des vaults, index en mémoire, lookup table +│ ├── search.py # Moteur de recherche fulltext avec scoring │ └── requirements.txt -├── frontend/ # Interface web -│ ├── index.html # Page principale -│ ├── app.js # Logique SPA -│ └── style.css # Styles -├── Dockerfile # Configuration Docker -├── docker-compose.yml # Déploiement -└── build.sh # Build multi-platform +├── frontend/ # Interface web (Vanilla JS, zéro framework) +│ ├── index.html # Page SPA + modales (aide, config, éditeur) +│ ├── app.js # Logique SPA, gestion d'état, API client +│ └── style.css # Styles (CSS variables, thèmes, responsive) +├── Dockerfile # Multi-stage, healthcheck, non-root +├── docker-compose.yml # Déploiement avec healthcheck +├── build.sh # Build multi-platform (amd64/arm64/arm/v7/i386) +└── CONTRIBUTING.md # Guide de contribution ``` ### Contribuer -1. Fork le projet -2. Créer une branche `feature/nouvelle-fonctionnalite` -3. Commit vos changements -4. Push vers la branche -5. Créer une Pull Request +Voir [CONTRIBUTING.md](CONTRIBUTING.md) pour les détails. --- @@ -389,4 +459,45 @@ Ce projet est sous licence **MIT** - voir le fichier [LICENSE](LICENSE) pour les --- -*Projet : ObsiGate | Version : 1.0.0 | Dernière mise à jour : 2025* +## 📝 Changelog + +### v1.1.0 (2025) + +**Sécurité** +- Protection path traversal sur tous les endpoints fichier +- Utilisateur non-root dans le conteneur Docker +- Dockerfile multi-stage (élimination des outils de build) + +**Performance** +- Recherche fulltext en mémoire (zéro I/O disque par requête) +- Table de lookup O(1) pour la résolution des wikilinks +- Renderer mistune mis en cache (singleton) +- Scoring multi-facteurs (titre, chemin, tags, fréquence) +- `lucide.createIcons()` batché via `requestAnimationFrame` +- `AbortController` sur les requêtes de recherche + +**Robustesse** +- Swap atomique de l'index (thread-safe) pendant le reload +- Extraction des tags inline (#tag) depuis le contenu markdown +- Modèles Pydantic sur tous les endpoints API +- Gestion d'erreurs avec toasts utilisateur (frontend) +- États de chargement pour la sidebar et le contenu +- Remplacement de `on_event` déprécié par `lifespan` + +**Infrastructure** +- Endpoint `/api/health` pour monitoring +- Healthcheck Docker (Dockerfile + docker-compose) +- `build.sh` amélioré (variable version, checks, couleurs) + +**Documentation** +- Docstrings complètes sur toutes les fonctions Python +- Schémas Pydantic documentés (Swagger UI auto-générée) +- README : sections Architecture, Performance, Sécurité, Changelog +- CONTRIBUTING.md ajouté + +### v1.0.0 (2025) +- Version initiale + +--- + +*Projet : ObsiGate | Version : 1.1.0 | Dernière mise à jour : 2025* diff --git a/backend/indexer.py b/backend/indexer.py index 0bcd073..07a9c91 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -2,6 +2,7 @@ import os import asyncio import logging import re +import threading from pathlib import Path from datetime import datetime, timezone from typing import Dict, List, Optional, Any @@ -16,6 +17,15 @@ index: Dict[str, Dict[str, Any]] = {} # Vault config: {name: path} vault_config: Dict[str, str] = {} +# Thread-safe lock for index updates +_index_lock = threading.Lock() + +# O(1) lookup table for wikilink resolution: {filename_lower: [{vault, path}, ...]} +_file_lookup: Dict[str, List[Dict[str, str]]] = {} + +# Maximum content size stored per file for in-memory search (bytes) +SEARCH_CONTENT_LIMIT = 100_000 + # Supported text-based file extensions SUPPORTED_EXTENSIONS = { ".md", ".txt", ".log", ".py", ".js", ".ts", ".jsx", ".tsx", @@ -30,7 +40,15 @@ SUPPORTED_EXTENSIONS = { def load_vault_config() -> Dict[str, str]: - """Read VAULT_N_NAME / VAULT_N_PATH env vars and return {name: path}.""" + """Read VAULT_N_NAME / VAULT_N_PATH env vars and return {name: path}. + + Scans environment variables ``VAULT_1_NAME``/``VAULT_1_PATH``, + ``VAULT_2_NAME``/``VAULT_2_PATH``, etc. in sequential order. + Stops at the first missing pair. + + Returns: + Dict mapping vault display names to filesystem paths. + """ vaults: Dict[str, str] = {} n = 1 while True: @@ -43,8 +61,25 @@ def load_vault_config() -> Dict[str, str]: return vaults +# Regex for extracting inline #tags from markdown body (excludes code blocks) +_INLINE_TAG_RE = re.compile(r'(?:^|\s)#([a-zA-Z][a-zA-Z0-9_/-]{1,50})', re.MULTILINE) +# Regex patterns for stripping code blocks before inline tag extraction +_CODE_BLOCK_RE = re.compile(r'```.*?```', re.DOTALL) +_INLINE_CODE_RE = re.compile(r'`[^`]+`') + + def _extract_tags(post: frontmatter.Post) -> List[str]: - """Extract tags from frontmatter metadata.""" + """Extract tags from frontmatter metadata. + + Handles tags as comma-separated string, list, or other types. + Strips leading ``#`` from each tag. + + Args: + post: Parsed frontmatter Post object. + + Returns: + List of cleaned tag strings. + """ tags = post.metadata.get("tags", []) if isinstance(tags, str): tags = [t.strip().lstrip("#") for t in tags.split(",") if t.strip()] @@ -55,8 +90,36 @@ def _extract_tags(post: frontmatter.Post) -> List[str]: return tags +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 + false positives from code comments or shell commands. + + Args: + content: Raw markdown content (without frontmatter). + + Returns: + Deduplicated list of inline tag strings. + """ + stripped = _CODE_BLOCK_RE.sub('', content) + stripped = _INLINE_CODE_RE.sub('', stripped) + return list(set(_INLINE_TAG_RE.findall(stripped))) + + def _extract_title(post: frontmatter.Post, filepath: Path) -> str: - """Extract title from frontmatter or derive from filename.""" + """Extract title from frontmatter or derive from filename. + + Falls back to the file stem with hyphens/underscores replaced by spaces + when no ``title`` key is present in frontmatter. + + Args: + post: Parsed frontmatter Post object. + filepath: Path to the source file. + + Returns: + Human-readable title string. + """ title = post.metadata.get("title", "") if not title: title = filepath.stem.replace("-", " ").replace("_", " ") @@ -64,7 +127,17 @@ def _extract_title(post: frontmatter.Post, filepath: Path) -> str: def parse_markdown_file(raw: str) -> frontmatter.Post: - """Parse markdown frontmatter, falling back to plain content if YAML is invalid.""" + """Parse markdown frontmatter, falling back to plain content if YAML is invalid. + + When the YAML block is malformed, strips it and returns a Post with + empty metadata so that rendering can still proceed. + + Args: + raw: Full raw markdown string including optional frontmatter. + + Returns: + ``frontmatter.Post`` with ``.content`` and ``.metadata`` attributes. + """ try: return frontmatter.loads(raw) except Exception as exc: @@ -78,7 +151,19 @@ def parse_markdown_file(raw: str) -> frontmatter.Post: def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]: - """Synchronously scan a single vault directory.""" + """Synchronously scan a single vault directory and build file index. + + Walks the vault tree, reads supported files, extracts metadata + (tags, title, content preview) and stores a capped content snapshot + for in-memory full-text search. + + Args: + vault_name: Display name of the vault. + vault_path: Absolute filesystem path to the vault root. + + Returns: + Dict with keys ``files`` (list), ``tags`` (counter dict), ``path`` (str). + """ vault_root = Path(vault_path) files: List[Dict[str, Any]] = [] tag_counts: Dict[str, int] = {} @@ -113,6 +198,9 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]: if ext == ".md": post = parse_markdown_file(raw) tags = _extract_tags(post) + # Merge inline #tags found in content body + inline_tags = _extract_inline_tags(post.content) + tags = list(set(tags) | set(inline_tags)) title = _extract_title(post, fpath) content_preview = post.content[:200].strip() @@ -121,6 +209,7 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]: "title": title, "tags": tags, "content_preview": content_preview, + "content": raw[:SEARCH_CONTENT_LIMIT], "size": stat.st_size, "modified": modified, "extension": ext, @@ -138,7 +227,12 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]: async def build_index() -> None: - """Build the full in-memory index for all configured vaults.""" + """Build the full in-memory index for all configured vaults. + + Runs vault scans concurrently in a thread pool, then performs + an atomic swap of the global index and lookup table under a lock + to ensure thread-safe reads during reload. + """ global index, vault_config vault_config = load_vault_config() @@ -156,14 +250,35 @@ async def build_index() -> None: for name, task in tasks: new_index[name] = await task - index.clear() - index.update(new_index) + # Build O(1) lookup table for wikilink resolution + new_lookup: Dict[str, List[Dict[str, str]]] = {} + for vname, vdata in new_index.items(): + for f in vdata["files"]: + entry = {"vault": vname, "path": f["path"]} + fname = f["path"].rsplit("/", 1)[-1].lower() + fpath_lower = f["path"].lower() + for key in (fname, fpath_lower): + if key not in new_lookup: + new_lookup[key] = [] + new_lookup[key].append(entry) + + # Atomic swap under lock for thread safety during concurrent reads + with _index_lock: + index.clear() + index.update(new_index) + _file_lookup.clear() + _file_lookup.update(new_lookup) + total_files = sum(len(v["files"]) for v in index.values()) logger.info(f"Index built: {len(index)} vaults, {total_files} total files") async def reload_index() -> Dict[str, Any]: - """Force a full re-index and return stats.""" + """Force a full re-index of all vaults and return per-vault statistics. + + Returns: + Dict mapping vault names to their file/tag counts. + """ await build_index() stats = {} for name, data in index.items(): @@ -172,39 +287,38 @@ async def reload_index() -> Dict[str, Any]: 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]]: + """Return the full index data for a vault, or ``None`` if not found.""" return index.get(vault_name) def find_file_in_index(link_target: str, current_vault: str) -> Optional[Dict[str, str]]: - """Find a file matching a wikilink target. Search current vault first, then all.""" + """Find a file matching a wikilink target using O(1) lookup table. + + Searches by filename first, then by full relative path. + Prefers results from *current_vault* when multiple matches exist. + + Args: + link_target: The wikilink target (e.g. ``"My Note"`` or ``"folder/My Note"``). + current_vault: Name of the vault the link originates from. + + Returns: + Dict with ``vault`` and ``path`` keys, or ``None`` if not found. + """ target_lower = link_target.lower().strip() if not target_lower.endswith(".md"): target_lower += ".md" - def _search_vault(vname: str, vdata: Dict[str, Any]): - for f in vdata["files"]: - fpath = f["path"].lower() - fname = fpath.rsplit("/", 1)[-1] - if fname == target_lower or fpath == target_lower: - return {"vault": vname, "path": f["path"]} + candidates = _file_lookup.get(target_lower, []) + if not candidates: return None - # Search current vault first - if current_vault in index: - result = _search_vault(current_vault, index[current_vault]) - if result: - return result - - # Search all other vaults - for vname, vdata in index.items(): - if vname == current_vault: - continue - result = _search_vault(vname, vdata) - if result: - return result - - return None + # Prefer current vault when multiple vaults contain a match + for c in candidates: + if c["vault"] == current_vault: + return c + return candidates[0] diff --git a/backend/main.py b/backend/main.py index d0e38fb..9550d55 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,14 +1,16 @@ import re import html as html_mod import logging +from contextlib import asynccontextmanager from pathlib import Path -from typing import Optional +from typing import Optional, List, Dict, Any import frontmatter import mistune -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Query, Body from fastapi.staticfiles import StaticFiles -from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, PlainTextResponse +from fastapi.responses import HTMLResponse, FileResponse +from pydantic import BaseModel, Field from backend.indexer import ( build_index, @@ -17,6 +19,7 @@ from backend.indexer import ( get_vault_data, find_file_in_index, parse_markdown_file, + _extract_tags, SUPPORTED_EXTENSIONS, ) from backend.search import search, get_all_tags @@ -27,29 +30,182 @@ logging.basicConfig( ) logger = logging.getLogger("obsigate") -app = FastAPI(title="ObsiGate", version="1.0.0") + +# --------------------------------------------------------------------------- +# Pydantic response models +# --------------------------------------------------------------------------- + +class VaultInfo(BaseModel): + """Summary information about a configured vault.""" + name: str = Field(description="Display name of the vault") + file_count: int = Field(description="Number of indexed files") + tag_count: int = Field(description="Number of unique tags") + + +class BrowseItem(BaseModel): + """A single entry (file or directory) returned by the browse endpoint.""" + name: str + path: str + type: str = Field(description="'file' or 'directory'") + children_count: Optional[int] = None + size: Optional[int] = None + extension: Optional[str] = None + + +class BrowseResponse(BaseModel): + """Paginated directory listing for a vault.""" + vault: str + path: str + items: List[BrowseItem] + + +class FileContentResponse(BaseModel): + """Rendered file content with metadata.""" + vault: str + path: str + title: str + tags: List[str] + frontmatter: Dict[str, Any] + html: str + raw_length: int + extension: str + is_markdown: bool + + +class FileRawResponse(BaseModel): + """Raw text content of a file.""" + vault: str + path: str + raw: str + + +class FileSaveResponse(BaseModel): + """Confirmation after saving a file.""" + status: str + vault: str + path: str + size: int + + +class FileDeleteResponse(BaseModel): + """Confirmation after deleting a file.""" + status: str + vault: str + path: str + + +class SearchResultItem(BaseModel): + """A single search result.""" + vault: str + path: str + title: str + tags: List[str] + score: int + snippet: str + modified: str + + +class SearchResponse(BaseModel): + """Full-text search response.""" + query: str + vault_filter: str + tag_filter: Optional[str] + count: int + results: List[SearchResultItem] + + +class TagsResponse(BaseModel): + """Tag aggregation response.""" + vault_filter: Optional[str] + tags: Dict[str, int] + + +class ReloadResponse(BaseModel): + """Index reload confirmation with per-vault stats.""" + status: str + vaults: Dict[str, Any] + + +class HealthResponse(BaseModel): + """Application health status.""" + status: str + version: str + vaults: int + total_files: int + + +# --------------------------------------------------------------------------- +# Application lifespan (replaces deprecated on_event) +# --------------------------------------------------------------------------- + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan: build index on startup, cleanup on shutdown.""" + logger.info("ObsiGate starting \u2014 building index...") + await build_index() + logger.info("ObsiGate ready.") + yield + + +app = FastAPI(title="ObsiGate", version="1.1.0", lifespan=lifespan) # Resolve frontend path relative to this file FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" # --------------------------------------------------------------------------- -# Startup +# Path safety helper # --------------------------------------------------------------------------- -@app.on_event("startup") -async def startup_event(): - logger.info("ObsiGate starting — building index...") - await build_index() - logger.info("ObsiGate ready.") +def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path: + """Resolve a relative path safely within the vault root. + + Prevents directory traversal attacks by ensuring the resolved + absolute path is a descendant of *vault_root*. + + Args: + vault_root: The vault's root directory (absolute). + relative_path: The user-supplied relative path. + + Returns: + Resolved absolute ``Path``. + + Raises: + HTTPException(403): When the resolved path escapes the vault root. + """ + resolved = (vault_root / relative_path).resolve() + vault_resolved = vault_root.resolve() + try: + resolved.relative_to(vault_resolved) + except ValueError: + raise HTTPException(status_code=403, detail="Access denied: path outside vault") + return resolved # --------------------------------------------------------------------------- -# Markdown rendering helpers +# Markdown rendering helpers (singleton renderer) # --------------------------------------------------------------------------- +# Cached mistune renderer — avoids re-creating on every request +_markdown_renderer = mistune.create_markdown( + escape=False, + plugins=["table", "strikethrough", "footnotes", "task_lists"], +) + + def _convert_wikilinks(content: str, current_vault: str) -> str: - """Convert [[wikilinks]] and [[target|display]] to HTML links.""" + """Convert ``[[wikilinks]]`` and ``[[target|display]]`` to clickable HTML. + + Resolved links get a ``data-vault`` / ``data-path`` attribute pair. + Unresolved links are rendered as ````. + + Args: + content: Markdown string potentially containing wikilinks. + current_vault: Active vault name for resolution priority. + + Returns: + Markdown string with wikilinks replaced by HTML anchors. + """ def _replace(match): target = match.group(1).strip() display = match.group(2).strip() if match.group(2) else target @@ -67,22 +223,48 @@ def _convert_wikilinks(content: str, current_vault: str) -> str: def _render_markdown(raw_md: str, vault_name: str) -> str: - """Render markdown string to HTML with wikilink support.""" + """Render a markdown string to HTML with wikilink support. + + Uses the cached singleton mistune renderer for performance. + + Args: + raw_md: Raw markdown text (frontmatter already stripped). + vault_name: Current vault for wikilink resolution context. + + Returns: + HTML string. + """ converted = _convert_wikilinks(raw_md, vault_name) - md = mistune.create_markdown( - escape=False, - plugins=["table", "strikethrough", "footnotes", "task_lists"], - ) - return md(converted) + return _markdown_renderer(converted) # --------------------------------------------------------------------------- # API Endpoints # --------------------------------------------------------------------------- -@app.get("/api/vaults") +@app.get("/api/health", response_model=HealthResponse) +async def api_health(): + """Health check endpoint for Docker and monitoring. + + Returns: + Application status, version, vault count and total file count. + """ + total_files = sum(len(v["files"]) for v in index.values()) + return { + "status": "ok", + "version": app.version, + "vaults": len(index), + "total_files": total_files, + } + + +@app.get("/api/vaults", response_model=List[VaultInfo]) async def api_vaults(): - """List configured vaults with file counts.""" + """List all configured vaults with file and tag counts. + + Returns: + List of vault summary objects. + """ result = [] for name, data in index.items(): result.append({ @@ -93,15 +275,27 @@ async def api_vaults(): return result -@app.get("/api/browse/{vault_name}") +@app.get("/api/browse/{vault_name}", response_model=BrowseResponse) async def api_browse(vault_name: str, path: str = ""): - """Browse directories and files in a vault at a given path level.""" + """Browse directories and files in a vault at a given path level. + + Returns sorted entries (directories first, then files) with metadata. + Hidden files/directories (starting with ``"."`` ) are excluded. + + Args: + vault_name: Name of the vault to browse. + path: Relative directory path within the vault (empty = root). + + Returns: + ``BrowseResponse`` with vault name, path, and item list. + """ vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") vault_root = Path(vault_data["path"]) - target = vault_root / path if path else vault_root + # Path traversal protection + target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve() if not target.exists(): raise HTTPException(status_code=404, detail=f"Path not found: {path}") @@ -161,15 +355,23 @@ EXT_TO_LANG = { } -@app.get("/api/file/{vault_name}/raw") +@app.get("/api/file/{vault_name}/raw", response_model=FileRawResponse) async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file")): - """Return raw file content.""" + """Return raw file content as plain text. + + Args: + vault_name: Name of the vault. + path: Relative file path within the vault. + + Returns: + ``FileRawResponse`` with vault, path, and raw text content. + """ vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") vault_root = Path(vault_data["path"]) - file_path = vault_root / path + file_path = _resolve_safe_path(vault_root, path) if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail=f"File not found: {path}") @@ -180,13 +382,21 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela @app.get("/api/file/{vault_name}/download") async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file")): - """Download a file as attachment.""" + """Download a file as an attachment. + + Args: + vault_name: Name of the vault. + path: Relative file path within the vault. + + Returns: + ``FileResponse`` with ``application/octet-stream`` content-type. + """ vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") vault_root = Path(vault_data["path"]) - file_path = vault_root / path + file_path = _resolve_safe_path(vault_root, path) if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail=f"File not found: {path}") @@ -198,28 +408,37 @@ async def api_file_download(vault_name: str, path: str = Query(..., description= ) -@app.put("/api/file/{vault_name}/save") -async def api_file_save(vault_name: str, path: str = Query(..., description="Relative path to file"), body: dict = {}): - """Save file content.""" +@app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse) +async def api_file_save( + vault_name: str, + path: str = Query(..., description="Relative path to file"), + body: dict = Body(...), +): + """Save (overwrite) a file's content. + + Expects a JSON body with a ``content`` key containing the new text. + The path is validated against traversal attacks before writing. + + Args: + vault_name: Name of the vault. + path: Relative file path within the vault. + body: JSON body with ``content`` string. + + Returns: + ``FileSaveResponse`` confirming the write. + """ vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") vault_root = Path(vault_data["path"]) - file_path = vault_root / path - - # Security: ensure path is within vault - try: - file_path.resolve().relative_to(vault_root.resolve()) - except ValueError: - raise HTTPException(status_code=403, detail="Access denied: path outside vault") + file_path = _resolve_safe_path(vault_root, path) if not file_path.exists(): raise HTTPException(status_code=404, detail=f"File not found: {path}") - # Get content from body content = body.get('content', '') - + try: file_path.write_text(content, encoding="utf-8") logger.info(f"File saved: {vault_name}/{path}") @@ -231,20 +450,25 @@ async def api_file_save(vault_name: str, path: str = Query(..., description="Rel raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}") -@app.delete("/api/file/{vault_name}") +@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse) async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file")): - """Delete a file.""" + """Delete a file from the vault. + + The path is validated against traversal attacks before deletion. + + Args: + vault_name: Name of the vault. + path: Relative file path within the vault. + + Returns: + ``FileDeleteResponse`` confirming the deletion. + """ vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") vault_root = Path(vault_data["path"]) - file_path = vault_root / path - - try: - file_path.resolve().relative_to(vault_root.resolve()) - except ValueError: - raise HTTPException(status_code=403, detail="Access denied: path outside vault") + file_path = _resolve_safe_path(vault_root, path) if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail=f"File not found: {path}") @@ -260,15 +484,27 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}") -@app.get("/api/file/{vault_name}") +@app.get("/api/file/{vault_name}", response_model=FileContentResponse) async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file")): - """Return rendered HTML + metadata for a file.""" + """Return rendered HTML and metadata for a file. + + Markdown files are parsed for frontmatter, rendered with wikilink + support, and returned with extracted tags. Other supported file + types are syntax-highlighted as code blocks. + + Args: + vault_name: Name of the vault. + path: Relative file path within the vault. + + Returns: + ``FileContentResponse`` with HTML, metadata, and tags. + """ vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") vault_root = Path(vault_data["path"]) - file_path = vault_root / path + file_path = _resolve_safe_path(vault_root, path) if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail=f"File not found: {path}") @@ -279,14 +515,8 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative if ext == ".md": post = parse_markdown_file(raw) - # Extract metadata - tags = post.metadata.get("tags", []) - if isinstance(tags, str): - tags = [t.strip().lstrip("#") for t in tags.split(",") if t.strip()] - elif isinstance(tags, list): - tags = [str(t).strip().lstrip("#") for t in tags] - else: - tags = [] + # Extract metadata using shared indexer logic + tags = _extract_tags(post) title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " ")) html_content = _render_markdown(post.content, vault_name) @@ -321,27 +551,50 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative } -@app.get("/api/search") +@app.get("/api/search", response_model=SearchResponse) async def api_search( q: str = Query("", description="Search query"), vault: str = Query("all", description="Vault filter"), tag: Optional[str] = Query(None, description="Tag filter"), ): - """Full-text search across vaults.""" + """Full-text search across vaults with relevance scoring. + + Supports combining free-text queries with tag filters. + Results are ranked by a multi-factor scoring algorithm. + + Args: + q: Free-text search string. + vault: Vault name or ``"all"`` to search everywhere. + tag: Comma-separated tag names to require. + + Returns: + ``SearchResponse`` with ranked results and snippets. + """ results = search(q, vault_filter=vault, tag_filter=tag) return {"query": q, "vault_filter": vault, "tag_filter": tag, "count": len(results), "results": results} -@app.get("/api/tags") +@app.get("/api/tags", response_model=TagsResponse) async def api_tags(vault: Optional[str] = Query(None, description="Vault filter")): - """Return all unique tags with counts.""" + """Return all unique tags with occurrence counts. + + Args: + vault: Optional vault name to restrict tag aggregation. + + Returns: + ``TagsResponse`` with tags sorted by descending count. + """ tags = get_all_tags(vault_filter=vault) return {"vault_filter": vault, "tags": tags} -@app.get("/api/index/reload") +@app.get("/api/index/reload", response_model=ReloadResponse) async def api_reload(): - """Force a re-index of all vaults.""" + """Force a full re-index of all configured vaults. + + Returns: + ``ReloadResponse`` with per-vault file and tag counts. + """ stats = await reload_index() return {"status": "ok", "vaults": stats} diff --git a/backend/search.py b/backend/search.py index 1af4f98..6760f40 100644 --- a/backend/search.py +++ b/backend/search.py @@ -1,34 +1,44 @@ -import re import logging -from pathlib import Path from typing import List, Dict, Any, Optional -from backend.indexer import index, get_vault_data +from backend.indexer import index logger = logging.getLogger("obsigate.search") +# Default maximum number of search results returned +DEFAULT_SEARCH_LIMIT = 200 + def _normalize_tag_filter(tag_filter: Optional[str]) -> List[str]: + """Parse a comma-separated tag filter string into a clean list. + + Strips whitespace and leading ``#`` from each tag. + + Args: + tag_filter: Raw tag filter string (e.g. ``"docker,linux"``). + + Returns: + List of normalised tag strings, empty list if input is falsy. + """ if not tag_filter: return [] return [tag.strip().lstrip("#") for tag in tag_filter.split(",") if tag.strip()] -def _read_file_content(vault_name: str, file_path: str) -> str: - """Read raw markdown content of a file from disk.""" - vault_data = get_vault_data(vault_name) - if not vault_data: - return "" - vault_root = Path(vault_data["path"]) - full_path = vault_root / file_path - try: - return full_path.read_text(encoding="utf-8", errors="replace") - except Exception: - return "" - - def _extract_snippet(content: str, query: str, context_chars: int = 120) -> str: - """Extract a text snippet around the first occurrence of query.""" + """Extract a text snippet around the first occurrence of *query*. + + Returns up to ``context_chars`` characters before and after the match. + Falls back to the first 200 characters when the query is not found. + + Args: + content: Full text to search within. + query: The search term. + context_chars: Number of context characters on each side. + + Returns: + Snippet string, optionally prefixed/suffixed with ``...``. + """ lower_content = content.lower() lower_query = query.lower() pos = lower_content.find(lower_query) @@ -51,10 +61,30 @@ def search( query: str, vault_filter: str = "all", tag_filter: Optional[str] = None, + limit: int = DEFAULT_SEARCH_LIMIT, ) -> List[Dict[str, Any]]: - """ - Full-text search across indexed vaults. - Returns scored results with snippets. + """Full-text search across indexed vaults with relevance scoring. + + Scoring heuristics (when a text query is provided): + - **+20** exact title match (case-insensitive) + - **+10** partial title match + - **+5** query found in file path + - **+3** query matches a tag name + - **+1 per occurrence** in content (capped at 10) + + When only tag filters are active, all matching files receive score 1. + Results are sorted descending by score and capped at *limit*. + + Uses the in-memory cached content from the index — **no disk I/O**. + + Args: + query: Free-text search string. + vault_filter: Vault name or ``"all"``. + tag_filter: Comma-separated tag names to require. + limit: Maximum number of results to return. + + Returns: + List of result dicts sorted by descending relevance score. """ query = query.strip() if query else "" has_query = len(query) > 0 @@ -63,6 +93,7 @@ def search( if not has_query and not selected_tags: return [] + query_lower = query.lower() results: List[Dict[str, Any]] = [] for vault_name, vault_data in index.items(): @@ -70,6 +101,7 @@ def search( continue for file_info in vault_data["files"]: + # Tag filter: all selected tags must be present if selected_tags and not all(tag in file_info["tags"] for tag in selected_tags): continue @@ -77,14 +109,32 @@ def search( snippet = file_info.get("content_preview", "") if has_query: - # Title match (high weight) - if query.lower() in file_info["title"].lower(): + title_lower = file_info["title"].lower() + + # Exact title match (highest weight) + if query_lower == title_lower: + score += 20 + # Partial title match + elif query_lower in title_lower: score += 10 - # Content match - content = _read_file_content(vault_name, file_info["path"]) - if query.lower() in content.lower(): - score += 1 + # Path match (folder/filename relevance) + if query_lower in file_info["path"].lower(): + score += 5 + + # Tag name match + for tag in file_info.get("tags", []): + if query_lower in tag.lower(): + score += 3 + break # count once per file + + # Content match — use cached content (no disk I/O) + content = file_info.get("content", "") + content_lower = content.lower() + if query_lower in content_lower: + # Frequency-based scoring, capped to avoid over-weighting + occurrences = content_lower.count(query_lower) + score += min(occurrences, 10) snippet = _extract_snippet(content, query) else: # Tag-only filter: all matching files get score 1 @@ -102,11 +152,18 @@ def search( }) results.sort(key=lambda x: -x["score"]) - return results + return results[:limit] def get_all_tags(vault_filter: Optional[str] = None) -> Dict[str, int]: - """Aggregate tag counts across vaults.""" + """Aggregate tag counts across vaults, sorted by descending count. + + Args: + vault_filter: Optional vault name to restrict to a single vault. + + Returns: + Dict mapping tag names to their total occurrence count. + """ merged: Dict[str, int] = {} for vault_name, vault_data in index.items(): if vault_filter and vault_name != vault_filter: diff --git a/build.sh b/build.sh index fe417cd..260fb7a 100644 --- a/build.sh +++ b/build.sh @@ -1,25 +1,49 @@ #!/bin/bash # Build multi-platform ObsiGate Docker image -set -e +set -euo pipefail -echo "=== ObsiGate Multi-Platform Build ===" +# ----- Configuration ----- +VERSION="1.1.0" +IMAGE_NAME="obsigate" +PLATFORMS="linux/amd64,linux/arm64,linux/arm/v7,linux/386" +BUILDER_NAME="obsigate-builder" -docker buildx create --use --name obsigate-builder 2>/dev/null || true +# ----- Helpers ----- +info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*"; } +ok() { printf '\033[1;32m[OK]\033[0m %s\n' "$*"; } +error() { printf '\033[1;31m[ERR]\033[0m %s\n' "$*" >&2; } -# Build for all target platforms +# ----- Pre-flight checks ----- +if ! command -v docker &>/dev/null; then + error "docker introuvable. Installez Docker avant de continuer." + exit 1 +fi + +if ! docker buildx version &>/dev/null; then + error "docker buildx introuvable. Mettez à jour Docker ou installez le plugin buildx." + exit 1 +fi + +info "=== ObsiGate v${VERSION} — Multi-Platform Build ===" +info "Platforms : ${PLATFORMS}" + +# ----- Builder setup ----- +docker buildx create --use --name "${BUILDER_NAME}" 2>/dev/null || true + +# ----- Build ----- # Note: --load only works for single platform; use --push for multi-platform registry push. -# For local testing, build one platform at a time: -# docker buildx build --platform linux/amd64 --load -t obsigate:latest . +# For local testing, build one platform at a time (see below). docker buildx build \ - --platform linux/amd64,linux/arm64,linux/arm/v7,linux/386 \ - --tag obsigate:latest \ - --tag obsigate:1.0.0 \ + --platform "${PLATFORMS}" \ + --tag "${IMAGE_NAME}:latest" \ + --tag "${IMAGE_NAME}:${VERSION}" \ + "$@" \ . +ok "Build terminé (v${VERSION})." echo "" -echo "Build terminé." -echo "Pour un push vers un registry : ajoutez --push au build." -echo "Pour un test local (amd64) :" -echo " docker buildx build --platform linux/amd64 --load -t obsigate:latest ." +info "Pour un push vers un registry : $0 --push" +info "Pour un test local (amd64) :" +echo " docker buildx build --platform linux/amd64 --load -t ${IMAGE_NAME}:latest ." echo " docker-compose up -d" diff --git a/docker-compose.yml b/docker-compose.yml index aaf86fe..ee97141 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,12 @@ services: restart: unless-stopped ports: - "2020:8080" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s volumes: - /NFS/OBSIDIAN_DOC/Obsidian-RECETTES:/vaults/Obsidian-RECETTES:ro - /NFS/OBSIDIAN_DOC/Obsidian_IT:/vaults/Obsidian_IT:ro diff --git a/frontend/app.js b/frontend/app.js index 4f61f87..d8adb4a 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -9,6 +9,7 @@ let currentVault = null; let currentPath = null; let searchTimeout = null; + let searchAbortController = null; let showingSource = false; let cachedRawSource = null; let allVaults = []; @@ -20,6 +21,7 @@ let fallbackEditorEl = null; let sidebarFilterCaseSensitive = false; let searchCaseSensitive = false; + let _iconDebounceTimer = null; const panelState = { vault: true, tag: true, @@ -78,7 +80,26 @@ // --------------------------------------------------------------------------- // Safe CDN helpers // --------------------------------------------------------------------------- + + /** + * Debounced icon creation — batches multiple rapid calls into one + * DOM scan to avoid excessive reflows when building large trees. + */ function safeCreateIcons() { + if (typeof lucide === "undefined" || !lucide.createIcons) return; + if (_iconDebounceTimer) return; // already scheduled + _iconDebounceTimer = requestAnimationFrame(() => { + _iconDebounceTimer = null; + try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ } + }); + } + + /** Force-flush icon creation immediately (use sparingly). */ + function flushIcons() { + if (_iconDebounceTimer) { + cancelAnimationFrame(_iconDebounceTimer); + _iconDebounceTimer = null; + } if (typeof lucide !== "undefined" && lucide.createIcons) { try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ } } @@ -292,12 +313,60 @@ }); } + // --------------------------------------------------------------------------- + // Toast notifications + // --------------------------------------------------------------------------- + + /** Display a brief toast message at the bottom of the viewport. */ + function showToast(message, type) { + type = type || "error"; + let container = document.getElementById("toast-container"); + if (!container) { + container = document.createElement("div"); + container.id = "toast-container"; + container.className = "toast-container"; + container.setAttribute("aria-live", "polite"); + document.body.appendChild(container); + } + var toast = document.createElement("div"); + toast.className = "toast toast-" + type; + toast.textContent = message; + container.appendChild(toast); + // Trigger entrance animation + requestAnimationFrame(function () { toast.classList.add("show"); }); + setTimeout(function () { + toast.classList.remove("show"); + toast.addEventListener("transitionend", function () { toast.remove(); }); + }, 3500); + } + // --------------------------------------------------------------------------- // API helpers // --------------------------------------------------------------------------- - async function api(path) { - const res = await fetch(path); - if (!res.ok) throw new Error(`API error: ${res.status}`); + + /** + * Fetch JSON from an API endpoint with optional AbortSignal support. + * Surfaces errors to the user via toast instead of silently failing. + * + * @param {string} path - API URL path. + * @param {object} [opts] - Fetch options (may include signal). + * @returns {Promise} Parsed JSON response. + */ + async function api(path, opts) { + var res; + try { + res = await fetch(path, opts || {}); + } catch (err) { + if (err.name === "AbortError") throw err; // let callers handle abort + showToast("Erreur réseau — vérifiez votre connexion"); + throw err; + } + if (!res.ok) { + var detail = ""; + try { var body = await res.json(); detail = body.detail || ""; } catch (_) { /* no json body */ } + showToast(detail || "Erreur API : " + res.status); + throw new Error(detail || "API error: " + res.status); + } return res.json(); } @@ -564,8 +633,17 @@ } async function loadDirectory(vaultName, dirPath, container) { - const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`; - const data = await api(url); + // Show inline loading indicator while fetching directory contents + container.innerHTML = '
'; + + var data; + try { + const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`; + data = await api(url); + } catch (err) { + container.innerHTML = '
Erreur de chargement
'; + return; + } container.innerHTML = ""; const fragment = document.createDocumentFragment(); @@ -905,9 +983,17 @@ if (active) active.classList.add("active"); } catch (e) { /* selector might fail on special chars */ } - const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`; - const data = await api(url); - renderFile(data); + // Show loading state while fetching + const area = document.getElementById("content-area"); + area.innerHTML = '
Chargement...
'; + + try { + const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`; + const data = await api(url); + renderFile(data); + } catch (err) { + area.innerHTML = '

Impossible de charger le fichier.

'; + } } function renderFile(data) { @@ -1324,13 +1410,26 @@ } async function performSearch(query, vaultFilter, tagFilter) { + // Cancel any in-flight search request + if (searchAbortController) { + searchAbortController.abort(); + } + searchAbortController = new AbortController(); + showLoading(); - + let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`; if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`; - const data = await api(url); - renderSearchResults(data, query, tagFilter); + try { + const data = await api(url, { signal: searchAbortController.signal }); + renderSearchResults(data, query, tagFilter); + } catch (err) { + if (err.name === "AbortError") return; // superseded by newer request + showWelcome(); + } finally { + searchAbortController = null; + } } function renderSearchResults(data, query, tagFilter) { @@ -1764,6 +1863,7 @@ await Promise.all([loadVaults(), loadTags()]); } catch (err) { console.error("Failed to initialize ObsiGate:", err); + showToast("Erreur lors de l'initialisation"); } safeCreateIcons(); diff --git a/frontend/index.html b/frontend/index.html index 089aaa9..2463094 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -157,7 +157,7 @@ -