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
|
# 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 \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends gcc libffi-dev \
|
&& apt-get install -y --no-install-recommends gcc libffi-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY backend/requirements.txt .
|
# Copy installed packages from builder stage
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
COPY --from=builder /install /usr/local
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
COPY backend/ ./backend/
|
COPY backend/ ./backend/
|
||||||
COPY frontend/ ./frontend/
|
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
|
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"]
|
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.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@ -25,6 +26,7 @@
|
|||||||
## 📋 Table des matières
|
## 📋 Table des matières
|
||||||
|
|
||||||
- [Fonctionnalités](#-fonctionnalités)
|
- [Fonctionnalités](#-fonctionnalités)
|
||||||
|
- [Architecture](#-architecture)
|
||||||
- [Prérequis](#-prérequis)
|
- [Prérequis](#-prérequis)
|
||||||
- [Installation rapide](#-installation-rapide)
|
- [Installation rapide](#-installation-rapide)
|
||||||
- [Configuration détaillée](#-configuration-détaillée)
|
- [Configuration détaillée](#-configuration-détaillée)
|
||||||
@ -33,8 +35,10 @@
|
|||||||
- [Build multi-platform](#-build-multi-platform)
|
- [Build multi-platform](#-build-multi-platform)
|
||||||
- [Utilisation](#-utilisation)
|
- [Utilisation](#-utilisation)
|
||||||
- [API](#-api)
|
- [API](#-api)
|
||||||
|
- [Performance](#-performance)
|
||||||
- [Dépannage](#-dépannage)
|
- [Dépannage](#-dépannage)
|
||||||
- [Stack technique](#-stack-technique)
|
- [Stack technique](#-stack-technique)
|
||||||
|
- [Changelog](#-changelog)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -48,7 +52,8 @@
|
|||||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
||||||
- **🐳 Docker multi-platform** : linux/amd64, linux/arm64, linux/arm/v7, linux/386
|
- **🐳 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 |
|
| Endpoint | Description | Méthode |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
|
| `/api/health` | Health check (status, version, stats) | GET |
|
||||||
| `/api/vaults` | Liste des vaults configurées | GET |
|
| `/api/vaults` | Liste des vaults configurées | GET |
|
||||||
| `/api/browse/{vault}?path=` | Navigation dans les dossiers | 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/search?q=&vault=&tag=` | Recherche fulltext | GET |
|
||||||
| `/api/tags?vault=` | Tags uniques avec compteurs | GET |
|
| `/api/tags?vault=` | Tags uniques avec compteurs | GET |
|
||||||
| `/api/index/reload` | Force un re-scan des vaults | 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 :**
|
**Exemple d'utilisation :**
|
||||||
```bash
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:2020/api/health
|
||||||
|
|
||||||
# Lister les vaults
|
# Lister les vaults
|
||||||
curl http://localhost:2020/api/vaults
|
curl http://localhost:2020/api/vaults
|
||||||
|
|
||||||
@ -327,51 +342,106 @@ docker-compose logs -f obsigate
|
|||||||
docker-compose logs --tail=100 obsigate
|
docker-compose logs --tail=100 obsigate
|
||||||
```
|
```
|
||||||
|
|
||||||
### Performance
|
---
|
||||||
|
|
||||||
- **Indexation** : Première utilisation peut prendre quelques secondes
|
## ⚡ Performance
|
||||||
- **Mémoire** : ~50-100MB par 1000 fichiers (index en mémoire)
|
|
||||||
- **CPU** : Minimal, sauf lors des recherches fulltext
|
| 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
|
## 🏗️ 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)
|
- **Frontend** : Vanilla JS + HTML + CSS (zéro framework, zéro build)
|
||||||
- **Rendu Markdown** : mistune 3.x
|
- **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)
|
- **Base de données** : Aucune (index en mémoire uniquement)
|
||||||
- **Architecture** : SPA + API REST
|
- **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
|
## 📝 Développement
|
||||||
|
|
||||||
### Structure du projet
|
### Structure du projet
|
||||||
```
|
```
|
||||||
ObsiGate/
|
ObsiGate/
|
||||||
├── backend/ # API FastAPI
|
├── backend/ # API FastAPI
|
||||||
│ ├── main.py # Point d'entrée
|
│ ├── main.py # Endpoints, Pydantic models, rendu markdown
|
||||||
│ ├── indexer.py # Indexation des vaults
|
│ ├── indexer.py # Scan des vaults, index en mémoire, lookup table
|
||||||
│ ├── search.py # Moteur de recherche
|
│ ├── search.py # Moteur de recherche fulltext avec scoring
|
||||||
│ └── requirements.txt
|
│ └── requirements.txt
|
||||||
├── frontend/ # Interface web
|
├── frontend/ # Interface web (Vanilla JS, zéro framework)
|
||||||
│ ├── index.html # Page principale
|
│ ├── index.html # Page SPA + modales (aide, config, éditeur)
|
||||||
│ ├── app.js # Logique SPA
|
│ ├── app.js # Logique SPA, gestion d'état, API client
|
||||||
│ └── style.css # Styles
|
│ └── style.css # Styles (CSS variables, thèmes, responsive)
|
||||||
├── Dockerfile # Configuration Docker
|
├── Dockerfile # Multi-stage, healthcheck, non-root
|
||||||
├── docker-compose.yml # Déploiement
|
├── docker-compose.yml # Déploiement avec healthcheck
|
||||||
└── build.sh # Build multi-platform
|
├── build.sh # Build multi-platform (amd64/arm64/arm/v7/i386)
|
||||||
|
└── CONTRIBUTING.md # Guide de contribution
|
||||||
```
|
```
|
||||||
|
|
||||||
### Contribuer
|
### Contribuer
|
||||||
|
|
||||||
1. Fork le projet
|
Voir [CONTRIBUTING.md](CONTRIBUTING.md) pour les détails.
|
||||||
2. Créer une branche `feature/nouvelle-fonctionnalite`
|
|
||||||
3. Commit vos changements
|
|
||||||
4. Push vers la branche
|
|
||||||
5. Créer une Pull Request
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -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 asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
@ -16,6 +17,15 @@ index: Dict[str, Dict[str, Any]] = {}
|
|||||||
# Vault config: {name: path}
|
# Vault config: {name: path}
|
||||||
vault_config: Dict[str, str] = {}
|
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 text-based file extensions
|
||||||
SUPPORTED_EXTENSIONS = {
|
SUPPORTED_EXTENSIONS = {
|
||||||
".md", ".txt", ".log", ".py", ".js", ".ts", ".jsx", ".tsx",
|
".md", ".txt", ".log", ".py", ".js", ".ts", ".jsx", ".tsx",
|
||||||
@ -30,7 +40,15 @@ SUPPORTED_EXTENSIONS = {
|
|||||||
|
|
||||||
|
|
||||||
def load_vault_config() -> Dict[str, str]:
|
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] = {}
|
vaults: Dict[str, str] = {}
|
||||||
n = 1
|
n = 1
|
||||||
while True:
|
while True:
|
||||||
@ -43,8 +61,25 @@ def load_vault_config() -> Dict[str, str]:
|
|||||||
return vaults
|
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]:
|
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", [])
|
tags = post.metadata.get("tags", [])
|
||||||
if isinstance(tags, str):
|
if isinstance(tags, str):
|
||||||
tags = [t.strip().lstrip("#") for t in tags.split(",") if t.strip()]
|
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
|
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:
|
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", "")
|
title = post.metadata.get("title", "")
|
||||||
if not title:
|
if not title:
|
||||||
title = filepath.stem.replace("-", " ").replace("_", " ")
|
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:
|
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:
|
try:
|
||||||
return frontmatter.loads(raw)
|
return frontmatter.loads(raw)
|
||||||
except Exception as exc:
|
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]:
|
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)
|
vault_root = Path(vault_path)
|
||||||
files: List[Dict[str, Any]] = []
|
files: List[Dict[str, Any]] = []
|
||||||
tag_counts: Dict[str, int] = {}
|
tag_counts: Dict[str, int] = {}
|
||||||
@ -113,6 +198,9 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
|
|||||||
if ext == ".md":
|
if ext == ".md":
|
||||||
post = parse_markdown_file(raw)
|
post = parse_markdown_file(raw)
|
||||||
tags = _extract_tags(post)
|
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)
|
title = _extract_title(post, fpath)
|
||||||
content_preview = post.content[:200].strip()
|
content_preview = post.content[:200].strip()
|
||||||
|
|
||||||
@ -121,6 +209,7 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
|
|||||||
"title": title,
|
"title": title,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"content_preview": content_preview,
|
"content_preview": content_preview,
|
||||||
|
"content": raw[:SEARCH_CONTENT_LIMIT],
|
||||||
"size": stat.st_size,
|
"size": stat.st_size,
|
||||||
"modified": modified,
|
"modified": modified,
|
||||||
"extension": ext,
|
"extension": ext,
|
||||||
@ -138,7 +227,12 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
async def build_index() -> None:
|
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
|
global index, vault_config
|
||||||
vault_config = load_vault_config()
|
vault_config = load_vault_config()
|
||||||
|
|
||||||
@ -156,14 +250,35 @@ async def build_index() -> None:
|
|||||||
for name, task in tasks:
|
for name, task in tasks:
|
||||||
new_index[name] = await task
|
new_index[name] = await task
|
||||||
|
|
||||||
index.clear()
|
# Build O(1) lookup table for wikilink resolution
|
||||||
index.update(new_index)
|
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())
|
total_files = sum(len(v["files"]) for v in index.values())
|
||||||
logger.info(f"Index built: {len(index)} vaults, {total_files} total files")
|
logger.info(f"Index built: {len(index)} vaults, {total_files} total files")
|
||||||
|
|
||||||
|
|
||||||
async def reload_index() -> Dict[str, Any]:
|
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()
|
await build_index()
|
||||||
stats = {}
|
stats = {}
|
||||||
for name, data in index.items():
|
for name, data in index.items():
|
||||||
@ -172,39 +287,38 @@ async def reload_index() -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def get_vault_names() -> List[str]:
|
def get_vault_names() -> List[str]:
|
||||||
|
"""Return the list of all indexed vault names."""
|
||||||
return list(index.keys())
|
return list(index.keys())
|
||||||
|
|
||||||
|
|
||||||
def get_vault_data(vault_name: str) -> Optional[Dict[str, Any]]:
|
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)
|
return index.get(vault_name)
|
||||||
|
|
||||||
|
|
||||||
def find_file_in_index(link_target: str, current_vault: str) -> Optional[Dict[str, str]]:
|
def find_file_in_index(link_target: str, current_vault: str) -> 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()
|
target_lower = link_target.lower().strip()
|
||||||
if not target_lower.endswith(".md"):
|
if not target_lower.endswith(".md"):
|
||||||
target_lower += ".md"
|
target_lower += ".md"
|
||||||
|
|
||||||
def _search_vault(vname: str, vdata: Dict[str, Any]):
|
candidates = _file_lookup.get(target_lower, [])
|
||||||
for f in vdata["files"]:
|
if not candidates:
|
||||||
fpath = f["path"].lower()
|
|
||||||
fname = fpath.rsplit("/", 1)[-1]
|
|
||||||
if fname == target_lower or fpath == target_lower:
|
|
||||||
return {"vault": vname, "path": f["path"]}
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Search current vault first
|
# Prefer current vault when multiple vaults contain a match
|
||||||
if current_vault in index:
|
for c in candidates:
|
||||||
result = _search_vault(current_vault, index[current_vault])
|
if c["vault"] == current_vault:
|
||||||
if result:
|
return c
|
||||||
return result
|
return candidates[0]
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
383
backend/main.py
383
backend/main.py
@ -1,14 +1,16 @@
|
|||||||
import re
|
import re
|
||||||
import html as html_mod
|
import html as html_mod
|
||||||
import logging
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
import frontmatter
|
import frontmatter
|
||||||
import mistune
|
import mistune
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Query, Body
|
||||||
from fastapi.staticfiles import StaticFiles
|
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 (
|
from backend.indexer import (
|
||||||
build_index,
|
build_index,
|
||||||
@ -17,6 +19,7 @@ from backend.indexer import (
|
|||||||
get_vault_data,
|
get_vault_data,
|
||||||
find_file_in_index,
|
find_file_in_index,
|
||||||
parse_markdown_file,
|
parse_markdown_file,
|
||||||
|
_extract_tags,
|
||||||
SUPPORTED_EXTENSIONS,
|
SUPPORTED_EXTENSIONS,
|
||||||
)
|
)
|
||||||
from backend.search import search, get_all_tags
|
from backend.search import search, get_all_tags
|
||||||
@ -27,29 +30,182 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("obsigate")
|
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
|
# Resolve frontend path relative to this file
|
||||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Startup
|
# Path safety helper
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.on_event("startup")
|
def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
|
||||||
async def startup_event():
|
"""Resolve a relative path safely within the vault root.
|
||||||
logger.info("ObsiGate starting — building index...")
|
|
||||||
await build_index()
|
Prevents directory traversal attacks by ensuring the resolved
|
||||||
logger.info("ObsiGate ready.")
|
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:
|
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):
|
def _replace(match):
|
||||||
target = match.group(1).strip()
|
target = match.group(1).strip()
|
||||||
display = match.group(2).strip() if match.group(2) else target
|
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:
|
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)
|
converted = _convert_wikilinks(raw_md, vault_name)
|
||||||
md = mistune.create_markdown(
|
return _markdown_renderer(converted)
|
||||||
escape=False,
|
|
||||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
|
||||||
)
|
|
||||||
return md(converted)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# API Endpoints
|
# 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():
|
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 = []
|
result = []
|
||||||
for name, data in index.items():
|
for name, data in index.items():
|
||||||
result.append({
|
result.append({
|
||||||
@ -93,15 +275,27 @@ async def api_vaults():
|
|||||||
return result
|
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 = ""):
|
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)
|
vault_data = get_vault_data(vault_name)
|
||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
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():
|
if not target.exists():
|
||||||
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
|
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")):
|
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)
|
vault_data = get_vault_data(vault_name)
|
||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
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():
|
if not file_path.exists() or not file_path.is_file():
|
||||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
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")
|
@app.get("/api/file/{vault_name}/download")
|
||||||
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file")):
|
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)
|
vault_data = get_vault_data(vault_name)
|
||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
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():
|
if not file_path.exists() or not file_path.is_file():
|
||||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
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")
|
@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 = {}):
|
async def api_file_save(
|
||||||
"""Save file content."""
|
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)
|
vault_data = get_vault_data(vault_name)
|
||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
vault_root = Path(vault_data["path"])
|
||||||
file_path = vault_root / path
|
file_path = _resolve_safe_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")
|
|
||||||
|
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||||
|
|
||||||
# Get content from body
|
|
||||||
content = body.get('content', '')
|
content = body.get('content', '')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_path.write_text(content, encoding="utf-8")
|
file_path.write_text(content, encoding="utf-8")
|
||||||
logger.info(f"File saved: {vault_name}/{path}")
|
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)}")
|
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")):
|
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)
|
vault_data = get_vault_data(vault_name)
|
||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
vault_root = Path(vault_data["path"])
|
||||||
file_path = vault_root / path
|
file_path = _resolve_safe_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")
|
|
||||||
|
|
||||||
if not file_path.exists() or not file_path.is_file():
|
if not file_path.exists() or not file_path.is_file():
|
||||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
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)}")
|
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")):
|
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)
|
vault_data = get_vault_data(vault_name)
|
||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||||
|
|
||||||
vault_root = Path(vault_data["path"])
|
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():
|
if not file_path.exists() or not file_path.is_file():
|
||||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
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":
|
if ext == ".md":
|
||||||
post = parse_markdown_file(raw)
|
post = parse_markdown_file(raw)
|
||||||
|
|
||||||
# Extract metadata
|
# Extract metadata using shared indexer logic
|
||||||
tags = post.metadata.get("tags", [])
|
tags = _extract_tags(post)
|
||||||
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 = []
|
|
||||||
|
|
||||||
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
|
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
|
||||||
html_content = _render_markdown(post.content, vault_name)
|
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(
|
async def api_search(
|
||||||
q: str = Query("", description="Search query"),
|
q: str = Query("", description="Search query"),
|
||||||
vault: str = Query("all", description="Vault filter"),
|
vault: str = Query("all", description="Vault filter"),
|
||||||
tag: Optional[str] = Query(None, description="Tag 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)
|
results = search(q, vault_filter=vault, tag_filter=tag)
|
||||||
return {"query": q, "vault_filter": vault, "tag_filter": tag, "count": len(results), "results": results}
|
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")):
|
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)
|
tags = get_all_tags(vault_filter=vault)
|
||||||
return {"vault_filter": vault, "tags": tags}
|
return {"vault_filter": vault, "tags": tags}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/index/reload")
|
@app.get("/api/index/reload", response_model=ReloadResponse)
|
||||||
async def api_reload():
|
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()
|
stats = await reload_index()
|
||||||
return {"status": "ok", "vaults": stats}
|
return {"status": "ok", "vaults": stats}
|
||||||
|
|
||||||
|
|||||||
@ -1,34 +1,44 @@
|
|||||||
import re
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Dict, Any, Optional
|
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")
|
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]:
|
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:
|
if not tag_filter:
|
||||||
return []
|
return []
|
||||||
return [tag.strip().lstrip("#") for tag in tag_filter.split(",") if tag.strip()]
|
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:
|
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_content = content.lower()
|
||||||
lower_query = query.lower()
|
lower_query = query.lower()
|
||||||
pos = lower_content.find(lower_query)
|
pos = lower_content.find(lower_query)
|
||||||
@ -51,10 +61,30 @@ def search(
|
|||||||
query: str,
|
query: str,
|
||||||
vault_filter: str = "all",
|
vault_filter: str = "all",
|
||||||
tag_filter: Optional[str] = None,
|
tag_filter: Optional[str] = None,
|
||||||
|
limit: int = DEFAULT_SEARCH_LIMIT,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""Full-text search across indexed vaults with relevance scoring.
|
||||||
Full-text search across indexed vaults.
|
|
||||||
Returns scored results with snippets.
|
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 ""
|
query = query.strip() if query else ""
|
||||||
has_query = len(query) > 0
|
has_query = len(query) > 0
|
||||||
@ -63,6 +93,7 @@ def search(
|
|||||||
if not has_query and not selected_tags:
|
if not has_query and not selected_tags:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
query_lower = query.lower()
|
||||||
results: List[Dict[str, Any]] = []
|
results: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
for vault_name, vault_data in index.items():
|
for vault_name, vault_data in index.items():
|
||||||
@ -70,6 +101,7 @@ def search(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
for file_info in vault_data["files"]:
|
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):
|
if selected_tags and not all(tag in file_info["tags"] for tag in selected_tags):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -77,14 +109,32 @@ def search(
|
|||||||
snippet = file_info.get("content_preview", "")
|
snippet = file_info.get("content_preview", "")
|
||||||
|
|
||||||
if has_query:
|
if has_query:
|
||||||
# Title match (high weight)
|
title_lower = file_info["title"].lower()
|
||||||
if query.lower() in 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
|
score += 10
|
||||||
|
|
||||||
# Content match
|
# Path match (folder/filename relevance)
|
||||||
content = _read_file_content(vault_name, file_info["path"])
|
if query_lower in file_info["path"].lower():
|
||||||
if query.lower() in content.lower():
|
score += 5
|
||||||
score += 1
|
|
||||||
|
# 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)
|
snippet = _extract_snippet(content, query)
|
||||||
else:
|
else:
|
||||||
# Tag-only filter: all matching files get score 1
|
# Tag-only filter: all matching files get score 1
|
||||||
@ -102,11 +152,18 @@ def search(
|
|||||||
})
|
})
|
||||||
|
|
||||||
results.sort(key=lambda x: -x["score"])
|
results.sort(key=lambda x: -x["score"])
|
||||||
return results
|
return results[:limit]
|
||||||
|
|
||||||
|
|
||||||
def get_all_tags(vault_filter: Optional[str] = None) -> Dict[str, int]:
|
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] = {}
|
merged: Dict[str, int] = {}
|
||||||
for vault_name, vault_data in index.items():
|
for vault_name, vault_data in index.items():
|
||||||
if vault_filter and vault_name != vault_filter:
|
if vault_filter and vault_name != vault_filter:
|
||||||
|
|||||||
50
build.sh
50
build.sh
@ -1,25 +1,49 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Build multi-platform ObsiGate Docker image
|
# 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.
|
# Note: --load only works for single platform; use --push for multi-platform registry push.
|
||||||
# For local testing, build one platform at a time:
|
# For local testing, build one platform at a time (see below).
|
||||||
# docker buildx build --platform linux/amd64 --load -t obsigate:latest .
|
|
||||||
|
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64,linux/arm64,linux/arm/v7,linux/386 \
|
--platform "${PLATFORMS}" \
|
||||||
--tag obsigate:latest \
|
--tag "${IMAGE_NAME}:latest" \
|
||||||
--tag obsigate:1.0.0 \
|
--tag "${IMAGE_NAME}:${VERSION}" \
|
||||||
|
"$@" \
|
||||||
.
|
.
|
||||||
|
|
||||||
|
ok "Build terminé (v${VERSION})."
|
||||||
echo ""
|
echo ""
|
||||||
echo "Build terminé."
|
info "Pour un push vers un registry : $0 --push"
|
||||||
echo "Pour un push vers un registry : ajoutez --push au build."
|
info "Pour un test local (amd64) :"
|
||||||
echo "Pour un test local (amd64) :"
|
echo " docker buildx build --platform linux/amd64 --load -t ${IMAGE_NAME}:latest ."
|
||||||
echo " docker buildx build --platform linux/amd64 --load -t obsigate:latest ."
|
|
||||||
echo " docker-compose up -d"
|
echo " docker-compose up -d"
|
||||||
|
|||||||
@ -7,6 +7,12 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "2020:8080"
|
- "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:
|
volumes:
|
||||||
- /NFS/OBSIDIAN_DOC/Obsidian-RECETTES:/vaults/Obsidian-RECETTES:ro
|
- /NFS/OBSIDIAN_DOC/Obsidian-RECETTES:/vaults/Obsidian-RECETTES:ro
|
||||||
- /NFS/OBSIDIAN_DOC/Obsidian_IT:/vaults/Obsidian_IT:ro
|
- /NFS/OBSIDIAN_DOC/Obsidian_IT:/vaults/Obsidian_IT:ro
|
||||||
|
|||||||
122
frontend/app.js
122
frontend/app.js
@ -9,6 +9,7 @@
|
|||||||
let currentVault = null;
|
let currentVault = null;
|
||||||
let currentPath = null;
|
let currentPath = null;
|
||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
|
let searchAbortController = null;
|
||||||
let showingSource = false;
|
let showingSource = false;
|
||||||
let cachedRawSource = null;
|
let cachedRawSource = null;
|
||||||
let allVaults = [];
|
let allVaults = [];
|
||||||
@ -20,6 +21,7 @@
|
|||||||
let fallbackEditorEl = null;
|
let fallbackEditorEl = null;
|
||||||
let sidebarFilterCaseSensitive = false;
|
let sidebarFilterCaseSensitive = false;
|
||||||
let searchCaseSensitive = false;
|
let searchCaseSensitive = false;
|
||||||
|
let _iconDebounceTimer = null;
|
||||||
const panelState = {
|
const panelState = {
|
||||||
vault: true,
|
vault: true,
|
||||||
tag: true,
|
tag: true,
|
||||||
@ -78,7 +80,26 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Safe CDN helpers
|
// Safe CDN helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced icon creation — batches multiple rapid calls into one
|
||||||
|
* DOM scan to avoid excessive reflows when building large trees.
|
||||||
|
*/
|
||||||
function safeCreateIcons() {
|
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) {
|
if (typeof lucide !== "undefined" && lucide.createIcons) {
|
||||||
try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ }
|
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
|
// 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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -564,8 +633,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDirectory(vaultName, dirPath, container) {
|
async function loadDirectory(vaultName, dirPath, container) {
|
||||||
const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`;
|
// Show inline loading indicator while fetching directory contents
|
||||||
const data = await api(url);
|
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 = "";
|
container.innerHTML = "";
|
||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
@ -905,9 +983,17 @@
|
|||||||
if (active) active.classList.add("active");
|
if (active) active.classList.add("active");
|
||||||
} catch (e) { /* selector might fail on special chars */ }
|
} catch (e) { /* selector might fail on special chars */ }
|
||||||
|
|
||||||
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
|
// Show loading state while fetching
|
||||||
const data = await api(url);
|
const area = document.getElementById("content-area");
|
||||||
renderFile(data);
|
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) {
|
function renderFile(data) {
|
||||||
@ -1324,13 +1410,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function performSearch(query, vaultFilter, tagFilter) {
|
async function performSearch(query, vaultFilter, tagFilter) {
|
||||||
|
// Cancel any in-flight search request
|
||||||
|
if (searchAbortController) {
|
||||||
|
searchAbortController.abort();
|
||||||
|
}
|
||||||
|
searchAbortController = new AbortController();
|
||||||
|
|
||||||
showLoading();
|
showLoading();
|
||||||
|
|
||||||
let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`;
|
let url = `/api/search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultFilter)}`;
|
||||||
if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
|
if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
|
||||||
|
|
||||||
const data = await api(url);
|
try {
|
||||||
renderSearchResults(data, query, tagFilter);
|
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) {
|
function renderSearchResults(data, query, tagFilter) {
|
||||||
@ -1764,6 +1863,7 @@
|
|||||||
await Promise.all([loadVaults(), loadTags()]);
|
await Promise.all([loadVaults(), loadTags()]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to initialize ObsiGate:", err);
|
console.error("Failed to initialize ObsiGate:", err);
|
||||||
|
showToast("Erreur lors de l'initialisation");
|
||||||
}
|
}
|
||||||
|
|
||||||
safeCreateIcons();
|
safeCreateIcons();
|
||||||
|
|||||||
@ -157,7 +157,7 @@
|
|||||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- 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">
|
<div class="sidebar-tree" id="sidebar-tree">
|
||||||
<!-- Sidebar filter -->
|
<!-- Sidebar filter -->
|
||||||
<div class="sidebar-filter">
|
<div class="sidebar-filter">
|
||||||
@ -190,7 +190,7 @@
|
|||||||
<i data-lucide="chevron-down" style="width:16px;height:16px"></i>
|
<i data-lucide="chevron-down" style="width:16px;height:16px"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="sidebar-panel-content" id="vault-panel-content">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -212,8 +212,8 @@
|
|||||||
<div class="sidebar-resize-handle" id="sidebar-resize-handle"></div>
|
<div class="sidebar-resize-handle" id="sidebar-resize-handle"></div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<main class="content-area" id="content-area">
|
<main class="content-area" id="content-area" aria-label="Contenu principal">
|
||||||
<div class="welcome" id="welcome">
|
<div class="welcome" id="welcome" role="status">
|
||||||
<i data-lucide="library" style="width:48px;height:48px;color:var(--text-muted)"></i>
|
<i data-lucide="library" style="width:48px;height:48px;color:var(--text-muted)"></i>
|
||||||
<h2>ObsiGate</h2>
|
<h2>ObsiGate</h2>
|
||||||
<p>Sélectionnez un fichier dans la sidebar ou utilisez la recherche pour commencer.</p>
|
<p>Sélectionnez un fichier dans la sidebar ou utilisez la recherche pour commencer.</p>
|
||||||
|
|||||||
@ -26,6 +26,10 @@
|
|||||||
--scrollbar: #30363d;
|
--scrollbar: #30363d;
|
||||||
--resize-handle: #30363d;
|
--resize-handle: #30363d;
|
||||||
--overlay-bg: rgba(0,0,0,0.5);
|
--overlay-bg: rgba(0,0,0,0.5);
|
||||||
|
--danger: #ff7b72;
|
||||||
|
--danger-bg: #3d1a18;
|
||||||
|
--success: #3fb950;
|
||||||
|
--success-bg: #1a3d1f;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== THEME — LIGHT ===== */
|
/* ===== THEME — LIGHT ===== */
|
||||||
@ -47,6 +51,10 @@
|
|||||||
--scrollbar: #d0d7de;
|
--scrollbar: #d0d7de;
|
||||||
--resize-handle: #d0d7de;
|
--resize-handle: #d0d7de;
|
||||||
--overlay-bg: rgba(0,0,0,0.3);
|
--overlay-bg: rgba(0,0,0,0.3);
|
||||||
|
--danger: #cf222e;
|
||||||
|
--danger-bg: #ffebe9;
|
||||||
|
--success: #1a7f37;
|
||||||
|
--success-bg: #dafbe1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== BASE ===== */
|
/* ===== BASE ===== */
|
||||||
@ -1382,8 +1390,8 @@ select {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.editor-btn.danger:hover {
|
.editor-btn.danger:hover {
|
||||||
color: #ff7b72;
|
color: var(--danger);
|
||||||
border-color: #ff7b72;
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
.editor-body {
|
.editor-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -2059,3 +2067,55 @@ body.resizing-v {
|
|||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
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