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
This commit is contained in:
parent
d9add8dcba
commit
d311a09527
169
CONTRIBUTING.md
Normal file
169
CONTRIBUTING.md
Normal file
@ -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).
|
||||
27
Dockerfile
27
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"]
|
||||
|
||||
161
README.md
161
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.
|
||||
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@ -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]
|
||||
|
||||
381
backend/main.py
381
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 ``<span class="wikilink-missing">``.
|
||||
|
||||
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,26 +408,35 @@ 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:
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
50
build.sh
50
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"
|
||||
|
||||
@ -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
|
||||
|
||||
120
frontend/app.js
120
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<any>} 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 = '<div class="tree-loading"><div class="loading-spinner" style="width:16px;height:16px;border-width:2px"></div></div>';
|
||||
|
||||
var data;
|
||||
try {
|
||||
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
|
||||
data = await api(url);
|
||||
} catch (err) {
|
||||
container.innerHTML = '<div class="tree-loading" style="color:var(--text-muted);font-size:0.75rem;padding:4px 16px">Erreur de chargement</div>';
|
||||
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 = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
|
||||
|
||||
try {
|
||||
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
|
||||
const data = await api(url);
|
||||
renderFile(data);
|
||||
} catch (err) {
|
||||
area.innerHTML = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier.</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@ -157,7 +157,7 @@
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Navigation des vaults">
|
||||
<div class="sidebar-tree" id="sidebar-tree">
|
||||
<!-- Sidebar filter -->
|
||||
<div class="sidebar-filter">
|
||||
@ -190,7 +190,7 @@
|
||||
<i data-lucide="chevron-down" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
<div class="sidebar-panel-content" id="vault-panel-content">
|
||||
<div id="vault-tree"></div>
|
||||
<div id="vault-tree" role="tree" aria-label="Arborescence des fichiers"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -212,8 +212,8 @@
|
||||
<div class="sidebar-resize-handle" id="sidebar-resize-handle"></div>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="content-area" id="content-area">
|
||||
<div class="welcome" id="welcome">
|
||||
<main class="content-area" id="content-area" aria-label="Contenu principal">
|
||||
<div class="welcome" id="welcome" role="status">
|
||||
<i data-lucide="library" style="width:48px;height:48px;color:var(--text-muted)"></i>
|
||||
<h2>ObsiGate</h2>
|
||||
<p>Sélectionnez un fichier dans la sidebar ou utilisez la recherche pour commencer.</p>
|
||||
|
||||
@ -26,6 +26,10 @@
|
||||
--scrollbar: #30363d;
|
||||
--resize-handle: #30363d;
|
||||
--overlay-bg: rgba(0,0,0,0.5);
|
||||
--danger: #ff7b72;
|
||||
--danger-bg: #3d1a18;
|
||||
--success: #3fb950;
|
||||
--success-bg: #1a3d1f;
|
||||
}
|
||||
|
||||
/* ===== THEME — LIGHT ===== */
|
||||
@ -47,6 +51,10 @@
|
||||
--scrollbar: #d0d7de;
|
||||
--resize-handle: #d0d7de;
|
||||
--overlay-bg: rgba(0,0,0,0.3);
|
||||
--danger: #cf222e;
|
||||
--danger-bg: #ffebe9;
|
||||
--success: #1a7f37;
|
||||
--success-bg: #dafbe1;
|
||||
}
|
||||
|
||||
/* ===== BASE ===== */
|
||||
@ -1382,8 +1390,8 @@ select {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.editor-btn.danger:hover {
|
||||
color: #ff7b72;
|
||||
border-color: #ff7b72;
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
@ -2059,3 +2067,55 @@ body.resizing-v {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* --- Toast notifications --- */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
transition: opacity 250ms ease, transform 250ms ease;
|
||||
pointer-events: auto;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.toast-error {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
.toast-success {
|
||||
border-color: var(--success);
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* --- Tree loading indicator --- */
|
||||
.tree-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user