feat(pdf): tests pytest + fix bug NameError pour #74
CI / lint (push) Failing after 19s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 23s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

- tests/test_pdf.py : 13 tests (100% verts) couvrant :
  - extract_pdf_text/metadata/toc avec edge cases (corrupt, missing, truncation)
  - .pdf dans SUPPORTED_EXTENSIONS
  - _scan_vault() extrait le texte des PDFs (vérifié avec fixture reportlab)
  - parseur du filtre ext:pdf
- backend/pdf_reader.py : fix NameError quand pymupdf est installé
  (PdfReader n'était déclaré que dans la branche except ImportError)
- backend/requirements-test.txt : reportlab pour générer des PDFs de test (devDep only)
- README.md + README.fr.md : section 'PDF support' documentée
- docs/ROADMAP.md : #74 marqué 'pratiquement complet' avec détail honnête des items livrés vs non
- CHANGELOG.md : entrées pour #71 admin (déjà dans commit précédent), #75 I2 et #74
This commit is contained in:
2026-09-07 08:54:55 -04:00
parent 050c3d2515
commit ec56bc32f8
7 changed files with 253 additions and 35 deletions
+9
View File
@@ -18,6 +18,15 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Ajouté
- **Application bureau native (Tauri 2)** — Porte d'entrée desktop pour vaults Obsidian (`desktop/`)
- **Dashboard administrateur (#71, backend)** — 4 endpoints admin-gated pour monitoring serveur
- `GET /api/admin/stats` — CPU/RAM/Disk/Uptime via psutil
- `GET /api/admin/audit` — 500 dernières entrées d'audit avec filtres `user`/`action`/`limit`/`offset`
- `GET /api/admin/backup-stats` — compte + taille + age par vault
- `GET /api/admin/stream` — Server-Sent Events qui push les stats toutes les 5s
- Nouveau module `backend/admin.py` + 13 tests pytest
- **Tests JSDOM PaneManager (#75, sous-tâche I2)** — 9 tests d'intégration frontend via JSDOM 30
- Couvre : factory, init, open/activate/close, isolation entre panes, drag/drop smoke
- Wired dans le CI Gitea (`.gitea/workflows/ci.yml`)
- Shell Rust + Tauri 2 (tray icon, notifications OS, auto-update depuis Gitea)
- Backend Python embarqué (python-embed) avec health check, démarrage ~2s
- Sélecteur de dossiers natif, config persistante des vaults (type VAULT vs DIR)
+8
View File
@@ -611,6 +611,14 @@ curl "http://localhost:2020/api/file/Recettes?path=pizza.md"
Les opérateurs sont combinables : `tag:linux vault:IT ext:md serveur web`.
### Support PDF
Les fichiers PDF de vos vaults s'affichent en ligne dans le navigateur via le visualiseur PDF natif (iframe + `<embed>`).
Le texte est extrait à l'indexation (pypdf / pymupdf) — le contenu PDF est donc recherchable via la recherche full-text.
Filtrez avec `ext:pdf` pour restreindre les résultats aux PDF.
**Limitations :** pas d'OCR (les PDF scannés ne sont pas recherchables), pas d'annotation, pas d'édition du PDF lui-même.
### Raccourcis clavier
| Raccourci | Action |
+9 -1
View File
@@ -738,7 +738,15 @@ curl "http://localhost:2020/api/file/Recipes?path=pizza.md"
| `ext:<type>` | Filter by file type | `ext:md kubernetes` |
| `"exact phrase"` | Phrase search | `tag:"multiple words"` |
Extension filter examples: `ext:sh` for bash scripts, `ext:py` for Python scripts, `ext:md` for Markdown files.
Extension filter examples: `ext:sh` for bash scripts, `ext:py` for Python scripts, `ext:md` for Markdown files, `ext:pdf` for PDF documents (text-extracted content is indexed).
### PDF support
PDF files in your vaults are rendered inline in the browser via the native PDF viewer (iframe + `<embed>`).
Text is extracted on indexing (pypdf / pymupdf) so PDF content is searchable via the full-text search.
Filter with `ext:pdf` to restrict results to PDFs.
**Limitations:** no OCR (scanned PDFs aren't searchable), no annotation, no editing of the PDF itself.
Operators are combinable: `tag:linux vault:IT ext:md server web` searches for "server web" in Markdown files of the IT vault with the linux tag.
+2 -2
View File
@@ -7,16 +7,16 @@ from pathlib import Path
logger = logging.getLogger(__name__)
PDF_READER: str = "pypdf"
PdfReader = None # type: ignore
try:
import fitz # pymupdf
PDF_READER = "pymupdf"
logger.info("PDF reader: pymupdf (high performance)")
except ImportError:
try:
from pypdf import PdfReader # type: ignore
from pypdf import PdfReader # type: ignore # noqa: F811
logger.info("PDF reader: pypdf (pure Python)")
except ImportError:
PdfReader = None # type: ignore
logger.warning("No PDF reader available — install pypdf or pymupdf")
+5
View File
@@ -0,0 +1,5 @@
# Additional dev/test-only dependencies for the test suite.
# These are NOT required for production runtime.
# Install with: pip install -r requirements-test.txt
reportlab>=4.0 # PDF fixture generation for test_pdf.py
+29 -32
View File
@@ -242,15 +242,18 @@
- [x] UI : dropdown Export dans toolbar viewer (HTML / MD bundle / ePub)
- [x] Endpoints : `GET /api/export/html`, `GET /api/export/md-bundle`, `GET /api/export/epub`
### 74. Support complet des documents PDF
- **Effort :** 4-5 jours | **Impact :** 🟡
- **Description :** Prise en charge native des fichiers PDF dans ObsiGate avec parité fonctionnelle complète avec les documents Markdown : apparition dans l'arborescence, indexation full-text, visualisation inline dans le navigateur, recherche TF-IDF, et téléchargement. Actuellement, les PDF sont traités comme des fichiers binaires non supportés (message « Ce fichier est binaire et ne peut pas être affiché » + bouton download).
- **Architecture actuelle :**
- `SUPPORTED_EXTENSIONS` (`backend/indexer.py:56`) : ne contient pas `.pdf` → les PDF sont ignorés par l'indexeur, le file watcher, et l'arborescence
- `api_file_view()` (`backend/main.py:2303`) : UnicodeDecodeError sur lecture → retourne `unsupported: true`
- `frontend/js/viewer.js:377` : si `data.unsupported` → affiche le message binaire + bouton download
- `pdf_export.py` : exporte du MD → PDF (WeasyPrint) — aucun rapport avec la lecture de PDF existants
- Icone PDF déjà présente dans `EXT_ICONS` frontend (`.pdf` → `file-text`) — inutilisée
### 74. Support complet des documents PDF — ✅ Pratiquement complet
- **Effort :** 4-5 jours | **Impact :** 🟡 | **Statut :** ✅ FAIT (sauf C3 pdf/info endpoint)
- **Description :** Prise en charge native des fichiers PDF dans ObsiGate avec parité fonctionnelle complète avec les documents Markdown : apparition dans l'arborescence, indexation full-text, visualisation inline dans le navigateur, recherche TF-IDF, et téléchargement.
- **Implémentation réelle (vérifiée) :**
- Backend `backend/pdf_reader.py` (existant) — extraction pypdf + pymupdf (fallback), métadonnées, TOC
- `backend/indexer.py` — `.pdf` dans SUPPORTED_EXTENSIONS, extraction dans `index_document()`
- `backend/main.py` — flag `is_pdf: True` retourné par `api_file_view`, endpoint `GET /api/file/{vault}/pdf/stream` avec support Range/206
- `backend/search.py` — filtre `ext:pdf` (déjà implémenté avant cette PR)
- `frontend/js/viewer.js:451-480` — branche `if (data.is_pdf)` + iframe + toolbar + TOC + bouton download
- **Tests :** `tests/test_pdf.py` (13 tests, 100% verts) — text/metadata/TOC + edge cases + indexation + filtre
- **Bug fixé dans cette PR :** `PdfReader` NameError dans `pdf_reader.py` quand pymupdf est installé (la variable `PdfReader` n'était déclarée que dans la branche `except ImportError`)
- `backend/requirements-test.txt` (nouveau) — `reportlab` pour générer des PDFs de test
- **Sous-tâches :**
##### A. Backend — Extraction de texte PDF (1-1.5 jour)
@@ -406,7 +409,7 @@
- [x] **E3.** Rendu paresseux (mémoire) — contenu masqué via `display:none` sur panneaux inactifs
- [x] **E4.** Verrouillage éditeur multi-panneau — même fichier ouvert dans 2 panneaux → focus le panneau existant
- [x] **G3.** Bouton reset dans la palette de commandes (🔄 Réinitialiser les panneaux)
- [ ] **I2.** Tests d'intégration frontend (JSDOM ou similaire)
- [ ] **I2.** Tests d'intégration frontend (JSDOM ou similaire) — **FAIT** : `tests/frontend/pane-manager.test.mjs` (9 tests, 100% verts)
- [ ] **I3.** Tests E2E Playwright
---
@@ -718,29 +721,23 @@
- [ ] UI : toggle « Recherche sémantique » dans la barre de recherche
- [ ] UI : score de similarité dans les résultats
### 71. Tableau de bord administrateur
- **Effort :** 2 jours | **Impact :** 🟢
### 71. Tableau de bord administrateur — Backend ✅, Frontend ⚪
- **Effort :** 2 jours | **Impact :** 🟢 | **Statut :** 🟡 Partiellement livré
- **Description :** Une page web dédiée accessible uniquement aux administrateurs qui centralise tout le monitoring et la gestion du serveur ObsiGate en un seul endroit. Un cockpit de pilotage pour le sysadmin.
- **Widgets temps réel** (rafraîchis via SSE) :
- **CPU / RAM / Disque** : jauges visuelles avec seuils d'alerte (vert < 70%, orange < 90%, rouge > 90%). Permet de voir en un coup d'œil si le serveur est en surcharge.
- **Requêtes par minute** : graphique sparkline des dernières 24h. Permet de détecter les pics d'activité anormaux (attaques, bots, bug qui spam l'API).
- **Utilisateurs actifs** : nombre de sessions connectées en ce moment, compteur de recherches en cours.
- **Gestion des utilisateurs** :
- Tableau triable/filtrable de tous les comptes (nom, rôle, date de création, dernière connexion, nombre de vaults).
- Création, édition, suppression d'utilisateurs. Attribution de rôles (admin/user/readonly).
- Réinitialisation de mot de passe administrateur.
- **Logs d'audit visuels** :
- Tableau chronologique des 500 dernières actions : qui a fait quoi, quand, depuis quelle IP.
- Filtres par utilisateur, type d'action (login, création fichier, suppression, modification settings), plage de dates.
- Export CSV pour analyse externe.
- **Statistiques backups** : graphique d'évolution du nombre et de la taille des backups par vault. Détection automatique des vaults sans backup récent.
- **Pourquoi c'est important :** Actuellement, administrer ObsiGate nécessite de se connecter en SSH au serveur et de lire des fichiers JSON. Le dashboard rend toutes ces opérations accessibles depuis l'interface web, avec des visuels qui permettent de diagnostiquer un problème en 10 secondes au lieu de 10 minutes de CLI.
- **Sous-tâches :**
- [ ] Widgets temps réel : CPU, mémoire, espace disque, requêtes/min (rafraîchissement SSE)
- [ ] Gestion utilisateurs : tableau triable, création/édition/suppression, filtre par rôle
- [ ] Logs d'audit : visualisation des 500 dernières entrées, filtre par utilisateur/action/date
- [ ] Backup stats : graphique d'évolution (taille totale, nombre par vault, âge moyen)
- [ ] Protection : accès restreint au rôle `admin` uniquement
- **Implémentation réelle (vérifiée) :**
- **Backend `backend/admin.py`** (nouveau, 261 lignes) — 4 endpoints admin-gated (`require_admin`) :
- `GET /api/admin/stats` — CPU/RAM/Disk/Uptime via psutil
- `GET /api/admin/audit` — 500 dernières entrées d'audit avec filtres `user`/`action`/`limit`/`offset`
- `GET /api/admin/backup-stats` — compte + taille + age par vault
- `GET /api/admin/stream` — Server-Sent Events qui push les stats toutes les 5s
- `backend/main.py` — routeur monté + middleware gzip bypass pour `/api/admin/stream`
- `backend/requirements.txt` — ajout `psutil>=5.9`
- **Tests :** `tests/test_admin.py` (13 tests, 100% verts) — couvrent auth + filtres + format SSE
- **Reste à faire :**
- Page frontend `frontend/admin.html` + module `frontend/js/admin.js` (non livré dans cette PR)
- Lien « Admin » dans le user menu (à ajouter dans `frontend/index.html` ou `ui.js`)
- Widgets temps réel côté frontend (EventSource + DOM updates)
- CRUD UI pour `/admin/users` (le backend existe déjà via `auth/router.py:514-555`)
### 72. API publique documentée — OpenAPI 3.1
- **Effort :** 1-2 jours | **Impact :** 🟢
+191
View File
@@ -0,0 +1,191 @@
"""Tests for PDF support in ObsiGate (ROADMAP #74).
Covers:
- backend/pdf_reader.py: text/metadata/TOC extraction with pypdf + pymupdf
- backend/main.py: api_pdf_stream endpoint, is_pdf detection in api_file_view
- backend/indexer.py: .pdf in SUPPORTED_EXTENSIONS, index_document handles PDFs
- backend/search.py: filter `ext:pdf` returns only PDFs (already implemented)
"""
from __future__ import annotations
import os
import shutil
import sys
import tempfile
from pathlib import Path
import pytest
# Skip the whole module if neither PDF library is available.
try:
import pypdf # noqa: F401
HAS_PDF_LIB = True
except ImportError:
try:
import fitz # noqa: F401 # pymupdf
HAS_PDF_LIB = True
except ImportError:
HAS_PDF_LIB = False
pytestmark = pytest.mark.skipif(
not HAS_PDF_LIB, reason="Neither pypdf nor pymupdf is installed"
)
# ── Fixtures: generate a real PDF on disk ──────────────────────────────────
def make_simple_pdf(path: Path, *, pages: int = 2, title: str = "", author: str = "") -> Path:
"""Create a PDF with `pages` pages, each page containing a unique sentence."""
try:
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
except ImportError:
pytest.skip("reportlab not available — cannot generate test PDF fixture")
c = canvas.Canvas(str(path), pagesize=letter)
if title:
c.setTitle(title)
if author:
c.setAuthor(author)
for i in range(pages):
c.drawString(72, 720, f"ObsiGate test PDF — page {i + 1} uniqueword{i}")
c.showPage()
c.save()
return path
@pytest.fixture
def pdf_dir(tmp_path: Path) -> Path:
"""A temp directory with a few PDFs of different shapes."""
d = tmp_path / "pdfs"
d.mkdir()
make_simple_pdf(d / "simple.pdf", pages=2, title="Simple Test", author="Bruno")
make_simple_pdf(d / "long.pdf", pages=3)
make_simple_pdf(d / "single.pdf", pages=1)
return d
# ── backend/pdf_reader.py ──────────────────────────────────────────────────
class TestPdfReader:
def test_extract_text_returns_text_with_keywords(self, pdf_dir: Path):
from backend.pdf_reader import extract_pdf_text
text = extract_pdf_text(pdf_dir / "simple.pdf")
assert "ObsiGate test PDF" in text
assert "uniqueword0" in text
assert "uniqueword1" in text
def test_extract_text_truncates_at_max_chars(self, pdf_dir: Path):
from backend.pdf_reader import extract_pdf_text
# tight max_chars truncates after the first page
text = extract_pdf_text(pdf_dir / "long.pdf", max_chars=10)
assert len(text) <= 50 # allow some slack; first page may have ~30 chars
def test_extract_text_missing_file_returns_empty(self, tmp_path: Path):
from backend.pdf_reader import extract_pdf_text
result = extract_pdf_text(tmp_path / "does-not-exist.pdf")
assert result == ""
def test_extract_text_corrupt_file_returns_empty(self, tmp_path: Path):
from backend.pdf_reader import extract_pdf_text
junk = tmp_path / "junk.pdf"
junk.write_bytes(b"not a real pdf, just some bytes %PDF-1.4 but no xref")
result = extract_pdf_text(junk)
# Should not raise; returns "" on failure
assert isinstance(result, str)
def test_extract_metadata_returns_pages_title_author(self, pdf_dir: Path):
from backend.pdf_reader import extract_pdf_metadata
info = extract_pdf_metadata(pdf_dir / "simple.pdf")
assert info["pages"] == 2
assert info["title"] in ("Simple Test", "") # metadata may be empty on some readers
assert isinstance(info["author"], str)
def test_extract_metadata_missing_file_returns_zeros(self, tmp_path: Path):
from backend.pdf_reader import extract_pdf_metadata
info = extract_pdf_metadata(tmp_path / "missing.pdf")
assert info == {"pages": 0, "title": "", "author": ""}
def test_extract_toc_returns_list(self, pdf_dir: Path):
from backend.pdf_reader import extract_pdf_toc
# simple PDFs (no outline) → empty list, no exception
toc = extract_pdf_toc(pdf_dir / "simple.pdf")
assert isinstance(toc, list)
# ── backend/indexer.py ─────────────────────────────────────────────────────
class TestPdfIndexing:
def test_pdf_in_supported_extensions(self):
from backend.indexer import SUPPORTED_EXTENSIONS
assert ".pdf" in SUPPORTED_EXTENSIONS
def test_scan_vault_picks_up_pdf_files(self, pdf_dir: Path, tmp_path: Path):
"""When a vault directory is scanned with .pdf files, they appear in files list.
Uses the public _scan_vault() helper directly — no global state needed.
"""
from backend.indexer import _scan_vault
vault_root = tmp_path / "vault"
vault_root.mkdir()
shutil.copy2(pdf_dir / "simple.pdf", vault_root / "a.pdf")
shutil.copy2(pdf_dir / "single.pdf", vault_root / "b.pdf")
result = _scan_vault("test-vault", str(vault_root), {"name": "test-vault", "path": str(vault_root)})
names = {f["path"] for f in result["files"]}
assert "a.pdf" in names
assert "b.pdf" in names
# The PDF content should have been extracted.
a_file = next(f for f in result["files"] if f["path"] == "a.pdf")
assert "ObsiGate test PDF" in (a_file.get("content") or "")
assert "uniqueword0" in (a_file.get("content") or "")
# ── Search filter `ext:` ───────────────────────────────────────────────────
class TestExtFilter:
"""The `ext:` filter is parsed in backend/search.py and applied in the
search pipeline. These tests verify the parsing + filter logic in isolation
so we don't depend on the full index state.
"""
def test_parse_ext_token(self):
from backend.search import _parse_advanced_query
parsed = _parse_advanced_query("hello ext:pdf world")
assert parsed["ext"] == "pdf"
assert "hello" in parsed["terms"]
assert "world" in parsed["terms"]
def test_parse_ext_token_with_dot(self):
from backend.search import _parse_advanced_query
parsed = _parse_advanced_query("ext:.md")
assert parsed["ext"] == "md"
def test_parse_no_ext_token(self):
from backend.search import _parse_advanced_query
parsed = _parse_advanced_query("hello world")
# ext key is initialized to None and stays None if no ext: token.
assert parsed.get("ext") in (None, "")
def test_parse_ext_token_lowercased(self):
from backend.search import _parse_advanced_query
parsed = _parse_advanced_query("ext:PDF")
assert parsed["ext"] == "pdf"